diff --git a/.gitignore b/.gitignore index 5f39638..6969694 100644 --- a/.gitignore +++ b/.gitignore @@ -29,9 +29,12 @@ node_modules/ # Local git worktrees .worktrees/ +/asf-doc/ +/asp-marketplace/ # OS .DS_Store Thumbs.db -.claude/* \ No newline at end of file +.claude/* +.asp/ \ No newline at end of file diff --git a/asf-doc b/asf-doc deleted file mode 160000 index 03df728..0000000 --- a/asf-doc +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 03df72860168810f7ce784c9c5798d05f9da99b2 diff --git a/asp-marketplace b/asp-marketplace deleted file mode 160000 index b6f9aaf..0000000 --- a/asp-marketplace +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b6f9aaff82a2784c6336255e0650a3fe9bd36daf diff --git a/backend/apps/agent_api/__init__.py b/backend/apps/agent_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/agent_api/apps.py b/backend/apps/agent_api/apps.py new file mode 100644 index 0000000..ad4c928 --- /dev/null +++ b/backend/apps/agent_api/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AgentApiConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.agent_api" diff --git a/backend/apps/agent_api/responses.py b/backend/apps/agent_api/responses.py new file mode 100644 index 0000000..0bd0e49 --- /dev/null +++ b/backend/apps/agent_api/responses.py @@ -0,0 +1,24 @@ +from uuid import uuid4 + +from rest_framework.response import Response + + +def request_id(request): + return request.headers.get("X-Request-ID") or f"req_{uuid4().hex}" + + +def agent_response(request, *, operation, data, status=200, pagination=None): + meta = { + "operation": operation, + "request_id": request_id(request), + } + if pagination is not None: + meta["pagination"] = pagination + return Response({"data": data, "meta": meta}, status=status) + + +def pagination_meta(page): + return { + "next_cursor": page.next_cursor, + "has_more": page.has_more, + } diff --git a/backend/apps/agent_api/serializers.py b/backend/apps/agent_api/serializers.py new file mode 100644 index 0000000..1061ba6 --- /dev/null +++ b/backend/apps/agent_api/serializers.py @@ -0,0 +1,162 @@ +import mimetypes + +from django.urls import reverse + + +def dt(value): + return value.isoformat() if value else None + + +def serialize_case(case, *, include_related=False): + data = { + "case_id": case.case_id, + "title": case.title, + "severity": case.severity, + "confidence": case.confidence, + "impact": case.impact, + "priority": case.priority, + "status": case.status, + "verdict": case.verdict, + "severity_ai": case.severity_ai, + "confidence_ai": case.confidence_ai, + "impact_ai": case.impact_ai, + "priority_ai": case.priority_ai, + "verdict_ai": case.verdict_ai, + "summary": case.summary, + "correlation_uid": case.correlation_uid, + "tags": case.tags, + "created_at": dt(case.created_at), + "updated_at": dt(case.updated_at), + } + if include_related: + data["alerts"] = [serialize_alert(alert, include_related=False) for alert in case.alerts.all()[:50]] + return data + + +def serialize_alert(alert, *, include_related=False): + data = { + "alert_id": alert.alert_id, + "case_id": alert.case.case_id if alert.case_id else "", + "title": alert.title, + "severity": alert.severity, + "confidence": alert.confidence, + "impact": alert.impact, + "status": alert.status, + "correlation_uid": alert.correlation_uid, + "source_uid": alert.source_uid, + "rule_id": alert.rule_id, + "rule_name": alert.rule_name, + "created_at": dt(alert.created_at), + "updated_at": dt(alert.updated_at), + } + if include_related: + data["artifacts"] = [serialize_artifact(artifact, include_related=False) for artifact in alert.artifacts.all()[:50]] + return data + + +def serialize_artifact(artifact, *, include_related=False): + data = { + "artifact_id": artifact.artifact_id, + "name": artifact.name, + "type": artifact.type, + "role": artifact.role, + "value": artifact.value, + "created_at": dt(artifact.created_at), + "updated_at": dt(artifact.updated_at), + } + if include_related: + data["alerts"] = [ + { + "alert_id": alert.alert_id, + "case_id": alert.case.case_id if alert.case_id else "", + "title": alert.title, + "severity": alert.severity, + "status": alert.status, + } + for alert in artifact.alerts.select_related("case").all()[:50] + ] + return data + + +def serialize_knowledge(knowledge): + return { + "knowledge_id": knowledge.knowledge_id, + "title": knowledge.title, + "body": knowledge.body, + "expires_at": dt(knowledge.expires_at), + "source": knowledge.source, + "tags": knowledge.tags, + "case_id": knowledge.case.case_id if knowledge.case_id else "", + "created_at": dt(knowledge.created_at), + "updated_at": dt(knowledge.updated_at), + } + + +def serialize_attachment(attachment, *, request=None): + path = reverse("attachment-download", kwargs={"access_key": attachment.access_key}) + url = request.build_absolute_uri(path) if request is not None else path + return { + "file_key": str(attachment.access_key), + "filename": attachment.filename, + "size": attachment.size, + "content_type": mimetypes.guess_type(attachment.filename)[0] or "application/octet-stream", + "download_url": url, + "uploaded_at": dt(attachment.uploaded_at), + } + + +def serialize_comment(comment, *, request=None): + return { + "id": comment.id, + "target": { + "content_type": comment.content_type.model, + "object_id": comment.object_id, + }, + "body": comment.body, + "author": comment.author.username if comment.author else "", + "parent_id": comment.parent_id, + "mentions": [user.username for user in comment.mentions.all()], + "attachments": [serialize_attachment(item, request=request) for item in comment.attachments.all()], + "created_at": dt(comment.created_at), + "updated_at": dt(comment.updated_at), + } + + +def serialize_enrichment(enrichment): + target = "" + if enrichment.case_id: + target = enrichment.case.case_id + elif enrichment.alert_id: + target = enrichment.alert.alert_id + elif enrichment.artifact_id: + target = enrichment.artifact.artifact_id + return { + "enrichment_id": enrichment.enrichment_id, + "target_id": target, + "name": enrichment.name, + "type": enrichment.type, + "provider": enrichment.provider, + "uid": enrichment.uid, + "value": enrichment.value, + "desc": enrichment.desc, + "data": enrichment.data, + "created_at": dt(enrichment.created_at), + "updated_at": dt(enrichment.updated_at), + } + + +def serialize_playbook(playbook, *, include_related=False): + data = { + "playbook_id": playbook.playbook_id, + "case_id": playbook.case.case_id if playbook.case_id else "", + "name": playbook.name, + "user_input": playbook.user_input, + "job_status": playbook.job_status, + "job_id": playbook.job_id, + "remark": playbook.remark, + "created_at": dt(playbook.created_at), + "updated_at": dt(playbook.updated_at), + } + if include_related and playbook.case_id: + data["case"] = serialize_case(playbook.case, include_related=False) + return data diff --git a/backend/apps/agent_api/urls.py b/backend/apps/agent_api/urls.py new file mode 100644 index 0000000..d49610b --- /dev/null +++ b/backend/apps/agent_api/urls.py @@ -0,0 +1,65 @@ +from django.urls import path + +from .views import ( + AgentVersionView, + AlertDetailView, + AlertListView, + ArtifactDetailView, + ArtifactListView, + CaseAIAnalysisView, + CaseDetailView, + CaseListView, + CommentListCreateView, + EnrichmentCreateView, + FileDetailView, + FileReadTextView, + FileUploadView, + KnowledgeDetailView, + KnowledgeListView, + CMDBLookupView, + DevStreamHeadView, + DevStreamReadView, + PlaybookDetailView, + PlaybookListView, + PlaybookRunView, + PlaybookTemplateListView, + SIEMAdaptiveQueryView, + SIEMDiscoverFieldsView, + SIEMESQLQueryView, + SIEMKeywordSearchView, + SIEMSchemaView, + SIEMSPLQueryView, + ThreatIntelQueryView, +) + +urlpatterns = [ + path("version/", AgentVersionView.as_view(), name="agent-api-version"), + path("cases/", CaseListView.as_view(), name="agent-api-case-list"), + path("cases//", CaseDetailView.as_view(), name="agent-api-case-detail"), + path("cases//ai-analysis/", CaseAIAnalysisView.as_view(), name="agent-api-case-ai-analysis"), + path("alerts/", AlertListView.as_view(), name="agent-api-alert-list"), + path("alerts//", AlertDetailView.as_view(), name="agent-api-alert-detail"), + path("artifacts/", ArtifactListView.as_view(), name="agent-api-artifact-list"), + path("artifacts//", ArtifactDetailView.as_view(), name="agent-api-artifact-detail"), + path("knowledge/", KnowledgeListView.as_view(), name="agent-api-knowledge-list"), + path("knowledge//", KnowledgeDetailView.as_view(), name="agent-api-knowledge-detail"), + path("comments/", CommentListCreateView.as_view(), name="agent-api-comment-list-create"), + path("files/", FileUploadView.as_view(), name="agent-api-file-upload"), + path("files//", FileDetailView.as_view(), name="agent-api-file-detail"), + path("files//read-text/", FileReadTextView.as_view(), name="agent-api-file-read-text"), + path("enrichments/", EnrichmentCreateView.as_view(), name="agent-api-enrichment-create"), + path("playbooks/templates/", PlaybookTemplateListView.as_view(), name="agent-api-playbook-template-list"), + path("playbooks/", PlaybookListView.as_view(), name="agent-api-playbook-list"), + path("playbooks/run/", PlaybookRunView.as_view(), name="agent-api-playbook-run"), + path("playbooks//", PlaybookDetailView.as_view(), name="agent-api-playbook-detail"), + path("siem/schema/", SIEMSchemaView.as_view(), name="agent-api-siem-schema"), + path("siem/search/keyword/", SIEMKeywordSearchView.as_view(), name="agent-api-siem-keyword-search"), + path("siem/query/adaptive/", SIEMAdaptiveQueryView.as_view(), name="agent-api-siem-adaptive-query"), + path("siem/query/spl/", SIEMSPLQueryView.as_view(), name="agent-api-siem-spl-query"), + path("siem/query/esql/", SIEMESQLQueryView.as_view(), name="agent-api-siem-esql-query"), + path("siem/fields/discover/", SIEMDiscoverFieldsView.as_view(), name="agent-api-siem-discover-fields"), + path("threat-intel/query/", ThreatIntelQueryView.as_view(), name="agent-api-threat-intel-query"), + path("cmdb/lookup/", CMDBLookupView.as_view(), name="agent-api-cmdb-lookup"), + path("dev/streams/head/", DevStreamHeadView.as_view(), name="agent-api-dev-stream-head"), + path("dev/streams/message/", DevStreamReadView.as_view(), name="agent-api-dev-stream-read"), +] diff --git a/backend/apps/agent_api/utils.py b/backend/apps/agent_api/utils.py new file mode 100644 index 0000000..3dc0157 --- /dev/null +++ b/backend/apps/agent_api/utils.py @@ -0,0 +1,42 @@ +from datetime import timezone as datetime_timezone + +from django.utils.dateparse import parse_datetime +from rest_framework.exceptions import ValidationError + + +def bool_param(value, *, default=False): + if value is None: + return default + if isinstance(value, bool): + return value + normalized = str(value).strip().lower() + if not normalized: + return default + return normalized not in {"0", "false", "no", "off"} + + +def list_param(query_params, name): + values = [] + for raw_value in query_params.getlist(name): + if raw_value is None: + continue + for item in str(raw_value).split(","): + item = item.strip() + if item: + values.append(item) + return values + + +def parse_tags(query_params): + return list_param(query_params, "tag") + list_param(query_params, "tags") + + +def parse_timezone_aware_datetime(value, field_name): + if value in (None, ""): + return None + parsed = parse_datetime(str(value).strip()) + if parsed is None: + raise ValidationError({field_name: "Must be ISO 8601 datetime with timezone."}) + if parsed.tzinfo is None or parsed.utcoffset() is None: + raise ValidationError({field_name: "Must include timezone, e.g. 2026-06-23T12:00:00Z."}) + return parsed.astimezone(datetime_timezone.utc) diff --git a/backend/apps/agent_api/views.py b/backend/apps/agent_api/views.py new file mode 100644 index 0000000..d8c2744 --- /dev/null +++ b/backend/apps/agent_api/views.py @@ -0,0 +1,750 @@ +import json +import mimetypes + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from django.db.models import Q +from rest_framework import parsers, permissions, status +from rest_framework.exceptions import NotFound, ValidationError +from rest_framework.views import APIView + +from apps.accounts.permissions import IsBusinessWriterOrReadOnly +from apps.accounts.models import UserApiKey +from apps.alerts.models import Alert +from apps.artifacts.models import Artifact +from apps.attachments.models import Attachment +from apps.audit.context import audit_actor +from apps.cases.models import Case +from apps.comments.models import Comment +from apps.comments.services import create_record_comment +from apps.common.cursor_pagination import paginate_created_at_cursor +from apps.common.redis_stream import RedisStreamClient +from apps.enrichments.models import Enrichment, EnrichmentProvider +from apps.knowledge.models import Knowledge +from apps.agentic.services.playbooks import create_pending_playbook_run, list_playbook_definitions +from apps.playbooks.models import Playbook +from integrations.cmdb.service import lookup_artifact_context +from integrations.siem import service as siem_service +from integrations.siem.models import ( + AdaptiveQueryInput, + DiscoverIndexFieldsInput, + ESQLQueryInput, + KeywordSearchInput, + SPLQueryInput, + SchemaExplorerInput, +) +from integrations.threat_intel.service import query_indicator + +from .responses import agent_response, pagination_meta +from .serializers import ( + serialize_alert, + serialize_artifact, + serialize_attachment, + serialize_case, + serialize_comment, + serialize_enrichment, + serialize_knowledge, + serialize_playbook, +) +from .utils import bool_param, list_param, parse_tags, parse_timezone_aware_datetime + + +API_VERSION = "v1" +MIN_CLI_VERSION = "0.1.0" +SERVER_VERSION = "0.1.0" +FOUNDATION_CAPABILITIES = [ + "agent.version", + "case.list", + "case.show", + "case.update_ai", + "alert.list", + "alert.show", + "artifact.list", + "artifact.show", + "knowledge.search", + "knowledge.show", + "knowledge.update", + "comment.list", + "comment.add", + "file.upload", + "file.info", + "file.read_text", + "enrichment.create", + "playbook.template.list", + "playbook.list", + "playbook.show", + "playbook.run", + "siem.schema", + "siem.search.keyword", + "siem.query.adaptive", + "siem.fields.discover", + "siem.query.spl", + "siem.query.esql", + "ti.query", + "cmdb.lookup", + "dev.stream.head", + "dev.stream.read", +] + + +class AgentVersionView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + api_key = request.auth if isinstance(request.auth, UserApiKey) else None + data = { + "api_version": API_VERSION, + "server_version": getattr(settings, "ASP_VERSION", SERVER_VERSION), + "min_cli_version": MIN_CLI_VERSION, + "capabilities": FOUNDATION_CAPABILITIES, + "user": { + "username": request.user.username, + "email": request.user.email, + "role": request.user.role, + "is_superuser": request.user.is_superuser, + }, + "api_key": _api_key_payload(api_key), + } + return agent_response(request, operation="agent.version", data=data) + + +def _api_key_payload(api_key): + if api_key is None: + return None + return { + "name": api_key.name, + "expires_at": api_key.expires_at.isoformat() if api_key.expires_at else None, + "last_used_at": api_key.last_used_at.isoformat() if api_key.last_used_at else None, + } + + +class CaseListView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + queryset = Case.objects.all() + if statuses := list_param(request.query_params, "status"): + queryset = queryset.filter(status__in=statuses) + if severities := list_param(request.query_params, "severity"): + queryset = queryset.filter(severity__in=severities) + if confidences := list_param(request.query_params, "confidence"): + queryset = queryset.filter(confidence__in=confidences) + if verdicts := list_param(request.query_params, "verdict"): + queryset = queryset.filter(verdict__in=verdicts) + if correlation_uid := request.query_params.get("correlation_uid"): + queryset = queryset.filter(correlation_uid=correlation_uid) + if title := request.query_params.get("title"): + queryset = queryset.filter(title__icontains=title) + for tag in parse_tags(request.query_params): + queryset = queryset.filter(tags__contains=[tag]) + + include_related = bool_param(request.query_params.get("include_related"), default=False) + if include_related: + queryset = queryset.prefetch_related("alerts") + page = paginate_created_at_cursor(queryset, request) + data = [serialize_case(case, include_related=include_related) for case in page.results] + return agent_response(request, operation="case.list", data=data, pagination=pagination_meta(page)) + + +class CaseDetailView(APIView): + permission_classes = [IsBusinessWriterOrReadOnly] + + def get(self, request, case_id): + case = _find_case(case_id) + include_related = bool_param(request.query_params.get("include_related"), default=True) + if include_related: + case = Case.objects.prefetch_related("alerts").get(pk=case.pk) + return agent_response(request, operation="case.show", data=serialize_case(case, include_related=include_related)) + + +class CaseAIAnalysisView(APIView): + permission_classes = [IsBusinessWriterOrReadOnly] + + def patch(self, request, case_id): + case = _find_case(case_id) + allowed_fields = {"severity_ai", "confidence_ai", "impact_ai", "priority_ai", "verdict_ai", "summary"} + updates = {field: request.data[field] for field in allowed_fields if field in request.data} + if not updates: + raise ValidationError({"detail": "At least one AI analysis field is required."}) + for field, value in updates.items(): + setattr(case, field, value) + with audit_actor(request.user): + case.full_clean() + case.save(update_fields=[*updates.keys(), "updated_at"]) + return agent_response(request, operation="case.update_ai", data=serialize_case(case, include_related=True), status=status.HTTP_200_OK) + + +class AlertListView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + queryset = Alert.objects.select_related("case") + if statuses := list_param(request.query_params, "status"): + queryset = queryset.filter(status__in=statuses) + if severities := list_param(request.query_params, "severity"): + queryset = queryset.filter(severity__in=severities) + if confidences := list_param(request.query_params, "confidence"): + queryset = queryset.filter(confidence__in=confidences) + if correlation_uid := request.query_params.get("correlation_uid"): + queryset = queryset.filter(correlation_uid=correlation_uid) + if case_id := request.query_params.get("case_id"): + queryset = queryset.filter(case__case_id=_record_id(case_id)) + include_related = bool_param(request.query_params.get("include_related"), default=False) + if include_related: + queryset = queryset.prefetch_related("artifacts") + page = paginate_created_at_cursor(queryset, request) + data = [serialize_alert(alert, include_related=include_related) for alert in page.results] + return agent_response(request, operation="alert.list", data=data, pagination=pagination_meta(page)) + + +class AlertDetailView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request, alert_id): + alert = _find_alert(alert_id) + include_related = bool_param(request.query_params.get("include_related"), default=True) + if include_related: + alert = Alert.objects.select_related("case").prefetch_related("artifacts").get(pk=alert.pk) + return agent_response(request, operation="alert.show", data=serialize_alert(alert, include_related=include_related)) + + +class ArtifactListView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + queryset = Artifact.objects.all() + if types := list_param(request.query_params, "type"): + queryset = queryset.filter(type__in=types) + if roles := list_param(request.query_params, "role"): + queryset = queryset.filter(role__in=roles) + if value := request.query_params.get("value"): + queryset = queryset.filter(value=value) + include_related = bool_param(request.query_params.get("include_related"), default=False) + if include_related: + queryset = queryset.prefetch_related("alerts", "alerts__case") + page = paginate_created_at_cursor(queryset, request) + data = [serialize_artifact(artifact, include_related=include_related) for artifact in page.results] + return agent_response(request, operation="artifact.list", data=data, pagination=pagination_meta(page)) + + +class ArtifactDetailView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request, artifact_id): + artifact = _find_artifact(artifact_id) + include_related = bool_param(request.query_params.get("include_related"), default=True) + if include_related: + artifact = Artifact.objects.prefetch_related("alerts", "alerts__case").get(pk=artifact.pk) + return agent_response(request, operation="artifact.show", data=serialize_artifact(artifact, include_related=include_related)) + + +class KnowledgeListView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + queryset = Knowledge.objects.select_related("case") + if keyword := request.query_params.get("keyword"): + terms = [item.strip() for item in keyword.split(",") if item.strip()] + query = Q() + for term in terms: + query |= Q(title__icontains=term) | Q(body__icontains=term) | Q(tags__contains=[term]) + queryset = queryset.filter(query) + if source := request.query_params.get("source"): + queryset = queryset.filter(source=source) + if case_id := request.query_params.get("case_id"): + queryset = queryset.filter(case__case_id=_record_id(case_id)) + for tag in parse_tags(request.query_params): + queryset = queryset.filter(tags__contains=[tag]) + page = paginate_created_at_cursor(queryset, request) + data = [serialize_knowledge(knowledge) for knowledge in page.results] + return agent_response(request, operation="knowledge.search", data=data, pagination=pagination_meta(page)) + + +class KnowledgeDetailView(APIView): + permission_classes = [IsBusinessWriterOrReadOnly] + + def get(self, request, knowledge_id): + return agent_response(request, operation="knowledge.show", data=serialize_knowledge(_find_knowledge(knowledge_id))) + + def patch(self, request, knowledge_id): + knowledge = _find_knowledge(knowledge_id) + allowed_fields = {"title", "body", "expires_at", "tags"} + updates = {field: request.data[field] for field in allowed_fields if field in request.data} + if not updates: + raise ValidationError({"detail": "At least one knowledge field is required."}) + if "expires_at" in updates: + updates["expires_at"] = parse_timezone_aware_datetime(updates["expires_at"], "expires_at") + for field, value in updates.items(): + setattr(knowledge, field, value) + with audit_actor(request.user): + knowledge.full_clean() + knowledge.save(update_fields=[*updates.keys(), "updated_at"]) + return agent_response(request, operation="knowledge.update", data=serialize_knowledge(knowledge)) + + +class CommentListCreateView(APIView): + permission_classes = [IsBusinessWriterOrReadOnly] + + def get(self, request): + target_id = request.query_params.get("target_id") + if not target_id: + raise ValidationError({"target_id": "This query parameter is required."}) + target = _find_comment_target(target_id) + content_type = _content_type_for_record(target) + queryset = Comment.objects.filter( + content_type=content_type, + object_id=str(target.pk), + ).select_related("author", "content_type", "parent").prefetch_related("mentions", "attachments").order_by("-created_at", "-id") + page = paginate_created_at_cursor(queryset, request) + data = [serialize_comment(comment, request=request) for comment in reversed(page.results)] + return agent_response(request, operation="comment.list", data=data, pagination=pagination_meta(page)) + + def post(self, request): + target_id = request.data.get("target_id") + if not target_id: + raise ValidationError({"target_id": "This field is required."}) + target = _find_comment_target(target_id) + attachments = _attachments_from_file_keys(request.data.get("file_keys") or request.data.get("file_key")) + body = str(request.data.get("body") or "") + if not body.strip() and not attachments: + raise ValidationError({"detail": "body or file_keys are required."}) + comment = create_record_comment( + author=request.user, + content_object=target, + body=body, + parent=_parent_comment_for_target(target, request.data.get("parent_id")), + mentions=_mention_users(request.data.get("mentions")), + attachments=attachments, + ) + return agent_response(request, operation="comment.add", data=serialize_comment(comment, request=request), status=status.HTTP_201_CREATED) + + +class FileUploadView(APIView): + permission_classes = [permissions.IsAuthenticated] + parser_classes = [parsers.MultiPartParser, parsers.FormParser] + + def post(self, request): + if "file" not in request.FILES: + raise ValidationError({"file": "This field is required."}) + uploaded = request.FILES["file"] + attachment = Attachment.objects.create( + uploaded_by=request.user, + file=uploaded, + filename=uploaded.name, + size=uploaded.size, + ) + return agent_response(request, operation="file.upload", data=serialize_attachment(attachment, request=request), status=status.HTTP_201_CREATED) + + +class FileDetailView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request, file_key): + return agent_response(request, operation="file.info", data=serialize_attachment(_find_attachment(file_key), request=request)) + + +class FileReadTextView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request, file_key): + attachment = _find_attachment(file_key) + max_bytes = _max_text_bytes(request.query_params.get("max_bytes")) + content_type = mimetypes.guess_type(attachment.filename)[0] or "application/octet-stream" + if not _is_text_content_type(content_type): + raise ValidationError({"file_key": f"File is not a supported text type: {content_type}"}) + with attachment.file.open("rb") as handle: + raw = handle.read(max_bytes + 1) + truncated = len(raw) > max_bytes + if truncated: + raw = raw[:max_bytes] + data = { + **serialize_attachment(attachment, request=request), + "text": raw.decode("utf-8", errors="replace"), + "truncated": truncated, + "max_bytes": max_bytes, + } + return agent_response(request, operation="file.read_text", data=data) + + +class EnrichmentCreateView(APIView): + permission_classes = [IsBusinessWriterOrReadOnly] + + def post(self, request): + target = _find_enrichment_target(request.data.get("target_id")) + enrichment = Enrichment( + name=request.data.get("name", ""), + type=request.data.get("type", "Other"), + provider=EnrichmentProvider.ASP, + uid=request.data.get("uid", ""), + value=request.data.get("value", ""), + desc=request.data.get("desc", ""), + data=_json_object(request.data.get("data", {}), "data"), + ) + if isinstance(target, Case): + enrichment.case = target + elif isinstance(target, Alert): + enrichment.alert = target + elif isinstance(target, Artifact): + enrichment.artifact = target + with audit_actor(request.user): + enrichment.full_clean() + enrichment.save() + return agent_response(request, operation="enrichment.create", data=serialize_enrichment(enrichment), status=status.HTTP_201_CREATED) + + +class PlaybookTemplateListView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + return agent_response(request, operation="playbook.template.list", data=list_playbook_definitions(include_path=False)) + + +class PlaybookListView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + queryset = Playbook.objects.select_related("case").all() + if playbook_id := request.query_params.get("playbook_id"): + queryset = queryset.filter(playbook_id=_record_id(playbook_id)) + if case_id := request.query_params.get("case_id"): + queryset = queryset.filter(case__case_id=_record_id(case_id)) + if statuses := list_param(request.query_params, "job_status"): + queryset = queryset.filter(job_status__in=statuses) + include_related = bool_param(request.query_params.get("include_related"), default=False) + page = paginate_created_at_cursor(queryset, request) + data = [serialize_playbook(playbook, include_related=include_related) for playbook in page.results] + return agent_response(request, operation="playbook.list", data=data, pagination=pagination_meta(page)) + + +class PlaybookDetailView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request, playbook_id): + playbook = _find_playbook(playbook_id) + include_related = bool_param(request.query_params.get("include_related"), default=True) + if include_related: + playbook = Playbook.objects.select_related("case").get(pk=playbook.pk) + return agent_response(request, operation="playbook.show", data=serialize_playbook(playbook, include_related=include_related)) + + +class PlaybookRunView(APIView): + permission_classes = [IsBusinessWriterOrReadOnly] + + def post(self, request): + name = request.data.get("name") + case_id = request.data.get("case_id") + if not name: + raise ValidationError({"name": "This field is required."}) + if not case_id: + raise ValidationError({"case_id": "This field is required."}) + try: + with audit_actor(request.user): + playbook = create_pending_playbook_run( + name=name, + case=_find_case(case_id), + user=request.user, + user_input=request.data.get("user_input", ""), + ) + except ValueError as exc: + raise ValidationError({"detail": str(exc)}) from exc + return agent_response(request, operation="playbook.run", data=serialize_playbook(playbook), status=status.HTTP_201_CREATED) + + +class SIEMSchemaView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + result = siem_service.explore_schema(SchemaExplorerInput(target_index=request.query_params.get("target_index"))) + return agent_response(request, operation="siem.schema", data=_dump(result)) + + +class SIEMKeywordSearchView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def post(self, request): + result = siem_service.keyword_search(KeywordSearchInput(**request.data)) + return agent_response(request, operation="siem.search.keyword", data=_dump(result)) + + +class SIEMAdaptiveQueryView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def post(self, request): + result = siem_service.execute_adaptive_query(AdaptiveQueryInput(**request.data)) + return agent_response(request, operation="siem.query.adaptive", data=_dump(result)) + + +class SIEMDiscoverFieldsView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def post(self, request): + result = siem_service.discover_index_fields(DiscoverIndexFieldsInput(**request.data)) + return agent_response(request, operation="siem.fields.discover", data=_dump(result)) + + +class SIEMSPLQueryView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def post(self, request): + result = siem_service.execute_spl(SPLQueryInput(**request.data)) + return agent_response(request, operation="siem.query.spl", data=_dump(result)) + + +class SIEMESQLQueryView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def post(self, request): + result = siem_service.execute_esql(ESQLQueryInput(**request.data)) + return agent_response(request, operation="siem.query.esql", data=_dump(result)) + + +class ThreatIntelQueryView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def post(self, request): + try: + result = query_indicator( + request.data.get("indicator"), + artifact_type=request.data.get("artifact_type", "Unknown"), + provider=request.data.get("provider"), + ) + except ValueError as exc: + raise ValidationError({"detail": str(exc)}) from exc + return agent_response(request, operation="ti.query", data=_dump(result)) + + +class CMDBLookupView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def post(self, request): + try: + result = lookup_artifact_context( + request.data.get("artifact_type"), + request.data.get("artifact_value"), + provider=request.data.get("provider"), + ) + except ValueError as exc: + raise ValidationError({"detail": str(exc)}) from exc + return agent_response(request, operation="cmdb.lookup", data=_dump(result)) + + +class DevStreamHeadView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + stream_name = request.query_params.get("stream_name") + if not stream_name: + raise ValidationError({"stream_name": "This query parameter is required."}) + n = _bounded_int(request.query_params.get("n"), default=3, maximum=100) + data = RedisStreamClient().read_stream_head(stream_name, n) + return agent_response(request, operation="dev.stream.head", data=data) + + +class DevStreamReadView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + stream_name = request.query_params.get("stream_name") + message_id = request.query_params.get("message_id") + if not stream_name: + raise ValidationError({"stream_name": "This query parameter is required."}) + if not message_id: + raise ValidationError({"message_id": "This query parameter is required."}) + data = RedisStreamClient().read_stream_message_by_id(stream_name, message_id) + return agent_response(request, operation="dev.stream.read", data=data) + + +def _record_id(value): + return str(value or "").strip().lower() + + +def _find_case(case_id): + try: + return Case.objects.get(case_id=_record_id(case_id)) + except Case.DoesNotExist as exc: + raise NotFound(f"Case not found: {case_id}") from exc + + +def _find_alert(alert_id): + try: + return Alert.objects.select_related("case").get(alert_id=_record_id(alert_id)) + except Alert.DoesNotExist as exc: + raise NotFound(f"Alert not found: {alert_id}") from exc + + +def _find_artifact(artifact_id): + try: + return Artifact.objects.get(artifact_id=_record_id(artifact_id)) + except Artifact.DoesNotExist as exc: + raise NotFound(f"Artifact not found: {artifact_id}") from exc + + +def _find_knowledge(knowledge_id): + try: + return Knowledge.objects.select_related("case").get(knowledge_id=_record_id(knowledge_id)) + except Knowledge.DoesNotExist as exc: + raise NotFound(f"Knowledge not found: {knowledge_id}") from exc + + +def _find_playbook(playbook_id): + try: + return Playbook.objects.select_related("case").get(playbook_id=_record_id(playbook_id)) + except Playbook.DoesNotExist as exc: + raise NotFound(f"Playbook not found: {playbook_id}") from exc + + +def _find_attachment(file_key): + try: + return Attachment.objects.get(access_key=file_key) + except (Attachment.DoesNotExist, ValueError) as exc: + raise NotFound(f"File not found: {file_key}") from exc + + +def _find_comment_target(target_id): + target_id = _record_id(target_id) + if target_id.startswith("case_"): + return _find_case(target_id) + if target_id.startswith("alert_"): + return _find_alert(target_id) + if target_id.startswith("artifact_"): + return _find_artifact(target_id) + if target_id.startswith("enrichment_"): + return _find_enrichment(target_id) + if target_id.startswith("knowledge_"): + return _find_knowledge(target_id) + if target_id.startswith("playbook_"): + return _find_playbook(target_id) + raise ValidationError({"target_id": "Must start with case_, alert_, artifact_, enrichment_, knowledge_, or playbook_."}) + + +def _find_enrichment_target(target_id): + target_id = _record_id(target_id) + if target_id.startswith("case_"): + return _find_case(target_id) + if target_id.startswith("alert_"): + return _find_alert(target_id) + if target_id.startswith("artifact_"): + return _find_artifact(target_id) + raise ValidationError({"target_id": "Must start with case_, alert_, or artifact_."}) + + +def _find_enrichment(enrichment_id): + try: + return Enrichment.objects.select_related("case", "alert", "artifact").get(enrichment_id=_record_id(enrichment_id)) + except Enrichment.DoesNotExist as exc: + raise NotFound(f"Enrichment not found: {enrichment_id}") from exc + + +def _content_type_for_record(record): + return ContentType.objects.get_for_model(record, for_concrete_model=False) + + +def _parent_comment_for_target(content_object, parent_id): + if parent_id in (None, ""): + return None + try: + parent = Comment.objects.get(pk=int(parent_id)) + except (TypeError, ValueError, Comment.DoesNotExist) as exc: + raise NotFound(f"Parent comment not found: {parent_id}") from exc + content_type = _content_type_for_record(content_object) + if parent.content_type_id != content_type.id or parent.object_id != str(content_object.pk): + raise ValidationError({"parent_id": "Must belong to the same target."}) + return parent + + +def _mention_users(mentions): + users = [] + seen_ids = set() + user_model = get_user_model() + for item in _coerce_list(mentions): + text = str(item or "").strip() + if not text: + continue + user = user_model.objects.filter(username=text).first() + if user is None and text.isdigit(): + user = user_model.objects.filter(pk=int(text)).first() + if user is None: + raise ValidationError({"mentions": f"User not found: {text}"}) + if user.id not in seen_ids: + users.append(user) + seen_ids.add(user.id) + return users + + +def _attachments_from_file_keys(file_keys): + keys = _coerce_list(file_keys) + attachments = [] + for key in keys: + attachments.append(_find_attachment(key)) + return attachments + + +def _coerce_list(value): + if value in (None, ""): + return [] + if isinstance(value, list): + return value + if isinstance(value, tuple | set): + return list(value) + if isinstance(value, str): + stripped = value.strip() + if not stripped: + return [] + if stripped.startswith("[") and stripped.endswith("]"): + try: + decoded = json.loads(stripped) + except json.JSONDecodeError: + decoded = None + if isinstance(decoded, list): + return decoded + return [item.strip() for item in stripped.split(",") if item.strip()] + return [value] + + +def _json_object(value, field_name): + if value in (None, ""): + return {} + if isinstance(value, dict): + return value + if isinstance(value, str): + try: + payload = json.loads(value) + except json.JSONDecodeError as exc: + raise ValidationError({field_name: "Must be a valid JSON object."}) from exc + if isinstance(payload, dict): + return payload + raise ValidationError({field_name: "Must be a valid JSON object."}) + + +def _max_text_bytes(value): + try: + parsed = int(value or 65536) + except (TypeError, ValueError): + parsed = 65536 + return max(1, min(parsed, 262144)) + + +def _is_text_content_type(content_type): + return ( + content_type.startswith("text/") + or content_type in {"application/json", "application/xml", "application/yaml", "application/x-yaml"} + ) + + +def _bounded_int(value, *, default, maximum): + try: + parsed = int(value or default) + except (TypeError, ValueError): + parsed = default + return max(1, min(parsed, maximum)) + + +def _dump(value): + if isinstance(value, list): + return [_dump(item) for item in value] + if isinstance(value, dict): + return {key: _dump(item) for key, item in value.items()} + if hasattr(value, "model_dump"): + return value.model_dump() + return value diff --git a/backend/asp/settings.py b/backend/asp/settings.py index f8d1967..04ce3ca 100644 --- a/backend/asp/settings.py +++ b/backend/asp/settings.py @@ -51,6 +51,7 @@ INSTALLED_APPS = [ "apps.webhook", "apps.mcp", "apps.agentic", + "apps.agent_api", ] MIDDLEWARE = [ diff --git a/backend/asp/urls.py b/backend/asp/urls.py index 55b7eab..87c66af 100644 --- a/backend/asp/urls.py +++ b/backend/asp/urls.py @@ -17,4 +17,5 @@ urlpatterns = [ path("api/", include("apps.inbox.urls")), path("api/", include("apps.preferences.urls")), path("api/", include("apps.webhook.urls")), + path("api/agent/v1/", include("apps.agent_api.urls")), ] diff --git a/cli/LICENSE b/cli/LICENSE new file mode 100644 index 0000000..53e49d5 --- /dev/null +++ b/cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Agentic SOC Platform contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..00df28d --- /dev/null +++ b/cli/README.md @@ -0,0 +1,25 @@ +# ASP CLI + +Command line client for Agentic SOC Platform. + +`asp-cli` provides the `asp` command for SOC analysts and automation agents to authenticate with an ASP server, inspect cases and alerts, add comments, upload files, run playbooks, and query investigation integrations. + +## Install + +```powershell +pipx install asp-cli +``` + +## Quick start + +```powershell +asp auth login --api-url https://asp.example.com --api-key asp_xxx +asp doctor +asp case list +``` + +For automation and agent skills, prefer stable JSON output: + +```powershell +asp case list --output json +``` diff --git a/cli/pyproject.toml b/cli/pyproject.toml new file mode 100644 index 0000000..ccb482d --- /dev/null +++ b/cli/pyproject.toml @@ -0,0 +1,48 @@ +[project] +name = "asp-cli" +version = "0.1.0" +description = "Command line client for Agentic SOC Platform" +readme = "README.md" +requires-python = ">=3.11" +license = "MIT" +authors = [ + { name = "Agentic SOC Platform contributors" }, +] +keywords = ["agentic-soc", "soc", "security", "cli"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Intended Audience :: Information Technology", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Security", +] +dependencies = [ + "httpx>=0.28.1", + "jmespath>=1.0.1", + "pydantic>=2.13.4", + "rich>=14.2.0", + "typer>=0.20.0", +] + +[project.urls] +Homepage = "https://github.com/FunnyWolf/agentic-soc-platform" +Repository = "https://github.com/FunnyWolf/agentic-soc-platform" +Issues = "https://github.com/FunnyWolf/agentic-soc-platform/issues" + +[project.scripts] +asp = "asp_cli.main:run" + +[build-system] +requires = ["hatchling>=1.28"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/asp_cli"] + +[tool.hatch.build.targets.wheel.force-include] +"src/asp_cli/spec/operations.json" = "asp_cli/spec/operations.json" diff --git a/cli/src/asp_cli/__init__.py b/cli/src/asp_cli/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/cli/src/asp_cli/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/cli/src/asp_cli/api_client.py b/cli/src/asp_cli/api_client.py new file mode 100644 index 0000000..d0ff459 --- /dev/null +++ b/cli/src/asp_cli/api_client.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import time +from typing import Any + +import httpx +from rich.console import Console + +from . import __version__ +from .config import redact_secret +from .errors import ( + CliError, + EXIT_AUTH, + EXIT_NETWORK, + EXIT_NOT_FOUND, + EXIT_PERMISSION, + EXIT_SERVER, + EXIT_USAGE, +) + + +class AspClient: + def __init__( + self, + *, + api_url: str, + api_key: str | None = None, + verbose: bool = False, + console: Console | None = None, + timeout: float = 20.0, + ) -> None: + self.base_url = _normalize_base_url(api_url) + self.api_key = api_key + self.verbose = verbose + self.console = console or Console(stderr=True) + self.timeout = timeout + + def health(self) -> dict[str, Any]: + return self.request("GET", "/api/health/", authenticated=False) + + def version(self) -> dict[str, Any]: + return self.request("GET", "/api/agent/v1/version/") + + def request(self, method: str, path: str, *, authenticated: bool = True, json: Any = None, files: Any = None) -> dict[str, Any]: + if authenticated and not self.api_key: + raise CliError("missing_api_key", "API key is required", {}, EXIT_AUTH) + + headers = { + "Accept": "application/json", + "User-Agent": f"asp-cli/{__version__}", + } + if authenticated and self.api_key: + headers["Authorization"] = f"Api-Key {self.api_key}" + + url = f"{self.base_url}{path}" + started = time.perf_counter() + try: + response = httpx.request(method, url, headers=headers, json=json, files=files, timeout=self.timeout) + except httpx.HTTPError as exc: + raise CliError("network_error", f"Unable to reach ASP server: {exc}", {"url": _redact_url(url)}, EXIT_NETWORK) from exc + + elapsed_ms = int((time.perf_counter() - started) * 1000) + if self.verbose: + self.console.print(f"{method} {path} -> {response.status_code} ({elapsed_ms}ms)", style="dim") + + if response.status_code >= 400: + self._raise_http_error(response, path) + + if not response.content: + return {} + try: + payload = response.json() + except ValueError as exc: + raise CliError("invalid_response", "Server returned non-JSON response", {"status_code": response.status_code}, EXIT_SERVER) from exc + if not isinstance(payload, dict): + raise CliError("invalid_response", "Server response must be a JSON object", {"status_code": response.status_code}, EXIT_SERVER) + return payload + + def _raise_http_error(self, response: httpx.Response, path: str) -> None: + message = _response_message(response) + details = {"status_code": response.status_code, "path": path} + if response.status_code == 400: + raise CliError("bad_request", message, details, EXIT_USAGE) + if response.status_code == 401: + raise CliError("authentication_failed", message, details, EXIT_AUTH) + if response.status_code == 403: + raise CliError("permission_denied", message, details, EXIT_PERMISSION) + if response.status_code == 404: + raise CliError("not_found", message, details, EXIT_NOT_FOUND) + raise CliError("server_error", message, details, EXIT_SERVER) + + +def _normalize_base_url(api_url: str) -> str: + base = api_url.strip().rstrip("/") + if base.endswith("/api"): + base = base[:-4] + if not base: + raise CliError("missing_api_url", "ASP API URL is required", {}, EXIT_USAGE) + return base + + +def _response_message(response: httpx.Response) -> str: + try: + payload = response.json() + except ValueError: + return response.text.strip() or f"HTTP {response.status_code}" + if isinstance(payload, dict): + detail = payload.get("detail") + if isinstance(detail, str): + return detail + error = payload.get("error") + if isinstance(error, dict) and isinstance(error.get("message"), str): + return error["message"] + return f"HTTP {response.status_code}" + + +def _redact_url(url: str) -> str: + return url.replace(redact_secret(url), "****") if "asp_" in url else url diff --git a/cli/src/asp_cli/config.py b/cli/src/asp_cli/config.py new file mode 100644 index 0000000..f4068a7 --- /dev/null +++ b/cli/src/asp_cli/config.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from .errors import CliError, EXIT_CONFIG + +GLOBAL_SETTINGS_PATH = Path.home() / ".asp" / "settings.json" +LOCAL_SETTINGS_DIR = ".asp" +SETTINGS_FILENAME = "settings.json" +SUPPORTED_KEYS = {"api_url", "api_key"} + + +@dataclass(frozen=True) +class ResolvedConfig: + api_url: str | None + api_key: str | None + sources: dict[str, str] + global_path: Path + local_path: Path | None + + @property + def has_auth(self) -> bool: + return bool(self.api_url and self.api_key) + + +def resolve_config(*, cwd: Path | None = None, api_url: str | None = None, api_key: str | None = None) -> ResolvedConfig: + cwd = (cwd or Path.cwd()).resolve() + global_settings = read_settings(GLOBAL_SETTINGS_PATH) + local_path = find_local_settings(cwd) + local_settings = read_settings(local_path) if local_path else {} + values: dict[str, Any] = {} + sources: dict[str, str] = {} + + _merge(values, sources, global_settings, "global") + if local_path: + _merge(values, sources, local_settings, "local") + _merge( + values, + sources, + { + "api_url": os.environ.get("ASP_API_URL"), + "api_key": os.environ.get("ASP_API_KEY"), + }, + "env", + ) + _merge(values, sources, {"api_url": api_url, "api_key": api_key}, "flags") + + return ResolvedConfig( + api_url=_clean(values.get("api_url")), + api_key=_clean(values.get("api_key")), + sources=sources, + global_path=GLOBAL_SETTINGS_PATH, + local_path=local_path, + ) + + +def auth_settings_path(*, local: bool, cwd: Path | None = None) -> Path: + if local: + return (cwd or Path.cwd()).resolve() / LOCAL_SETTINGS_DIR / SETTINGS_FILENAME + return GLOBAL_SETTINGS_PATH + + +def save_auth(*, api_url: str, api_key: str, local: bool = False, cwd: Path | None = None) -> Path: + path = auth_settings_path(local=local, cwd=cwd) + settings = read_settings(path) + settings["api_url"] = api_url.rstrip("/") + settings["api_key"] = api_key + write_settings(path, settings) + return path + + +def clear_auth(*, local: bool = False, cwd: Path | None = None) -> Path: + path = auth_settings_path(local=local, cwd=cwd) + settings = read_settings(path) + settings.pop("api_url", None) + settings.pop("api_key", None) + write_settings(path, settings) + return path + + +def set_config_value(key: str, value: str, *, local: bool = False, cwd: Path | None = None) -> Path: + if key not in SUPPORTED_KEYS: + raise CliError("invalid_config_key", f"Unsupported config key: {key}", {"supported": sorted(SUPPORTED_KEYS)}, EXIT_CONFIG) + path = auth_settings_path(local=local, cwd=cwd) + settings = read_settings(path) + settings[key] = value.rstrip("/") if key == "api_url" else value + write_settings(path, settings) + return path + + +def get_config_value(key: str, *, cwd: Path | None = None, api_url: str | None = None, api_key: str | None = None) -> tuple[str | None, str | None]: + if key not in SUPPORTED_KEYS: + raise CliError("invalid_config_key", f"Unsupported config key: {key}", {"supported": sorted(SUPPORTED_KEYS)}, EXIT_CONFIG) + config = resolve_config(cwd=cwd, api_url=api_url, api_key=api_key) + return getattr(config, key), config.sources.get(key) + + +def read_settings(path: Path | None) -> dict[str, Any]: + if path is None or not path.exists(): + return {} + try: + with path.open("r", encoding="utf-8") as handle: + payload = json.load(handle) + except json.JSONDecodeError as exc: + raise CliError("invalid_config", f"Invalid JSON in settings file: {path}", {"path": str(path)}, EXIT_CONFIG) from exc + if not isinstance(payload, dict): + raise CliError("invalid_config", f"Settings file must contain a JSON object: {path}", {"path": str(path)}, EXIT_CONFIG) + return payload + + +def write_settings(path: Path, settings: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as handle: + json.dump(settings, handle, indent=2, sort_keys=True) + handle.write("\n") + _restrict_permissions(path) + + +def find_local_settings(cwd: Path) -> Path | None: + git_root = find_git_root(cwd) + if git_root is None: + candidate = cwd / LOCAL_SETTINGS_DIR / SETTINGS_FILENAME + return candidate if candidate.exists() else None + + current = cwd + while True: + candidate = current / LOCAL_SETTINGS_DIR / SETTINGS_FILENAME + if candidate.exists(): + return candidate + if current == git_root: + return None + current = current.parent + + +def find_git_root(cwd: Path) -> Path | None: + current = cwd + while True: + if (current / ".git").exists(): + return current + if current == current.parent: + return None + current = current.parent + + +def redact_secret(value: str | None) -> str: + if not value: + return "" + if len(value) <= 8: + return "****" + return f"{value[:4]}...{value[-4:]}" + + +def _merge(values: dict[str, Any], sources: dict[str, str], incoming: dict[str, Any], source: str) -> None: + for key in SUPPORTED_KEYS: + value = _clean(incoming.get(key)) + if value: + values[key] = value + sources[key] = source + + +def _clean(value: Any) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None + + +def _restrict_permissions(path: Path) -> None: + try: + os.chmod(path, 0o600) + except OSError: + return diff --git a/cli/src/asp_cli/errors.py b/cli/src/asp_cli/errors.py new file mode 100644 index 0000000..25e9aaf --- /dev/null +++ b/cli/src/asp_cli/errors.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +EXIT_USAGE = 2 +EXIT_CONFIG = 3 +EXIT_AUTH = 4 +EXIT_PERMISSION = 5 +EXIT_NOT_FOUND = 6 +EXIT_CONFLICT = 7 +EXIT_VERSION = 8 +EXIT_NETWORK = 70 +EXIT_SERVER = 75 + + +@dataclass +class CliError(Exception): + code: str + message: str + details: dict[str, Any] = field(default_factory=dict) + exit_code: int = EXIT_USAGE + + def __str__(self) -> str: + return self.message diff --git a/cli/src/asp_cli/main.py b/cli/src/asp_cli/main.py new file mode 100644 index 0000000..d628c4e --- /dev/null +++ b/cli/src/asp_cli/main.py @@ -0,0 +1,1461 @@ +from __future__ import annotations + +import json +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Annotated +from urllib.parse import urlencode + +if __package__ in {None, ""}: + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + __package__ = "asp_cli" + +import httpx +import jmespath +import typer +from rich.console import Console +from rich.table import Table + +from . import __version__ +from .config import ( + clear_auth, + get_config_value, + read_settings, + redact_secret, + resolve_config, + save_auth, + set_config_value, +) +from .errors import CliError, EXIT_AUTH, EXIT_CONFIG, EXIT_NETWORK, EXIT_USAGE +from .api_client import AspClient +from .output import OutputFormat, emit_error, emit_success, key_value_table + +console = Console() +err_console = Console(stderr=True) + + +@dataclass +class RuntimeOptions: + api_url: str | None + api_key: str | None + output: OutputFormat + query: str | None + verbose: bool + + +app = typer.Typer(no_args_is_help=True, help="ASP command line client.") +auth_app = typer.Typer(no_args_is_help=True, help="Authenticate and inspect the current ASP session.") +config_app = typer.Typer(no_args_is_help=True, help="Read and write ASP CLI settings.") +completion_app = typer.Typer(no_args_is_help=True, help="Show shell completion installation commands.") +case_app = typer.Typer(no_args_is_help=True, help="List, show, and update ASP cases.") +alert_app = typer.Typer(no_args_is_help=True, help="List and show ASP alerts.") +artifact_app = typer.Typer(no_args_is_help=True, help="List and show ASP artifacts.") +knowledge_app = typer.Typer(no_args_is_help=True, help="Search, show, and update ASP knowledge.") +comment_app = typer.Typer(no_args_is_help=True, help="List and add ASP comments.") +file_app = typer.Typer(no_args_is_help=True, help="Upload, inspect, download, and read ASP files.") +enrichment_app = typer.Typer(no_args_is_help=True, help="Create ASP enrichments.") +playbook_app = typer.Typer(no_args_is_help=True, help="List and run ASP playbooks.") +playbook_template_app = typer.Typer(no_args_is_help=True, help="List playbook templates.") +siem_app = typer.Typer(no_args_is_help=True, help="Search and query ASP SIEM integrations.") +siem_schema_app = typer.Typer(no_args_is_help=True, help="Explore SIEM schema metadata.") +siem_search_app = typer.Typer(no_args_is_help=True, help="Run SIEM search workflows.") +siem_query_app = typer.Typer(no_args_is_help=True, help="Run structured or raw SIEM queries.") +siem_fields_app = typer.Typer(no_args_is_help=True, help="Discover live SIEM fields.") +ti_app = typer.Typer(no_args_is_help=True, help="Query threat intelligence providers.") +cmdb_app = typer.Typer(no_args_is_help=True, help="Look up asset context from CMDB providers.") +dev_app = typer.Typer(no_args_is_help=True, help="Advanced developer and debugging commands.") +dev_stream_app = typer.Typer(no_args_is_help=True, help="Inspect Redis streams.") + +app.add_typer(auth_app, name="auth") +app.add_typer(config_app, name="config") +app.add_typer(completion_app, name="completion") +app.add_typer(case_app, name="case") +app.add_typer(alert_app, name="alert") +app.add_typer(artifact_app, name="artifact") +app.add_typer(knowledge_app, name="knowledge") +app.add_typer(comment_app, name="comment") +app.add_typer(file_app, name="file") +app.add_typer(enrichment_app, name="enrichment") +playbook_app.add_typer(playbook_template_app, name="template") +app.add_typer(playbook_app, name="playbook") +siem_app.add_typer(siem_schema_app, name="schema") +siem_app.add_typer(siem_search_app, name="search") +siem_app.add_typer(siem_query_app, name="query") +siem_app.add_typer(siem_fields_app, name="fields") +app.add_typer(siem_app, name="siem") +app.add_typer(ti_app, name="ti") +app.add_typer(cmdb_app, name="cmdb") +dev_app.add_typer(dev_stream_app, name="stream") +app.add_typer(dev_app, name="dev") + + +@app.callback() +def main( + ctx: typer.Context, + version: Annotated[bool, typer.Option("--version", help="Show CLI version and exit.")] = False, + api_url: Annotated[str | None, typer.Option("--api-url", help="Temporarily override the ASP base URL.")] = None, + api_key: Annotated[str | None, typer.Option("--api-key", help="Temporarily override the ASP API key.")] = None, + output: Annotated[OutputFormat, typer.Option("--output", help="Output format.")] = OutputFormat.human, + query: Annotated[str | None, typer.Option("--query", help="JMESPath query applied to JSON data.")] = None, + verbose: Annotated[bool, typer.Option("--verbose", help="Show redacted request diagnostics.")] = False, +) -> None: + if version: + console.print(__version__) + raise typer.Exit() + ctx.obj = RuntimeOptions(api_url=api_url, api_key=api_key, output=output, query=query, verbose=verbose) + + +@auth_app.command("login") +def auth_login( + ctx: typer.Context, + api_url: Annotated[str, typer.Option("--api-url", help="ASP base URL, for example https://asp.example.com.")], + api_key: Annotated[str, typer.Option("--api-key", help="ASP user API key.")], + local: Annotated[bool, typer.Option("--local", help="Write .asp/settings.json in the current directory.")] = False, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command(ctx, "auth.login", output, lambda runtime, out: _auth_login(runtime, out, api_url, api_key, local)) + + +@auth_app.command("status") +def auth_status( + ctx: typer.Context, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command(ctx, "auth.status", output, _auth_status) + + +@auth_app.command("logout") +def auth_logout( + ctx: typer.Context, + local: Annotated[bool, typer.Option("--local", help="Remove auth from local .asp/settings.json instead of global settings.")] = False, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command(ctx, "auth.logout", output, lambda runtime, out: _auth_logout(runtime, out, local)) + + +@config_app.command("list") +def config_list( + ctx: typer.Context, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command(ctx, "config.list", output, _config_list) + + +@config_app.command("get") +def config_get( + ctx: typer.Context, + key: Annotated[str, typer.Argument(help="Config key: api_url or api_key.")], + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command(ctx, "config.get", output, lambda runtime, out: _config_get(runtime, out, key)) + + +@config_app.command("set") +def config_set( + ctx: typer.Context, + key: Annotated[str, typer.Argument(help="Config key: api_url or api_key.")], + value: Annotated[str, typer.Argument(help="Config value.")], + local: Annotated[bool, typer.Option("--local", help="Write local .asp/settings.json instead of global settings.")] = False, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command(ctx, "config.set", output, lambda runtime, out: _config_set(runtime, out, key, value, local)) + + +@app.command("doctor") +def doctor( + ctx: typer.Context, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command(ctx, "doctor", output, _doctor) + + +@case_app.command("list") +def case_list( + ctx: typer.Context, + status: Annotated[str | None, typer.Option("--status", help="Case status filter. Repeat values with commas.")] = None, + severity: Annotated[str | None, typer.Option("--severity", help="Case severity filter. Repeat values with commas.")] = None, + confidence: Annotated[str | None, typer.Option("--confidence", help="Case confidence filter. Repeat values with commas.")] = None, + verdict: Annotated[str | None, typer.Option("--verdict", help="Case verdict filter. Repeat values with commas.")] = None, + correlation_uid: Annotated[str | None, typer.Option("--correlation-uid", help="Case correlation UID.")] = None, + title: Annotated[str | None, typer.Option("--title", help="Title substring filter.")] = None, + tags: Annotated[str | None, typer.Option("--tags", help="Comma-separated tag filters.")] = None, + include_related: Annotated[bool, typer.Option("--include-related", help="Include related alerts.")] = False, + cursor: Annotated[str | None, typer.Option("--cursor", help="Pagination cursor.")] = None, + page_size: Annotated[int | None, typer.Option("--page-size", min=1, max=100, help="Page size.")] = None, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command( + ctx, + "case.list", + output, + lambda runtime, out: _list_cases( + runtime, + out, + status=status, + severity=severity, + confidence=confidence, + verdict=verdict, + correlation_uid=correlation_uid, + title=title, + tags=tags, + include_related=include_related, + cursor=cursor, + page_size=page_size, + ), + ) + + +@case_app.command("show") +def case_show( + ctx: typer.Context, + case_id: Annotated[str, typer.Argument(help="Case ID, for example case_000001.")], + include_related: Annotated[bool, typer.Option("--include-related/--no-include-related", help="Include related alerts.")] = True, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command(ctx, "case.show", output, lambda runtime, out: _show_case(runtime, out, case_id, include_related)) + + +@case_app.command("update-ai") +def case_update_ai( + ctx: typer.Context, + case_id: Annotated[str, typer.Argument(help="Case ID, for example case_000001.")], + severity_ai: Annotated[str | None, typer.Option("--severity-ai", help="AI-assessed severity.")] = None, + confidence_ai: Annotated[str | None, typer.Option("--confidence-ai", help="AI-assessed confidence.")] = None, + impact_ai: Annotated[str | None, typer.Option("--impact-ai", help="AI-assessed impact.")] = None, + priority_ai: Annotated[str | None, typer.Option("--priority-ai", help="AI-assessed priority.")] = None, + verdict_ai: Annotated[str | None, typer.Option("--verdict-ai", help="AI-assessed verdict.")] = None, + summary: Annotated[str | None, typer.Option("--summary", help="Case summary.")] = None, + summary_file: Annotated[Path | None, typer.Option("--summary-file", help="Read summary from file.")] = None, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command( + ctx, + "case.update_ai", + output, + lambda runtime, out: _update_case_ai( + runtime, + out, + case_id, + severity_ai=severity_ai, + confidence_ai=confidence_ai, + impact_ai=impact_ai, + priority_ai=priority_ai, + verdict_ai=verdict_ai, + summary=summary, + summary_file=summary_file, + ), + ) + + +@alert_app.command("list") +def alert_list( + ctx: typer.Context, + status: Annotated[str | None, typer.Option("--status", help="Alert status filter. Repeat values with commas.")] = None, + severity: Annotated[str | None, typer.Option("--severity", help="Alert severity filter. Repeat values with commas.")] = None, + confidence: Annotated[str | None, typer.Option("--confidence", help="Alert confidence filter. Repeat values with commas.")] = None, + case_id: Annotated[str | None, typer.Option("--case-id", help="Linked case ID.")] = None, + correlation_uid: Annotated[str | None, typer.Option("--correlation-uid", help="Correlation UID.")] = None, + include_related: Annotated[bool, typer.Option("--include-related", help="Include related artifacts.")] = False, + cursor: Annotated[str | None, typer.Option("--cursor", help="Pagination cursor.")] = None, + page_size: Annotated[int | None, typer.Option("--page-size", min=1, max=100, help="Page size.")] = None, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command( + ctx, + "alert.list", + output, + lambda runtime, out: _list_alerts( + runtime, + out, + status=status, + severity=severity, + confidence=confidence, + case_id=case_id, + correlation_uid=correlation_uid, + include_related=include_related, + cursor=cursor, + page_size=page_size, + ), + ) + + +@alert_app.command("show") +def alert_show( + ctx: typer.Context, + alert_id: Annotated[str, typer.Argument(help="Alert ID, for example alert_000001.")], + include_related: Annotated[bool, typer.Option("--include-related/--no-include-related", help="Include related artifacts.")] = True, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command(ctx, "alert.show", output, lambda runtime, out: _show_alert(runtime, out, alert_id, include_related)) + + +@artifact_app.command("list") +def artifact_list( + ctx: typer.Context, + type: Annotated[str | None, typer.Option("--type", help="Artifact type filter. Repeat values with commas.")] = None, + role: Annotated[str | None, typer.Option("--role", help="Artifact role filter. Repeat values with commas.")] = None, + value: Annotated[str | None, typer.Option("--value", help="Exact artifact value.")] = None, + include_related: Annotated[bool, typer.Option("--include-related", help="Include related alerts.")] = False, + cursor: Annotated[str | None, typer.Option("--cursor", help="Pagination cursor.")] = None, + page_size: Annotated[int | None, typer.Option("--page-size", min=1, max=100, help="Page size.")] = None, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command( + ctx, + "artifact.list", + output, + lambda runtime, out: _list_artifacts( + runtime, + out, + type=type, + role=role, + value=value, + include_related=include_related, + cursor=cursor, + page_size=page_size, + ), + ) + + +@artifact_app.command("show") +def artifact_show( + ctx: typer.Context, + artifact_id: Annotated[str, typer.Argument(help="Artifact ID, for example artifact_000001.")], + include_related: Annotated[bool, typer.Option("--include-related/--no-include-related", help="Include related alerts.")] = True, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command(ctx, "artifact.show", output, lambda runtime, out: _show_artifact(runtime, out, artifact_id, include_related)) + + +@knowledge_app.command("search") +def knowledge_search( + ctx: typer.Context, + keyword: Annotated[str | None, typer.Argument(help="Keyword to search in title, body, or tags.")] = None, + source: Annotated[str | None, typer.Option("--source", help="Knowledge source filter.")] = None, + case_id: Annotated[str | None, typer.Option("--case-id", help="Linked case ID.")] = None, + tags: Annotated[str | None, typer.Option("--tags", help="Comma-separated tag filters.")] = None, + cursor: Annotated[str | None, typer.Option("--cursor", help="Pagination cursor.")] = None, + page_size: Annotated[int | None, typer.Option("--page-size", min=1, max=100, help="Page size.")] = None, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command( + ctx, + "knowledge.search", + output, + lambda runtime, out: _search_knowledge( + runtime, + out, + keyword=keyword, + source=source, + case_id=case_id, + tags=tags, + cursor=cursor, + page_size=page_size, + ), + ) + + +@knowledge_app.command("show") +def knowledge_show( + ctx: typer.Context, + knowledge_id: Annotated[str, typer.Argument(help="Knowledge ID, for example knowledge_000001.")], + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command(ctx, "knowledge.show", output, lambda runtime, out: _show_knowledge(runtime, out, knowledge_id)) + + +@knowledge_app.command("update") +def knowledge_update( + ctx: typer.Context, + knowledge_id: Annotated[str, typer.Argument(help="Knowledge ID, for example knowledge_000001.")], + title: Annotated[str | None, typer.Option("--title", help="Knowledge title.")] = None, + body: Annotated[str | None, typer.Option("--body", help="Knowledge body.")] = None, + body_file: Annotated[Path | None, typer.Option("--body-file", help="Read body from file.")] = None, + expires_at: Annotated[str | None, typer.Option("--expires-at", help="ISO 8601 datetime with timezone.")] = None, + tags: Annotated[str | None, typer.Option("--tags", help="Comma-separated tags.")] = None, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command( + ctx, + "knowledge.update", + output, + lambda runtime, out: _update_knowledge( + runtime, + out, + knowledge_id, + title=title, + body=body, + body_file=body_file, + expires_at=expires_at, + tags=tags, + ), + ) + + +@comment_app.command("list") +def comment_list( + ctx: typer.Context, + target_id: Annotated[str, typer.Argument(help="Target record ID, for example case_000001.")], + cursor: Annotated[str | None, typer.Option("--cursor", help="Pagination cursor.")] = None, + page_size: Annotated[int | None, typer.Option("--page-size", min=1, max=100, help="Page size.")] = None, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command(ctx, "comment.list", output, lambda runtime, out: _list_comments(runtime, out, target_id, cursor, page_size)) + + +@comment_app.command("add") +def comment_add( + ctx: typer.Context, + target_id: Annotated[str, typer.Argument(help="Target record ID, for example case_000001.")], + body: Annotated[str | None, typer.Option("--body", help="Comment body.")] = None, + body_file: Annotated[Path | None, typer.Option("--body-file", help="Read comment body from file.")] = None, + file_key: Annotated[str | None, typer.Option("--file-key", help="Attachment file_key. Use commas for multiple keys.")] = None, + parent_id: Annotated[int | None, typer.Option("--parent-id", help="Parent comment ID for replies.")] = None, + mentions: Annotated[str | None, typer.Option("--mentions", help="Comma-separated usernames or user IDs.")] = None, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command( + ctx, + "comment.add", + output, + lambda runtime, out: _add_comment( + runtime, + out, + target_id, + body=body, + body_file=body_file, + file_key=file_key, + parent_id=parent_id, + mentions=mentions, + ), + ) + + +@file_app.command("upload") +def file_upload( + ctx: typer.Context, + path: Annotated[Path, typer.Argument(help="Local file path to upload.")], + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command(ctx, "file.upload", output, lambda runtime, out: _upload_file(runtime, out, path)) + + +@file_app.command("info") +def file_info( + ctx: typer.Context, + file_key: Annotated[str, typer.Argument(help="Attachment file_key.")], + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command(ctx, "file.info", output, lambda runtime, out: _file_info(runtime, out, file_key)) + + +@file_app.command("download") +def file_download( + ctx: typer.Context, + file_key: Annotated[str, typer.Argument(help="Attachment file_key.")], + output_path: Annotated[Path | None, typer.Option("--output-path", "-o", help="Local output path.")] = None, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command(ctx, "file.download", output, lambda runtime, out: _download_file(runtime, out, file_key, output_path)) + + +@file_app.command("read-text") +def file_read_text( + ctx: typer.Context, + file_key: Annotated[str, typer.Argument(help="Attachment file_key.")], + max_bytes: Annotated[int, typer.Option("--max-bytes", min=1, max=262144, help="Maximum bytes to read.")] = 65536, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command(ctx, "file.read_text", output, lambda runtime, out: _read_file_text(runtime, out, file_key, max_bytes)) + + +@enrichment_app.command("create") +def enrichment_create( + ctx: typer.Context, + target_id: Annotated[str, typer.Argument(help="Target case_, alert_, or artifact_ ID.")], + name: Annotated[str, typer.Option("--name", help="Enrichment name.")] = "", + type: Annotated[str, typer.Option("--type", help="Enrichment type.")] = "Other", + value: Annotated[str, typer.Option("--value", help="Enrichment value.")] = "", + uid: Annotated[str, typer.Option("--uid", help="Stable external identifier.")] = "", + desc: Annotated[str, typer.Option("--desc", help="Enrichment summary.")] = "", + data_json: Annotated[str | None, typer.Option("--data-json", help="JSON object string.")] = None, + data_file: Annotated[Path | None, typer.Option("--data-file", help="JSON object file.")] = None, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command( + ctx, + "enrichment.create", + output, + lambda runtime, out: _create_enrichment( + runtime, + out, + target_id, + name=name, + type=type, + value=value, + uid=uid, + desc=desc, + data_json=data_json, + data_file=data_file, + ), + ) + + +@playbook_template_app.command("list") +def playbook_template_list( + ctx: typer.Context, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command(ctx, "playbook.template.list", output, _list_playbook_templates) + + +@playbook_app.command("list") +def playbook_list( + ctx: typer.Context, + case_id: Annotated[str | None, typer.Option("--case-id", help="Linked case ID.")] = None, + job_status: Annotated[str | None, typer.Option("--job-status", help="Job status filter. Repeat values with commas.")] = None, + include_related: Annotated[bool, typer.Option("--include-related", help="Include related case.")] = False, + cursor: Annotated[str | None, typer.Option("--cursor", help="Pagination cursor.")] = None, + page_size: Annotated[int | None, typer.Option("--page-size", min=1, max=100, help="Page size.")] = None, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command( + ctx, + "playbook.list", + output, + lambda runtime, out: _list_playbooks( + runtime, + out, + case_id=case_id, + job_status=job_status, + include_related=include_related, + cursor=cursor, + page_size=page_size, + ), + ) + + +@playbook_app.command("show") +def playbook_show( + ctx: typer.Context, + playbook_id: Annotated[str, typer.Argument(help="Playbook run ID, for example playbook_000001.")], + include_related: Annotated[bool, typer.Option("--include-related/--no-include-related", help="Include related case.")] = True, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command(ctx, "playbook.show", output, lambda runtime, out: _show_playbook(runtime, out, playbook_id, include_related)) + + +@playbook_app.command("run") +def playbook_run( + ctx: typer.Context, + name: Annotated[str, typer.Argument(help="Playbook template name.")], + case_id: Annotated[str, typer.Argument(help="Case ID, for example case_000001.")], + user_input: Annotated[str | None, typer.Option("--user-input", help="Playbook user input.")] = None, + user_input_file: Annotated[Path | None, typer.Option("--user-input-file", help="Read user input from file.")] = None, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command( + ctx, + "playbook.run", + output, + lambda runtime, out: _run_playbook( + runtime, + out, + name, + case_id, + user_input=user_input, + user_input_file=user_input_file, + ), + ) + + +@siem_schema_app.command("list") +def siem_schema_list( + ctx: typer.Context, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command(ctx, "siem.schema", output, lambda runtime, out: _siem_schema(runtime, out, None)) + + +@siem_schema_app.command("show") +def siem_schema_show( + ctx: typer.Context, + target_index: Annotated[str, typer.Argument(help="Registered SIEM index/source name.")], + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command(ctx, "siem.schema", output, lambda runtime, out: _siem_schema(runtime, out, target_index)) + + +@siem_search_app.command("keyword") +def siem_search_keyword( + ctx: typer.Context, + keyword: Annotated[str, typer.Argument(help="Keyword or comma-separated AND keyword list.")], + time_range_start: Annotated[str, typer.Option("--from", help="ISO 8601 start time with timezone.")], + time_range_end: Annotated[str, typer.Option("--to", help="ISO 8601 end time with timezone.")], + time_field: Annotated[str, typer.Option("--time-field", help="Time field.")] = "@timestamp", + index_name: Annotated[str | None, typer.Option("--index-name", help="Optional target index/source.")] = None, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command( + ctx, + "siem.search.keyword", + output, + lambda runtime, out: _siem_keyword(runtime, out, keyword, time_range_start, time_range_end, time_field, index_name), + ) + + +@siem_query_app.command("adaptive") +def siem_query_adaptive( + ctx: typer.Context, + index_name: Annotated[str, typer.Argument(help="Target SIEM index/source name.")], + time_range_start: Annotated[str, typer.Option("--from", help="ISO 8601 start time with timezone.")], + time_range_end: Annotated[str, typer.Option("--to", help="ISO 8601 end time with timezone.")], + time_field: Annotated[str, typer.Option("--time-field", help="Time field.")] = "@timestamp", + filters_json: Annotated[str | None, typer.Option("--filters-json", help="Exact-match filters JSON object.")] = None, + filters_file: Annotated[Path | None, typer.Option("--filters-file", help="Exact-match filters JSON file.")] = None, + aggregation_fields: Annotated[str | None, typer.Option("--aggregation-fields", help="Comma-separated aggregation fields.")] = None, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command( + ctx, + "siem.query.adaptive", + output, + lambda runtime, out: _siem_adaptive(runtime, out, index_name, time_range_start, time_range_end, time_field, filters_json, filters_file, aggregation_fields), + ) + + +@siem_query_app.command("spl") +def siem_query_spl( + ctx: typer.Context, + query: Annotated[str, typer.Argument(help="Raw SPL query.")], + time_range_start: Annotated[str, typer.Option("--from", help="ISO 8601 start time with timezone.")], + time_range_end: Annotated[str, typer.Option("--to", help="ISO 8601 end time with timezone.")], + limit: Annotated[int, typer.Option("--limit", min=1, max=10000, help="Maximum records.")] = 100, + time_field: Annotated[str, typer.Option("--time-field", help="Time field.")] = "@timestamp", + index_name: Annotated[str | None, typer.Option("--index-name", help="Optional index/source label.")] = None, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command(ctx, "siem.query.spl", output, lambda runtime, out: _siem_raw_query(runtime, out, "spl", query, time_range_start, time_range_end, limit, time_field, index_name)) + + +@siem_query_app.command("esql") +def siem_query_esql( + ctx: typer.Context, + query: Annotated[str, typer.Argument(help="Raw ES|QL query.")], + time_range_start: Annotated[str, typer.Option("--from", help="ISO 8601 start time with timezone.")], + time_range_end: Annotated[str, typer.Option("--to", help="ISO 8601 end time with timezone.")], + limit: Annotated[int, typer.Option("--limit", min=1, max=10000, help="Maximum records.")] = 100, + time_field: Annotated[str, typer.Option("--time-field", help="Time field.")] = "@timestamp", + index_name: Annotated[str | None, typer.Option("--index-name", help="Optional index/source label.")] = None, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command(ctx, "siem.query.esql", output, lambda runtime, out: _siem_raw_query(runtime, out, "esql", query, time_range_start, time_range_end, limit, time_field, index_name)) + + +@siem_fields_app.command("discover") +def siem_fields_discover( + ctx: typer.Context, + index_name: Annotated[str, typer.Argument(help="Target SIEM index/source name.")], + backend: Annotated[str, typer.Argument(help="Backend: ELK or Splunk.")], + time_range_start: Annotated[str, typer.Option("--from", help="ISO 8601 start time with timezone.")], + time_range_end: Annotated[str, typer.Option("--to", help="ISO 8601 end time with timezone.")], + doc_limit: Annotated[int, typer.Option("--doc-limit", min=1, max=100000, help="Documents to sample.")] = 10000, + max_samples_per_field: Annotated[int, typer.Option("--max-samples-per-field", min=1, max=100, help="Samples per field.")] = 20, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command( + ctx, + "siem.fields.discover", + output, + lambda runtime, out: _siem_fields_discover(runtime, out, index_name, backend, time_range_start, time_range_end, doc_limit, max_samples_per_field), + ) + + +@ti_app.command("query") +def ti_query( + ctx: typer.Context, + indicator: Annotated[str, typer.Argument(help="Indicator value: IP, domain, URL, hash, etc.")], + artifact_type: Annotated[str, typer.Option("--artifact-type", help="Artifact type hint.")] = "Unknown", + provider: Annotated[str | None, typer.Option("--provider", help="Optional provider name.")] = None, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command(ctx, "ti.query", output, lambda runtime, out: _ti_query(runtime, out, indicator, artifact_type, provider)) + + +@cmdb_app.command("lookup") +def cmdb_lookup( + ctx: typer.Context, + artifact_type: Annotated[str, typer.Argument(help="Artifact type.")], + artifact_value: Annotated[str, typer.Argument(help="Artifact value.")], + provider: Annotated[str | None, typer.Option("--provider", help="Optional provider name.")] = None, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command(ctx, "cmdb.lookup", output, lambda runtime, out: _cmdb_lookup(runtime, out, artifact_type, artifact_value, provider)) + + +@dev_stream_app.command("head") +def dev_stream_head( + ctx: typer.Context, + stream_name: Annotated[str, typer.Argument(help="Redis stream name.")], + n: Annotated[int, typer.Option("-n", min=1, max=100, help="Number of messages.")] = 3, + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command(ctx, "dev.stream.head", output, lambda runtime, out: _dev_stream_head(runtime, out, stream_name, n)) + + +@dev_stream_app.command("read") +def dev_stream_read( + ctx: typer.Context, + stream_name: Annotated[str, typer.Argument(help="Redis stream name.")], + message_id: Annotated[str, typer.Argument(help="Redis stream message ID.")], + output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None, +) -> None: + run_command(ctx, "dev.stream.read", output, lambda runtime, out: _dev_stream_read(runtime, out, stream_name, message_id)) + + +@completion_app.command("powershell") +def completion_powershell() -> None: + console.print("Run this command to install PowerShell completion:") + console.print("asp --install-completion powershell", style="cyan") + + +@completion_app.command("bash") +def completion_bash() -> None: + console.print("Run this command to install Bash completion:") + console.print("asp --install-completion bash", style="cyan") + + +@completion_app.command("zsh") +def completion_zsh() -> None: + console.print("Run this command to install Zsh completion:") + console.print("asp --install-completion zsh", style="cyan") + + +def run_command(ctx: typer.Context, operation: str, output: OutputFormat | None, handler) -> None: + runtime = runtime_options(ctx) + out = output or runtime.output + try: + if runtime.query and out != OutputFormat.json: + raise CliError("query_requires_json", "--query requires --output json", {}, EXIT_USAGE) + handler(runtime, out) + except CliError as exc: + emit_error(err_console if out == OutputFormat.human else console, output=out, error=exc, operation=operation) + raise typer.Exit(exc.exit_code) from exc + + +def runtime_options(ctx: typer.Context) -> RuntimeOptions: + if isinstance(ctx.obj, RuntimeOptions): + return ctx.obj + parent = ctx.parent + while parent is not None: + if isinstance(parent.obj, RuntimeOptions): + return parent.obj + parent = parent.parent + return RuntimeOptions(api_url=None, api_key=None, output=OutputFormat.human, query=None, verbose=False) + + +def _auth_login(runtime: RuntimeOptions, output: OutputFormat, api_url: str, api_key: str, local: bool) -> None: + path = save_auth(api_url=api_url, api_key=api_key, local=local) + data = { + "scope": "local" if local else "global", + "settings_path": str(path), + "api_url": api_url.rstrip("/"), + "api_key": redact_secret(api_key), + } + if output == OutputFormat.human: + console.print(key_value_table("ASP auth saved", list(data.items()))) + console.print("Next: run [cyan]asp doctor[/cyan].") + return + emit_runtime_success(runtime, output=output, operation="auth.login", data=data) + + +def _auth_status(runtime: RuntimeOptions, output: OutputFormat) -> None: + config = _require_config(runtime) + client = AspClient(api_url=config.api_url or "", api_key=config.api_key, verbose=runtime.verbose, console=err_console) + payload = client.version() + data = { + "api_url": config.api_url, + "api_url_source": config.sources.get("api_url"), + "api_key_source": config.sources.get("api_key"), + "api_key": redact_secret(config.api_key), + "server": payload.get("data", {}), + } + if output == OutputFormat.human: + server = data["server"] + user = server.get("user") or {} + console.print( + key_value_table( + "ASP auth status", + [ + ("API URL", data["api_url"]), + ("API URL source", data["api_url_source"]), + ("API key source", data["api_key_source"]), + ("API key", data["api_key"]), + ("User", user.get("username")), + ("Role", user.get("role")), + ("API version", server.get("api_version")), + ], + ) + ) + return + emit_runtime_success(runtime, output=output, operation="auth.status", data=data) + + +def _auth_logout(runtime: RuntimeOptions, output: OutputFormat, local: bool) -> None: + path = clear_auth(local=local) + data = {"scope": "local" if local else "global", "settings_path": str(path)} + if output == OutputFormat.human: + console.print(key_value_table("ASP auth removed", list(data.items()))) + return + emit_runtime_success(runtime, output=output, operation="auth.logout", data=data) + + +def _config_list(runtime: RuntimeOptions, output: OutputFormat) -> None: + config = resolve_config(api_url=runtime.api_url, api_key=runtime.api_key) + global_settings = _redacted_settings(read_settings(config.global_path)) + local_settings = _redacted_settings(read_settings(config.local_path) if config.local_path else {}) + data = { + "global_path": str(config.global_path), + "local_path": str(config.local_path) if config.local_path else None, + "global": global_settings, + "local": local_settings, + "resolved": { + "api_url": config.api_url, + "api_url_source": config.sources.get("api_url"), + "api_key": redact_secret(config.api_key), + "api_key_source": config.sources.get("api_key"), + }, + } + if output == OutputFormat.human: + console.print(key_value_table("ASP config", [ + ("Global path", data["global_path"]), + ("Local path", data["local_path"]), + ("Resolved API URL", data["resolved"]["api_url"]), + ("API URL source", data["resolved"]["api_url_source"]), + ("Resolved API key", data["resolved"]["api_key"]), + ("API key source", data["resolved"]["api_key_source"]), + ])) + return + emit_runtime_success(runtime, output=output, operation="config.list", data=data) + + +def _config_get(runtime: RuntimeOptions, output: OutputFormat, key: str) -> None: + value, source = get_config_value(key, api_url=runtime.api_url, api_key=runtime.api_key) + display_value = redact_secret(value) if key == "api_key" else value + data = {"key": key, "value": display_value, "source": source} + if output == OutputFormat.human: + console.print(key_value_table("ASP config value", list(data.items()))) + return + emit_runtime_success(runtime, output=output, operation="config.get", data=data) + + +def _config_set(runtime: RuntimeOptions, output: OutputFormat, key: str, value: str, local: bool) -> None: + path = set_config_value(key, value, local=local) + data = {"key": key, "scope": "local" if local else "global", "settings_path": str(path)} + if output == OutputFormat.human: + console.print(key_value_table("ASP config saved", list(data.items()))) + return + emit_runtime_success(runtime, output=output, operation="config.set", data=data) + + +def _doctor(runtime: RuntimeOptions, output: OutputFormat) -> None: + config = _require_config(runtime) + client = AspClient(api_url=config.api_url or "", api_key=config.api_key, verbose=runtime.verbose, console=err_console) + checks = [] + ok = True + + try: + health = client.health() + checks.append({"name": "health", "ok": True, "detail": health.get("status", "ok")}) + except CliError as exc: + ok = False + checks.append({"name": "health", "ok": False, "detail": exc.message}) + + version_payload = None + try: + version_payload = client.version() + checks.append({"name": "auth", "ok": True, "detail": "authenticated"}) + checks.append({"name": "version", "ok": True, "detail": version_payload.get("data", {}).get("api_version")}) + except CliError as exc: + ok = False + checks.append({"name": "auth", "ok": False, "detail": exc.message}) + + data = { + "ok": ok, + "api_url": config.api_url, + "api_url_source": config.sources.get("api_url"), + "api_key_source": config.sources.get("api_key"), + "checks": checks, + "server": version_payload.get("data") if version_payload else None, + } + + if output == OutputFormat.human: + console.print(key_value_table("ASP doctor", [ + ("Overall", "ok" if ok else "failed"), + ("API URL", data["api_url"]), + ("API URL source", data["api_url_source"]), + ("API key source", data["api_key_source"]), + ])) + for check in checks: + style = "green" if check["ok"] else "red" + console.print(f"{check['name']}: {check['detail']}", style=style) + else: + emit_runtime_success(runtime, output=output, operation="doctor", data=data) + + if not ok: + raise typer.Exit(1) + + +def _list_cases(runtime: RuntimeOptions, output: OutputFormat, **filters) -> None: + payload = _agent_get(runtime, "/api/agent/v1/cases/", _clean_params(filters)) + _emit_agent_payload(runtime, output, "case.list", payload, lambda data, meta: _list_table( + "ASP cases", + ["case_id", "title", "severity", "status", "verdict", "priority", "created_at"], + data, + meta, + )) + + +def _show_case(runtime: RuntimeOptions, output: OutputFormat, case_id: str, include_related: bool) -> None: + payload = _agent_get(runtime, f"/api/agent/v1/cases/{case_id}/", {"include_related": include_related}) + _emit_agent_payload(runtime, output, "case.show", payload, lambda data, _meta: _detail_table( + "ASP case", + data, + ["case_id", "title", "severity", "confidence", "impact", "priority", "status", "verdict", "severity_ai", "confidence_ai", "impact_ai", "priority_ai", "verdict_ai", "summary", "correlation_uid", "tags", "created_at"], + )) + + +def _update_case_ai(runtime: RuntimeOptions, output: OutputFormat, case_id: str, **fields) -> None: + summary_file = fields.pop("summary_file") + if summary_file is not None: + fields["summary"] = _read_text_file(summary_file) + body = {key: value for key, value in fields.items() if value is not None} + if not body: + raise CliError("missing_update_fields", "At least one AI analysis field is required", {}, EXIT_USAGE) + payload = _agent_request(runtime, "PATCH", f"/api/agent/v1/cases/{case_id}/ai-analysis/", json=body) + _emit_agent_payload(runtime, output, "case.update_ai", payload, lambda data, _meta: _detail_table( + "Updated ASP case AI analysis", + data, + ["case_id", "severity_ai", "confidence_ai", "impact_ai", "priority_ai", "verdict_ai", "summary"], + )) + + +def _list_alerts(runtime: RuntimeOptions, output: OutputFormat, **filters) -> None: + payload = _agent_get(runtime, "/api/agent/v1/alerts/", _clean_params(filters)) + _emit_agent_payload(runtime, output, "alert.list", payload, lambda data, meta: _list_table( + "ASP alerts", + ["alert_id", "case_id", "title", "severity", "status", "confidence", "created_at"], + data, + meta, + )) + + +def _show_alert(runtime: RuntimeOptions, output: OutputFormat, alert_id: str, include_related: bool) -> None: + payload = _agent_get(runtime, f"/api/agent/v1/alerts/{alert_id}/", {"include_related": include_related}) + _emit_agent_payload(runtime, output, "alert.show", payload, lambda data, _meta: _detail_table( + "ASP alert", + data, + ["alert_id", "case_id", "title", "severity", "confidence", "impact", "status", "correlation_uid", "source_uid", "rule_id", "rule_name", "created_at"], + )) + + +def _list_artifacts(runtime: RuntimeOptions, output: OutputFormat, **filters) -> None: + payload = _agent_get(runtime, "/api/agent/v1/artifacts/", _clean_params(filters)) + _emit_agent_payload(runtime, output, "artifact.list", payload, lambda data, meta: _list_table( + "ASP artifacts", + ["artifact_id", "type", "role", "name", "value", "created_at"], + data, + meta, + )) + + +def _show_artifact(runtime: RuntimeOptions, output: OutputFormat, artifact_id: str, include_related: bool) -> None: + payload = _agent_get(runtime, f"/api/agent/v1/artifacts/{artifact_id}/", {"include_related": include_related}) + _emit_agent_payload(runtime, output, "artifact.show", payload, lambda data, _meta: _detail_table( + "ASP artifact", + data, + ["artifact_id", "type", "role", "name", "value", "created_at"], + )) + + +def _search_knowledge(runtime: RuntimeOptions, output: OutputFormat, **filters) -> None: + payload = _agent_get(runtime, "/api/agent/v1/knowledge/", _clean_params(filters)) + _emit_agent_payload(runtime, output, "knowledge.search", payload, lambda data, meta: _list_table( + "ASP knowledge", + ["knowledge_id", "title", "source", "case_id", "tags", "created_at"], + data, + meta, + )) + + +def _show_knowledge(runtime: RuntimeOptions, output: OutputFormat, knowledge_id: str) -> None: + payload = _agent_get(runtime, f"/api/agent/v1/knowledge/{knowledge_id}/", {}) + _emit_agent_payload(runtime, output, "knowledge.show", payload, lambda data, _meta: _detail_table( + "ASP knowledge", + data, + ["knowledge_id", "title", "source", "case_id", "tags", "expires_at", "body", "created_at"], + )) + + +def _update_knowledge(runtime: RuntimeOptions, output: OutputFormat, knowledge_id: str, **fields) -> None: + body_file = fields.pop("body_file") + if body_file is not None: + fields["body"] = _read_text_file(body_file) + if fields.get("tags") is not None: + fields["tags"] = _split_csv(fields["tags"]) + body = {key: value for key, value in fields.items() if value is not None} + if not body: + raise CliError("missing_update_fields", "At least one knowledge field is required", {}, EXIT_USAGE) + payload = _agent_request(runtime, "PATCH", f"/api/agent/v1/knowledge/{knowledge_id}/", json=body) + _emit_agent_payload(runtime, output, "knowledge.update", payload, lambda data, _meta: _detail_table( + "Updated ASP knowledge", + data, + ["knowledge_id", "title", "source", "case_id", "tags", "expires_at", "body"], + )) + + +def _list_comments(runtime: RuntimeOptions, output: OutputFormat, target_id: str, cursor: str | None, page_size: int | None) -> None: + payload = _agent_get(runtime, "/api/agent/v1/comments/", _clean_params({ + "target_id": target_id, + "cursor": cursor, + "page_size": page_size, + })) + _emit_agent_payload(runtime, output, "comment.list", payload, lambda data, meta: _list_table( + "ASP comments", + ["id", "author", "body", "parent_id", "created_at"], + data, + meta, + )) + + +def _add_comment(runtime: RuntimeOptions, output: OutputFormat, target_id: str, **fields) -> None: + body_file = fields.pop("body_file") + if body_file is not None: + fields["body"] = _read_text_file(body_file) + body = { + "target_id": target_id, + "body": fields.get("body") or "", + "file_keys": _split_csv(fields.get("file_key")), + "parent_id": fields.get("parent_id"), + "mentions": _split_csv(fields.get("mentions")), + } + if not body["body"].strip() and not body["file_keys"]: + raise CliError("missing_comment_content", "Comment body or file_key is required", {}, EXIT_USAGE) + payload = _agent_request(runtime, "POST", "/api/agent/v1/comments/", json=body) + _emit_agent_payload(runtime, output, "comment.add", payload, lambda data, _meta: _detail_table( + "ASP comment added", + data, + ["id", "author", "body", "parent_id", "created_at"], + )) + + +def _upload_file(runtime: RuntimeOptions, output: OutputFormat, path: Path) -> None: + if not path.exists() or not path.is_file(): + raise CliError("file_not_found", f"File not found: {path}", {"path": str(path)}, EXIT_USAGE) + with path.open("rb") as handle: + payload = _agent_request(runtime, "POST", "/api/agent/v1/files/", files={"file": (path.name, handle)}) + _emit_agent_payload(runtime, output, "file.upload", payload, lambda data, _meta: _detail_table( + "ASP file uploaded", + data, + ["file_key", "filename", "size", "content_type", "download_url"], + )) + + +def _file_info(runtime: RuntimeOptions, output: OutputFormat, file_key: str) -> None: + payload = _agent_get(runtime, f"/api/agent/v1/files/{file_key}/", {}) + _emit_agent_payload(runtime, output, "file.info", payload, lambda data, _meta: _detail_table( + "ASP file", + data, + ["file_key", "filename", "size", "content_type", "download_url", "uploaded_at"], + )) + + +def _download_file(runtime: RuntimeOptions, output: OutputFormat, file_key: str, output_path: Path | None) -> None: + info = _agent_get(runtime, f"/api/agent/v1/files/{file_key}/", {}) + data = info.get("data") or {} + target_path = output_path or Path(data.get("filename") or file_key) + try: + response = httpx.get(data["download_url"], timeout=60.0) + response.raise_for_status() + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.write_bytes(response.content) + except (KeyError, httpx.HTTPError, OSError) as exc: + raise CliError("file_download_failed", f"Unable to download file: {file_key}", {"file_key": file_key}, EXIT_NETWORK) from exc + result = {**data, "output_path": str(target_path)} + if output == OutputFormat.human: + console.print(key_value_table("ASP file downloaded", [ + ("file_key", result.get("file_key")), + ("filename", result.get("filename")), + ("output_path", result.get("output_path")), + ("size", result.get("size")), + ])) + return + emit_runtime_success(runtime, output=output, operation="file.download", data=result, meta=info.get("meta")) + + +def _read_file_text(runtime: RuntimeOptions, output: OutputFormat, file_key: str, max_bytes: int) -> None: + payload = _agent_get(runtime, f"/api/agent/v1/files/{file_key}/read-text/", {"max_bytes": max_bytes}) + _emit_agent_payload(runtime, output, "file.read_text", payload, lambda data, _meta: data.get("text", "")) + + +def _create_enrichment(runtime: RuntimeOptions, output: OutputFormat, target_id: str, **fields) -> None: + data_file = fields.pop("data_file") + data_json = fields.pop("data_json") + body = {"target_id": target_id, **fields} + if data_file is not None: + body["data"] = _read_json_object_file(data_file) + elif data_json is not None: + body["data"] = _read_json_object_text(data_json) + else: + body["data"] = {} + payload = _agent_request(runtime, "POST", "/api/agent/v1/enrichments/", json=body) + _emit_agent_payload(runtime, output, "enrichment.create", payload, lambda data, _meta: _detail_table( + "ASP enrichment created", + data, + ["enrichment_id", "target_id", "name", "type", "provider", "uid", "value", "desc", "created_at"], + )) + + +def _list_playbook_templates(runtime: RuntimeOptions, output: OutputFormat) -> None: + payload = _agent_get(runtime, "/api/agent/v1/playbooks/templates/", {}) + _emit_agent_payload(runtime, output, "playbook.template.list", payload, lambda data, _meta: _list_table( + "ASP playbook templates", + ["name", "description", "tags"], + data, + )) + + +def _list_playbooks(runtime: RuntimeOptions, output: OutputFormat, **filters) -> None: + payload = _agent_get(runtime, "/api/agent/v1/playbooks/", _clean_params(filters)) + _emit_agent_payload(runtime, output, "playbook.list", payload, lambda data, meta: _list_table( + "ASP playbooks", + ["playbook_id", "case_id", "name", "job_status", "job_id", "created_at"], + data, + meta, + )) + + +def _show_playbook(runtime: RuntimeOptions, output: OutputFormat, playbook_id: str, include_related: bool) -> None: + payload = _agent_get(runtime, f"/api/agent/v1/playbooks/{playbook_id}/", {"include_related": include_related}) + _emit_agent_payload(runtime, output, "playbook.show", payload, lambda data, _meta: _detail_table( + "ASP playbook", + data, + ["playbook_id", "case_id", "name", "user_input", "job_status", "job_id", "remark", "created_at"], + )) + + +def _run_playbook(runtime: RuntimeOptions, output: OutputFormat, name: str, case_id: str, **fields) -> None: + user_input_file = fields.get("user_input_file") + user_input = _read_text_file(user_input_file) if user_input_file is not None else (fields.get("user_input") or "") + payload = _agent_request(runtime, "POST", "/api/agent/v1/playbooks/run/", json={ + "name": name, + "case_id": case_id, + "user_input": user_input, + }) + _emit_agent_payload(runtime, output, "playbook.run", payload, lambda data, _meta: _detail_table( + "ASP playbook queued", + data, + ["playbook_id", "case_id", "name", "job_status", "created_at"], + )) + + +def _siem_schema(runtime: RuntimeOptions, output: OutputFormat, target_index: str | None) -> None: + payload = _agent_get(runtime, "/api/agent/v1/siem/schema/", _clean_params({"target_index": target_index})) + if target_index: + renderer = lambda data, _meta: _siem_schema_detail(data) + else: + renderer = lambda data, _meta: _list_table( + "ASP SIEM schema", + ["name", "backend", "description", "default_aggregation_fields"], + data, + ) + _emit_agent_payload(runtime, output, "siem.schema", payload, renderer) + + +def _siem_keyword(runtime: RuntimeOptions, output: OutputFormat, keyword: str, time_range_start: str, time_range_end: str, time_field: str, index_name: str | None) -> None: + body = { + "keyword": _split_csv(keyword) if "," in keyword else keyword, + "time_range_start": time_range_start, + "time_range_end": time_range_end, + "time_field": time_field, + "index_name": index_name, + } + payload = _agent_request(runtime, "POST", "/api/agent/v1/siem/search/keyword/", json=_clean_params(body)) + _emit_agent_payload(runtime, output, "siem.search.keyword", payload, _siem_query_table) + + +def _siem_adaptive( + runtime: RuntimeOptions, + output: OutputFormat, + index_name: str, + time_range_start: str, + time_range_end: str, + time_field: str, + filters_json: str | None, + filters_file: Path | None, + aggregation_fields: str | None, +) -> None: + filters = {} + if filters_file is not None: + filters = _read_json_object_file(filters_file) + elif filters_json is not None: + filters = _read_json_object_text(filters_json) + payload = _agent_request(runtime, "POST", "/api/agent/v1/siem/query/adaptive/", json={ + "index_name": index_name, + "time_range_start": time_range_start, + "time_range_end": time_range_end, + "time_field": time_field, + "filters": filters, + "aggregation_fields": _split_csv(aggregation_fields), + }) + _emit_agent_payload(runtime, output, "siem.query.adaptive", payload, _siem_query_table) + + +def _siem_raw_query( + runtime: RuntimeOptions, + output: OutputFormat, + kind: str, + query: str, + time_range_start: str, + time_range_end: str, + limit: int, + time_field: str, + index_name: str | None, +) -> None: + payload = _agent_request(runtime, "POST", f"/api/agent/v1/siem/query/{kind}/", json=_clean_params({ + "query": query, + "time_range_start": time_range_start, + "time_range_end": time_range_end, + "limit": limit, + "time_field": time_field, + "index_name": index_name, + })) + _emit_agent_payload(runtime, output, f"siem.query.{kind}", payload, _siem_query_table) + + +def _siem_fields_discover(runtime: RuntimeOptions, output: OutputFormat, index_name: str, backend: str, time_range_start: str, time_range_end: str, doc_limit: int, max_samples_per_field: int) -> None: + payload = _agent_request(runtime, "POST", "/api/agent/v1/siem/fields/discover/", json={ + "index_name": index_name, + "backend": backend, + "time_range_start": time_range_start, + "time_range_end": time_range_end, + "doc_limit": doc_limit, + "max_samples_per_field": max_samples_per_field, + }) + _emit_agent_payload(runtime, output, "siem.fields.discover", payload, lambda data, _meta: _detail_table( + "ASP SIEM fields", + data, + ["backend", "index_name", "total_fields"], + )) + + +def _ti_query(runtime: RuntimeOptions, output: OutputFormat, indicator: str, artifact_type: str, provider: str | None) -> None: + payload = _agent_request(runtime, "POST", "/api/agent/v1/threat-intel/query/", json=_clean_params({ + "indicator": indicator, + "artifact_type": artifact_type, + "provider": provider, + })) + _emit_agent_payload(runtime, output, "ti.query", payload, lambda data, _meta: _detail_table( + "ASP threat intelligence", + data, + ["indicator", "indicator_type", "aggregated_risk_level", "errors"], + )) + + +def _cmdb_lookup(runtime: RuntimeOptions, output: OutputFormat, artifact_type: str, artifact_value: str, provider: str | None) -> None: + payload = _agent_request(runtime, "POST", "/api/agent/v1/cmdb/lookup/", json=_clean_params({ + "artifact_type": artifact_type, + "artifact_value": artifact_value, + "provider": provider, + })) + _emit_agent_payload(runtime, output, "cmdb.lookup", payload, lambda data, _meta: _detail_table( + "ASP CMDB lookup", + data, + ["artifact_type", "artifact_value", "errors"], + )) + + +def _dev_stream_head(runtime: RuntimeOptions, output: OutputFormat, stream_name: str, n: int) -> None: + payload = _agent_get(runtime, "/api/agent/v1/dev/streams/head/", {"stream_name": stream_name, "n": n}) + _emit_agent_payload(runtime, output, "dev.stream.head", payload, lambda data, _meta: _list_table( + "ASP stream head", + ["message_id", "data"], + data, + )) + + +def _dev_stream_read(runtime: RuntimeOptions, output: OutputFormat, stream_name: str, message_id: str) -> None: + payload = _agent_get(runtime, "/api/agent/v1/dev/streams/message/", {"stream_name": stream_name, "message_id": message_id}) + _emit_agent_payload(runtime, output, "dev.stream.read", payload, lambda data, _meta: _detail_table( + "ASP stream message", + data, + ["message_id", "data"], + )) + + +def _agent_get(runtime: RuntimeOptions, path: str, params: dict) -> dict: + return _agent_request(runtime, "GET", _path_with_query(path, params)) + + +def _agent_request(runtime: RuntimeOptions, method: str, path: str, *, json=None, files=None) -> dict: + config = _require_config(runtime) + client = AspClient(api_url=config.api_url or "", api_key=config.api_key, verbose=runtime.verbose, console=err_console) + return client.request(method, path, json=json, files=files) + + +def _path_with_query(path: str, params: dict) -> str: + cleaned = _clean_params(params) + if not cleaned: + return path + return f"{path}?{urlencode(cleaned, doseq=True)}" + + +def _clean_params(params: dict) -> dict: + cleaned = {} + for key, value in params.items(): + if value is None or value is False or value == "": + continue + if key == "tags": + cleaned["tag"] = _split_csv(value) + elif isinstance(value, str) and "," in value and key in {"status", "severity", "confidence", "verdict", "type", "role"}: + cleaned[key] = _split_csv(value) + else: + cleaned[key] = value + return cleaned + + +def _emit_agent_payload(runtime: RuntimeOptions, output: OutputFormat, operation: str, payload: dict, human_renderer) -> None: + data = payload.get("data") + meta = payload.get("meta") or {} + if output == OutputFormat.human: + console.print(human_renderer(data, meta)) + return + emit_runtime_success(runtime, output=output, operation=operation, data=data, meta=meta) + + +def _list_table(title: str, columns: list[str], rows: list[dict], meta: dict | None = None) -> Table: + table = Table(title=title) + for column in columns: + table.add_column(column) + for row in rows or []: + table.add_row(*[_format_cell(row.get(column)) for column in columns]) + pagination = (meta or {}).get("pagination") or {} + if pagination.get("has_more"): + table.caption = f"More results available. Continue with --cursor {pagination.get('next_cursor')}" + return table + + +def _detail_table(title: str, data: dict, fields: list[str]) -> Table: + return key_value_table(title, [(field, _format_cell(data.get(field))) for field in fields if field in data]) + + +def _siem_schema_detail(data: dict) -> Table: + table = _detail_table("ASP SIEM schema", data, ["name", "backend", "description"]) + fields = data.get("fields") or [] + table.caption = f"{len(fields)} fields. Use --output json for full field metadata." + return table + + +def _siem_query_table(data, _meta) -> Table: + rows = data if isinstance(data, list) else [data] + return _list_table( + "ASP SIEM query", + ["backend", "index_name", "status", "total_hits", "returned_records", "truncated", "message"], + rows, + ) + + +def _format_cell(value) -> str: + if value is None: + return "" + if isinstance(value, list): + return ", ".join(str(item) for item in value) + if isinstance(value, dict): + return ", ".join(f"{key}={val}" for key, val in value.items()) + text = str(value) + return text if len(text) <= 160 else f"{text[:157]}..." + + +def _split_csv(value: str | None) -> list[str]: + if not value: + return [] + return [item.strip() for item in value.split(",") if item.strip()] + + +def _read_text_file(path: Path) -> str: + try: + return path.read_text(encoding="utf-8") + except OSError as exc: + raise CliError("file_read_failed", f"Unable to read file: {path}", {"path": str(path)}, EXIT_USAGE) from exc + + +def _read_json_object_file(path: Path) -> dict: + return _read_json_object_text(_read_text_file(path)) + + +def _read_json_object_text(text: str) -> dict: + try: + payload = json.loads(text) + except json.JSONDecodeError as exc: + raise CliError("invalid_json", "Expected a valid JSON object", {}, EXIT_USAGE) from exc + if not isinstance(payload, dict): + raise CliError("invalid_json", "Expected a valid JSON object", {}, EXIT_USAGE) + return payload + + +def _require_config(runtime: RuntimeOptions): + config = resolve_config(api_url=runtime.api_url, api_key=runtime.api_key) + missing = [] + if not config.api_url: + missing.append("api_url") + if not config.api_key: + missing.append("api_key") + if missing: + raise CliError( + "missing_config", + "ASP API URL and API key are required. Run: asp auth login --api-url --api-key ", + {"missing": missing}, + EXIT_AUTH if "api_key" in missing else EXIT_CONFIG, + ) + return config + + +def _redacted_settings(settings: dict) -> dict: + redacted = dict(settings) + if "api_key" in redacted: + redacted["api_key"] = redact_secret(str(redacted["api_key"])) + return redacted + + +def apply_query(data, query: str | None): + if not query: + return data + return jmespath.search(query, data) + + +def emit_runtime_success( + runtime: RuntimeOptions, + *, + output: OutputFormat, + operation: str, + data, + meta: dict | None = None, + human: str | None = None, +) -> None: + emit_success( + console, + output=output, + operation=operation, + data=apply_query(data, runtime.query) if output == OutputFormat.json else data, + meta=meta, + human=human, + ) + + +def run() -> None: + app() + + +if __name__ == "__main__": + run() diff --git a/cli/src/asp_cli/output.py b/cli/src/asp_cli/output.py new file mode 100644 index 0000000..317a225 --- /dev/null +++ b/cli/src/asp_cli/output.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import json +from enum import Enum +from typing import Any + +from rich.console import Console +from rich.table import Table + +from .errors import CliError + + +class OutputFormat(str, Enum): + human = "human" + json = "json" + + +def emit_success( + console: Console, + *, + output: OutputFormat, + operation: str, + data: Any, + meta: dict[str, Any] | None = None, + human: str | None = None, +) -> None: + if output == OutputFormat.json: + payload = { + "data": data, + "meta": { + "operation": operation, + **(meta or {}), + }, + } + console.print(json.dumps(payload, ensure_ascii=False, indent=2)) + return + if human is not None: + console.print(human) + return + console.print(data) + + +def emit_error(console: Console, *, output: OutputFormat, error: CliError, operation: str | None = None) -> None: + if output == OutputFormat.json: + payload = { + "error": { + "code": error.code, + "message": error.message, + "details": error.details, + }, + "meta": { + "operation": operation, + }, + } + console.print(json.dumps(payload, ensure_ascii=False, indent=2)) + return + console.print(f"Error: {error.message}", style="bold red") + + +def key_value_table(title: str, rows: list[tuple[str, Any]]) -> Table: + table = Table(title=title, show_header=False) + table.add_column("Key", style="cyan", no_wrap=True) + table.add_column("Value") + for key, value in rows: + table.add_row(key, "" if value is None else str(value)) + return table diff --git a/cli/src/asp_cli/py.typed b/cli/src/asp_cli/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/cli/src/asp_cli/spec/__init__.py b/cli/src/asp_cli/spec/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/src/asp_cli/spec/operations.json b/cli/src/asp_cli/spec/operations.json new file mode 100644 index 0000000..fe71fd7 --- /dev/null +++ b/cli/src/asp_cli/spec/operations.json @@ -0,0 +1,404 @@ +{ + "schema_version": "0.1.0", + "api_version": "v1", + "min_cli_version": "0.1.0", + "operations": [ + { + "id": "agent.version", + "cli_path": "doctor", + "method": "GET", + "endpoint": "/api/agent/v1/version/", + "permission": "authenticated", + "capabilities": ["agent.version"], + "aliases": [], + "examples": [ + "asp doctor", + "asp doctor --output json" + ] + }, + { + "id": "auth.login", + "cli_path": "auth login", + "method": "local", + "endpoint": null, + "permission": "local", + "capabilities": [], + "aliases": [], + "examples": [ + "asp auth login --api-url https://asp.example.com --api-key asp_xxx" + ] + }, + { + "id": "auth.status", + "cli_path": "auth status", + "method": "GET", + "endpoint": "/api/agent/v1/version/", + "permission": "authenticated", + "capabilities": ["agent.version"], + "aliases": [], + "examples": [ + "asp auth status", + "asp auth status --output json" + ] + }, + { + "id": "auth.logout", + "cli_path": "auth logout", + "method": "local", + "endpoint": null, + "permission": "local", + "capabilities": [], + "aliases": [], + "examples": [ + "asp auth logout" + ] + }, + { + "id": "config.list", + "cli_path": "config list", + "method": "local", + "endpoint": null, + "permission": "local", + "capabilities": [], + "aliases": [], + "examples": [ + "asp config list", + "asp --output json config list" + ] + }, + { + "id": "config.get", + "cli_path": "config get", + "method": "local", + "endpoint": null, + "permission": "local", + "capabilities": [], + "aliases": [], + "examples": [ + "asp config get api_url" + ] + }, + { + "id": "config.set", + "cli_path": "config set", + "method": "local", + "endpoint": null, + "permission": "local", + "capabilities": [], + "aliases": [], + "examples": [ + "asp config set api_url https://asp.example.com" + ] + }, + { + "id": "case.list", + "cli_path": "case list", + "method": "GET", + "endpoint": "/api/agent/v1/cases/", + "permission": "authenticated", + "capabilities": ["case.list"], + "aliases": ["list_cases"], + "examples": ["asp case list --status New --output json"] + }, + { + "id": "case.show", + "cli_path": "case show", + "method": "GET", + "endpoint": "/api/agent/v1/cases/{case_id}/", + "permission": "authenticated", + "capabilities": ["case.show"], + "aliases": ["list_cases(case_id=...)"], + "examples": ["asp case show case_000001 --output json"] + }, + { + "id": "case.update_ai", + "cli_path": "case update-ai", + "method": "PATCH", + "endpoint": "/api/agent/v1/cases/{case_id}/ai-analysis/", + "permission": "business_writer", + "capabilities": ["case.update_ai"], + "aliases": ["update_case"], + "examples": ["asp case update-ai case_000001 --summary-file summary.md --output json"] + }, + { + "id": "alert.list", + "cli_path": "alert list", + "method": "GET", + "endpoint": "/api/agent/v1/alerts/", + "permission": "authenticated", + "capabilities": ["alert.list"], + "aliases": ["list_alerts"], + "examples": ["asp alert list --case-id case_000001 --output json"] + }, + { + "id": "alert.show", + "cli_path": "alert show", + "method": "GET", + "endpoint": "/api/agent/v1/alerts/{alert_id}/", + "permission": "authenticated", + "capabilities": ["alert.show"], + "aliases": ["list_alerts(alert_id=...)"], + "examples": ["asp alert show alert_000001 --output json"] + }, + { + "id": "artifact.list", + "cli_path": "artifact list", + "method": "GET", + "endpoint": "/api/agent/v1/artifacts/", + "permission": "authenticated", + "capabilities": ["artifact.list"], + "aliases": ["list_artifacts"], + "examples": ["asp artifact list --type \"IP Address\" --output json"] + }, + { + "id": "artifact.show", + "cli_path": "artifact show", + "method": "GET", + "endpoint": "/api/agent/v1/artifacts/{artifact_id}/", + "permission": "authenticated", + "capabilities": ["artifact.show"], + "aliases": ["list_artifacts(artifact_id=...)"], + "examples": ["asp artifact show artifact_000001 --output json"] + }, + { + "id": "knowledge.search", + "cli_path": "knowledge search", + "method": "GET", + "endpoint": "/api/agent/v1/knowledge/", + "permission": "authenticated", + "capabilities": ["knowledge.search"], + "aliases": ["search_knowledge"], + "examples": ["asp knowledge search phishing --output json"] + }, + { + "id": "knowledge.show", + "cli_path": "knowledge show", + "method": "GET", + "endpoint": "/api/agent/v1/knowledge/{knowledge_id}/", + "permission": "authenticated", + "capabilities": ["knowledge.show"], + "aliases": [], + "examples": ["asp knowledge show knowledge_000001 --output json"] + }, + { + "id": "knowledge.update", + "cli_path": "knowledge update", + "method": "PATCH", + "endpoint": "/api/agent/v1/knowledge/{knowledge_id}/", + "permission": "business_writer", + "capabilities": ["knowledge.update"], + "aliases": ["update_knowledge"], + "examples": ["asp knowledge update knowledge_000001 --body-file note.md --output json"] + }, + { + "id": "comment.list", + "cli_path": "comment list", + "method": "GET", + "endpoint": "/api/agent/v1/comments/", + "permission": "authenticated", + "capabilities": ["comment.list"], + "aliases": [], + "examples": ["asp comment list case_000001 --output json"] + }, + { + "id": "comment.add", + "cli_path": "comment add", + "method": "POST", + "endpoint": "/api/agent/v1/comments/", + "permission": "business_writer", + "capabilities": ["comment.add"], + "aliases": ["add_comment"], + "examples": ["asp comment add case_000001 --body-file note.md --output json"] + }, + { + "id": "file.upload", + "cli_path": "file upload", + "method": "POST", + "endpoint": "/api/agent/v1/files/", + "permission": "authenticated", + "capabilities": ["file.upload"], + "aliases": [], + "examples": ["asp file upload evidence.txt --output json"] + }, + { + "id": "file.info", + "cli_path": "file info", + "method": "GET", + "endpoint": "/api/agent/v1/files/{file_key}/", + "permission": "authenticated", + "capabilities": ["file.info"], + "aliases": ["get_file"], + "examples": ["asp file info 6f2c5d7e-31c6-4f48-9e3c-6d9b5f92c457 --output json"] + }, + { + "id": "file.download", + "cli_path": "file download", + "method": "GET", + "endpoint": "/api/agent/v1/files/{file_key}/", + "permission": "authenticated", + "capabilities": ["file.info"], + "aliases": [], + "examples": ["asp file download 6f2c5d7e-31c6-4f48-9e3c-6d9b5f92c457 --output-path evidence.txt"] + }, + { + "id": "file.read_text", + "cli_path": "file read-text", + "method": "GET", + "endpoint": "/api/agent/v1/files/{file_key}/read-text/", + "permission": "authenticated", + "capabilities": ["file.read_text"], + "aliases": [], + "examples": ["asp file read-text 6f2c5d7e-31c6-4f48-9e3c-6d9b5f92c457 --max-bytes 4096 --output json"] + }, + { + "id": "enrichment.create", + "cli_path": "enrichment create", + "method": "POST", + "endpoint": "/api/agent/v1/enrichments/", + "permission": "business_writer", + "capabilities": ["enrichment.create"], + "aliases": ["create_enrichment"], + "examples": ["asp enrichment create case_000001 --name ti --data-file enrichment.json --output json"] + }, + { + "id": "playbook.template.list", + "cli_path": "playbook template list", + "method": "GET", + "endpoint": "/api/agent/v1/playbooks/templates/", + "permission": "authenticated", + "capabilities": ["playbook.template.list"], + "aliases": ["list_playbook_templates"], + "examples": ["asp playbook template list --output json"] + }, + { + "id": "playbook.list", + "cli_path": "playbook list", + "method": "GET", + "endpoint": "/api/agent/v1/playbooks/", + "permission": "authenticated", + "capabilities": ["playbook.list"], + "aliases": ["list_playbooks"], + "examples": ["asp playbook list --case-id case_000001 --output json"] + }, + { + "id": "playbook.show", + "cli_path": "playbook show", + "method": "GET", + "endpoint": "/api/agent/v1/playbooks/{playbook_id}/", + "permission": "authenticated", + "capabilities": ["playbook.show"], + "aliases": ["list_playbooks(playbook_id=...)"], + "examples": ["asp playbook show playbook_000001 --output json"] + }, + { + "id": "playbook.run", + "cli_path": "playbook run", + "method": "POST", + "endpoint": "/api/agent/v1/playbooks/run/", + "permission": "business_writer", + "capabilities": ["playbook.run"], + "aliases": ["execute_playbook"], + "examples": ["asp playbook run collect_case_context case_000001 --user-input-file prompt.md --output json"] + }, + { + "id": "siem.schema", + "cli_path": "siem schema list|show", + "method": "GET", + "endpoint": "/api/agent/v1/siem/schema/", + "permission": "authenticated", + "capabilities": ["siem.schema"], + "aliases": ["siem_explore_schema"], + "examples": ["asp siem schema list --output json", "asp siem schema show logs-security --output json"] + }, + { + "id": "siem.search.keyword", + "cli_path": "siem search keyword", + "method": "POST", + "endpoint": "/api/agent/v1/siem/search/keyword/", + "permission": "authenticated", + "capabilities": ["siem.search.keyword"], + "aliases": ["siem_keyword_search"], + "examples": ["asp siem search keyword 1.2.3.4 --from 2026-07-02T00:00:00Z --to 2026-07-02T01:00:00Z --output json"] + }, + { + "id": "siem.query.adaptive", + "cli_path": "siem query adaptive", + "method": "POST", + "endpoint": "/api/agent/v1/siem/query/adaptive/", + "permission": "authenticated", + "capabilities": ["siem.query.adaptive"], + "aliases": ["siem_adaptive_query"], + "examples": ["asp siem query adaptive logs-security --from 2026-07-02T00:00:00Z --to 2026-07-02T01:00:00Z --filters-file filters.json --output json"] + }, + { + "id": "siem.fields.discover", + "cli_path": "siem fields discover", + "method": "POST", + "endpoint": "/api/agent/v1/siem/fields/discover/", + "permission": "authenticated", + "capabilities": ["siem.fields.discover"], + "aliases": ["siem_discover_index_fields"], + "examples": ["asp siem fields discover logs-security ELK --from 2026-07-02T00:00:00Z --to 2026-07-02T01:00:00Z --output json"] + }, + { + "id": "siem.query.spl", + "cli_path": "siem query spl", + "method": "POST", + "endpoint": "/api/agent/v1/siem/query/spl/", + "permission": "authenticated", + "capabilities": ["siem.query.spl"], + "aliases": ["siem_execute_spl"], + "examples": ["asp siem query spl \"index=main error\" --from 2026-07-02T00:00:00Z --to 2026-07-02T01:00:00Z --output json"] + }, + { + "id": "siem.query.esql", + "cli_path": "siem query esql", + "method": "POST", + "endpoint": "/api/agent/v1/siem/query/esql/", + "permission": "authenticated", + "capabilities": ["siem.query.esql"], + "aliases": ["siem_execute_esql"], + "examples": ["asp siem query esql \"FROM logs-* | LIMIT 10\" --from 2026-07-02T00:00:00Z --to 2026-07-02T01:00:00Z --output json"] + }, + { + "id": "ti.query", + "cli_path": "ti query", + "method": "POST", + "endpoint": "/api/agent/v1/threat-intel/query/", + "permission": "authenticated", + "capabilities": ["ti.query"], + "aliases": ["ti_query", "threat-intel query"], + "examples": ["asp ti query 1.2.3.4 --artifact-type \"IP Address\" --output json"] + }, + { + "id": "cmdb.lookup", + "cli_path": "cmdb lookup", + "method": "POST", + "endpoint": "/api/agent/v1/cmdb/lookup/", + "permission": "authenticated", + "capabilities": ["cmdb.lookup"], + "aliases": ["cmdb_lookup"], + "examples": ["asp cmdb lookup \"IP Address\" 1.2.3.4 --output json"] + }, + { + "id": "dev.stream.head", + "cli_path": "dev stream head", + "method": "GET", + "endpoint": "/api/agent/v1/dev/streams/head/", + "permission": "authenticated", + "capabilities": ["dev.stream.head"], + "aliases": ["read_stream_head"], + "examples": ["asp dev stream head custom-module-events -n 3 --output json"] + }, + { + "id": "dev.stream.read", + "cli_path": "dev stream read", + "method": "GET", + "endpoint": "/api/agent/v1/dev/streams/message/", + "permission": "authenticated", + "capabilities": ["dev.stream.read"], + "aliases": ["read_stream_message_by_id"], + "examples": ["asp dev stream read custom-module-events 0-1 --output json"] + } + ] +} diff --git a/cli/uv.lock b/cli/uv.lock new file mode 100644 index 0000000..474ba38 --- /dev/null +++ b/cli/uv.lock @@ -0,0 +1,333 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53" }, +] + +[[package]] +name = "anyio" +version = "4.14.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72" }, +] + +[[package]] +name = "asp-cli" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "jmespath" }, + { name = "pydantic" }, + { name = "rich" }, + { name = "typer" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "jmespath", specifier = ">=1.0.1" }, + { name = "pydantic", specifier = ">=2.13.4" }, + { name = "rich", specifier = ">=14.2.0" }, + { name = "typer", specifier = ">=0.20.0" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398" }, + { url = "https://mirrors.aliyun.com/pypi/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712" }, + { url = "https://mirrors.aliyun.com/pypi/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894" }, + { url = "https://mirrors.aliyun.com/pypi/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76" }, + { url = "https://mirrors.aliyun.com/pypi/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462" }, + { url = "https://mirrors.aliyun.com/pypi/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914" }, + { url = "https://mirrors.aliyun.com/pypi/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28" }, + { url = "https://mirrors.aliyun.com/pypi/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565" }, + { url = "https://mirrors.aliyun.com/pypi/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02" }, + { url = "https://mirrors.aliyun.com/pypi/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686" }, +] + +[[package]] +name = "typer" +version = "0.26.8" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7" }, +] diff --git a/docs/superpowers/specs/2026-07-02-cli-agent-integration-design.md b/docs/superpowers/specs/2026-07-02-cli-agent-integration-design.md new file mode 100644 index 0000000..a6a101b --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-cli-agent-integration-design.md @@ -0,0 +1,496 @@ +# ASP CLI Agent 集成架构设计 + +## 状态 + +已确认。 + +## 背景 + +ASP 当前通过后端 MCP endpoint 向 Claude Code plugin 的 agents 和 skills 暴露能力。MCP 的优点是工具签名、参数和调用协议由框架处理,接入成本低;主要问题是工具列表和描述会固定进入上下文,缺少命令行天然具备的渐进式 help。ASP 的目标用户是安全工程师和 SOC 分析师,对 CLI 接受度较高,因此新架构将 CLI 作为 Agent 和人类共同使用的主集成面。 + +现有 `/api/mcp` 先保留兼容窗口。新能力以 CLI 和 Agent Operations API 为主路径,等 CLI 能力、文档和 marketplace skills 稳定后,再将 MCP 标记为 deprecated。 + +## 目标 + +- 提供符合主流 Agent/平台 CLI 习惯的 `asp` 命令。 +- 通过分层命令和 help 支持渐进式能力发现。 +- 首发使用 Python 实现,支持 `pipx install` 和一行 bootstrap 安装。 +- 覆盖当前 MCP 暴露的能力,并为后续新增 operation 留出发版期扩展机制。 +- 所有命令支持人类可读输出和稳定 JSON 输出。 +- 复用成熟 CLI/HTTP/渲染库,避免手写底层框架。 +- 保持 CLI 和后端运行时解耦,CLI 可独立安装。 + +## 非目标 + +- 不做运行时动态命令发现。服务端新增业务命令后,通过 CLI 发版暴露。 +- 不把 CLI 做成后端 Django 管理命令,也不要求 CLI 在后端源码环境运行。 +- 不把所有能力压到单一 `operation run` 或 `/run` RPC。 +- 不按 MCP 函数名设计主命令;MCP 名只作为迁移映射和 alias 记录。 +- 不让文件内容默认进入 CLI 输出或 Agent 上下文。 + +## 选定方案 + +采用“静态主流 CLI + build-time operation spec + Agent Operations API”。 + +- CLI 使用 Python Typer + Rich + httpx + Pydantic。 +- CLI 业务命令是静态分层命令,随 CLI 发版。 +- 后端新增版本化 Agent Operations API,提供适合 CLI/Agent 的稳定 schema。 +- 后端维护发版期 operation spec,CLI 包内携带对应 spec snapshot。 +- marketplace 新增 CLI 版 skills,与 MCP 版并行,稳定后切默认入口。 + +这个方案接近 `gh`、`docker`、`terraform` 等主流 CLI 的静态命令树模式,同时保留类似 AWS CLI 的 model/spec-driven 契约管理思想。相比服务端动态命令发现,它牺牲“服务端新增命令无需 CLI 发版”的便利,换取更稳定的 help、completion、测试和安装体验。 + +## 架构组件 + +### ASP CLI package + +CLI 作为当前 monorepo 中的独立 package 维护,发布为 PyPI 包。 + +首发安装方式: + +```bash +pipx install asp-cli +``` + +同时提供 PowerShell 和 bash bootstrap 一行命令,用于检测 Python/pipx 并安装 CLI。CLI 包不 import Django app、models 或 serializers;后端依赖不会进入 CLI 安装环境。 + +CLI 静态内置: + +- `auth` +- `config` +- `doctor` +- `completion` +- 各业务命令组 + +CLI 使用包内 operation spec snapshot 提供命令说明、examples、兼容测试和文档生成输入。 + +### Backend Agent Operations API + +后端新增 `/api/agent/v1/...` API 层,定位是给 Agent/CLI 使用的稳定接口。 + +API 原则: + +- 版本化。 +- 领域化。 +- 可写 OpenAPI / operation spec。 +- 复用现有 service、ORM、permission 和 audit 机制。 +- 使用 Agent 专用 serializer/schema,不直接暴露 UI REST 字段。 +- 不提供单一 `/run` RPC。 + +示例 endpoint 形态: + +```text +GET /api/agent/v1/version +GET /api/agent/v1/cases/ +GET /api/agent/v1/cases/{case_id}/ +PATCH /api/agent/v1/cases/{case_id}/ai-analysis/ +POST /api/agent/v1/comments/ +GET /api/agent/v1/files/{file_key}/ +POST /api/agent/v1/files/ +POST /api/agent/v1/siem/search/keyword/ +POST /api/agent/v1/threat-intel/query/ +POST /api/agent/v1/cmdb/lookup/ +``` + +具体 URL 可在实现时细化,但不得退化为 UI REST 的不稳定透传。 + +### Operation spec + +operation spec 是 CLI、Agent API、文档和 skills 的发版期契约源。 + +每个 operation 至少包含: + +- operation id,例如 `case.list`。 +- CLI path,例如 `case list`。 +- HTTP method 和 endpoint。 +- 参数 schema。 +- 输出 schema。 +- 权限要求。 +- capability 要求。 +- examples。 +- deprecated aliases,例如旧 MCP 工具名 `list_cases`。 +- 最低 CLI/API 版本要求。 + +CLI 不运行时动态拉取业务命令。服务端通过 `/api/agent/v1/version` 返回 `api_version`、`min_cli_version` 和 capabilities,CLI 用于兼容检查。CLI 新、服务端旧时,对不支持的 operation 明确报错;服务端要求更高 CLI 时,CLI 直接提示升级。 + +## 命令树 + +主命令树: + +```text +asp auth login|status|logout +asp config get|set|list +asp doctor +asp completion powershell|bash|zsh + +asp case list|show|update-ai +asp alert list|show +asp artifact list|show +asp enrichment create +asp knowledge search|show|update +asp playbook template list +asp playbook list|show|run + +asp comment list|add +asp file upload|info|download|read-text + +asp siem schema list|show +asp siem search keyword +asp siem query adaptive|spl|esql +asp siem fields discover + +asp ti query +asp cmdb lookup + +asp dev stream head|read +``` + +命名规则: + +- 安全行业常用短名作为主命令,例如 `siem`、`ti`、`cmdb`。 +- 长名通过 alias 或 help 提供,例如 `threat-intel` alias 到 `ti`。 +- MCP 函数名不作为主 CLI UX。 + +### 当前 MCP 能力映射 + +| MCP 工具 | CLI 命令 | +| --- | --- | +| `list_cases` | `asp case list`, `asp case show` | +| `update_case` | `asp case update-ai` | +| `get_file` | `asp file info`, `asp file download`, `asp file read-text` | +| `add_comment` | `asp comment add` | +| `list_alerts` | `asp alert list`, `asp alert show` | +| `list_artifacts` | `asp artifact list`, `asp artifact show` | +| `create_enrichment` | `asp enrichment create` | +| `list_playbook_templates` | `asp playbook template list` | +| `execute_playbook` | `asp playbook run` | +| `list_playbooks` | `asp playbook list`, `asp playbook show` | +| `update_knowledge` | `asp knowledge update` | +| `search_knowledge` | `asp knowledge search` | +| `read_stream_message_by_id` | `asp dev stream read` | +| `read_stream_head` | `asp dev stream head` | +| `ti_query` | `asp ti query` | +| `cmdb_lookup` | `asp cmdb lookup` | +| `siem_explore_schema` | `asp siem schema list`, `asp siem schema show` | +| `siem_keyword_search` | `asp siem search keyword` | +| `siem_adaptive_query` | `asp siem query adaptive` | +| `siem_discover_index_fields` | `asp siem fields discover` | +| `siem_execute_spl` | `asp siem query spl` | +| `siem_execute_esql` | `asp siem query esql` | + +CLI 可以比 MCP 更完整。首版设计包含 `comment list`、`file upload`、`file download`、`file read-text`,因为这些能力适合 CLI,但不适合 MCP tool 参数直接传输文件内容。 + +## Help 和命令发现 + +采用分层渐进式 help: + +- `asp --help`:只显示全局选项和命令组。 +- `asp case --help`:显示 case 子命令和常见流程。 +- `asp case list --help`:显示完整参数、枚举、输出说明和 examples。 + +所有业务命令支持 `--output human|json`。Agent/skill 文档必须使用 `--output json`,避免解析 human 表格。 + +CLI 提供 shell completion: + +```bash +asp completion powershell +asp completion bash +asp completion zsh +``` + +`asp auth login` 成功后给出下一步建议,例如运行 `asp doctor` 和一个只读 list 命令。 + +## 配置和认证 + +`asp auth login` 是主认证入口: + +```bash +asp auth login --api-url https://asp.example.com --api-key asp_xxx +``` + +该命令默认将 base URL 和 API key 写入 settings。后续命令自动使用该配置,不需要再次认证。 + +配置范围: + +- 全局个人配置。 +- 当前仓库 local `.asp/settings.json`。 + +local 配置查找规则: + +- 从当前目录向上查找最近的 `.asp/settings.json`。 +- 不越过 git repository root。 + +配置优先级: + +```text +explicit CLI flags > environment variables > local .asp/settings.json > global settings +``` + +环境变量仅作为 CI、容器或高级临时覆盖通道,日常文档主推 settings。 + +API key 明文保存在 settings 中。实现写入配置时尽量收紧文件权限;文档说明明文配置的行为和适用场景,`auth login` 成功路径不输出风险警告。 + +`asp auth status` 输出当前配置来源、base URL、认证用户和 key 状态,不显示完整 API key。`asp auth logout` 删除当前 scope 的认证配置。 + +## 输入契约 + +输入规则: + +- 简单参数用 flags。 +- 列表用重复 flag,兼容逗号分隔。 +- 复杂对象支持 `--data-json`、`--data-file`、`--stdin`。 +- 长文本支持 `--body`、`--body-file`,后续可支持 `--editor`。 + +示例: + +```bash +asp case list --status New --severity High --limit 20 +asp enrichment create case_000001 --name ti --data-file enrichment.json +asp comment add case_000001 --body-file note.md --file-key 6f2c... +``` + +## 输出契约 + +默认输出为 human,面向人类阅读: + +- list/search 使用紧凑表格。 +- show/detail 使用分区详情。 +- 写操作输出变更摘要。 +- SIEM/TI/CMDB 输出关键命中和分析摘要。 + +完整数据通过 JSON 输出: + +```bash +asp case list --output json +``` + +成功 JSON 统一 envelope: + +```json +{ + "data": {}, + "meta": { + "operation": "case.list", + "request_id": "req_...", + "pagination": null + } +} +``` + +失败 JSON 统一 envelope: + +```json +{ + "error": { + "code": "not_found", + "message": "Case not found: case_000001", + "details": {} + }, + "meta": { + "operation": "case.show", + "request_id": "req_..." + } +} +``` + +CLI 支持可选 `--query`,使用 JMESPath 对 JSON `data` 做客户端筛选: + +```bash +asp case list --output json --query "data[].case_id" +``` + +## 分页和大结果 + +list/search 默认有界,避免一次拉取过多数据。 + +分页采用无状态 cursor。服务端不保存客户端翻页 session,cursor 是客户端携带的不透明 token。 + +JSON `meta.pagination` 示例: + +```json +{ + "pagination": { + "next_cursor": "opaque-token", + "has_more": true + } +} +``` + +CLI 支持: + +- `--cursor`:继续下一页。 +- `--limit`:控制返回数量。 +- `--page-size`:控制单次请求大小。 +- `--all`:显式自动翻页。 +- `--max-items`:限制自动翻页最大数量。 + +SIEM 查询必须要求时间范围和 limit。若底层 SIEM 后端支持稳定 cursor/search_after,再提供 cursor;否则返回有界结果并在 meta 中说明限制。 + +## 错误、exit code 和日志 + +错误类型使用稳定 error code 和 exit code,至少区分: + +- 参数错误。 +- 认证失败。 +- 权限不足。 +- 资源不存在。 +- 冲突。 +- 版本不兼容。 +- 网络错误。 +- 服务端错误。 + +human 模式输出简短可行动错误。`--verbose` 才显示请求方法、URL path、HTTP status、request id 和耗时。 + +日志规则: + +- 默认不写详细日志。 +- `--verbose` 输出脱敏诊断信息。 +- `--log-file` 显式写本地日志。 +- `--debug-http` 仍强制脱敏。 +- Authorization、API key 和敏感参数不得出现在日志中。 + +## 权限和写操作安全 + +认证继续使用 ASP User API Key。 + +权限规则: + +- 读操作要求 authenticated。 +- 写操作复用现有 business writer 规则。 +- operation spec 标注 required permission 和 required capability。 +- 后端写操作继续使用现有 audit 机制。 + +明确写命令不做二次确认,保证 Agent/skill 可无交互执行: + +- `asp comment add` +- `asp case update-ai` +- `asp enrichment create` +- `asp playbook run` + +未来 destructive 或 bulk 命令必须要求 `--yes`,并优先支持 `--dry-run`。JSON/CI 模式下不弹交互 prompt;缺少 `--yes` 时返回标准错误。 + +## 文件能力 + +CLI 文件命令: + +- `asp file upload ` +- `asp file info ` +- `asp file download --output-path ` +- `asp file read-text --max-bytes ` + +默认不输出文件 bytes、base64 或大文本。`read-text` 必须显式调用,并受大小和内容类型限制。 + +comment 附件继续使用 `file_key` 引用。CLI 上传本地文件后返回 `file_key`,可直接传给 `asp comment add --file-key ...`。 + +## `asp doctor` + +`asp doctor` 是只读诊断命令,支持 human 和 JSON 输出。 + +检查内容: + +- 当前配置来源。 +- base URL 连通性。 +- TLS/代理基础错误。 +- API key 是否有效。 +- 当前用户和角色。 +- 服务端 API version。 +- CLI version。 +- 版本兼容。 +- 服务端 capabilities,例如 SIEM、TI、CMDB。 + +`doctor` 不修改配置,不执行写操作。 + +## Marketplace skills 迁移 + +迁移策略: + +1. 新增 CLI 版 skills,metadata 标记依赖 ASP CLI。 +2. MCP 版 skills 保留兼容窗口。 +3. CLI 版 skills 一律使用 `--output json`。 +4. CLI 版稳定后,marketplace 默认入口切到 CLI。 +5. MCP 版标记 deprecated,后续再移除。 + +同一个 skill 不同时兼容 MCP 和 CLI,避免分支逻辑复杂化。CLI 版 skill 应直接写最优 CLI 命令,不围绕 MCP 历史函数名设计。 + +## 文档 + +文档由两部分组成: + +- 从 operation spec 生成命令/API 参考,包括参数、schema、examples 和输出结构。 +- 手写指南和 SOP,包括安装、认证、配置、SOC 调查流程、Claude Code skills 使用。 + +asf-doc 修改遵循项目规则:先更新 zh 文档,zh 定稿后再同步 en 文档。 + +## 测试策略 + +后端测试: + +- Agent API endpoint tests。 +- 权限 tests。 +- schema/envelope tests。 +- cursor 分页 tests。 +- 错误码 tests。 + +spec 测试: + +- operation spec 结构校验。 +- CLI command 覆盖检查。 +- deprecated alias 映射检查。 +- server `min_cli_version` 兼容检查。 + +CLI 测试: + +- Typer 命令解析测试。 +- httpx mock 集成测试。 +- `--output json` contract tests。 +- 关键 human 输出 snapshot tests。 +- 配置优先级 tests。 +- 脱敏日志 tests。 + +Marketplace skill 检查: + +- 命令示例静态检查。 +- JSON 输出契约引用检查。 + +## 实施阶段 + +### Phase 1: Foundation + +- CLI package skeleton。 +- `auth login/status/logout`。 +- global/local settings。 +- `doctor`。 +- `--output human|json`。 +- JSON envelope。 +- 标准错误和 exit code。 +- operation spec 基础结构。 +- Agent API `/version` 和基础认证。 + +### Phase 2: Core SOC + +- `case`。 +- `comment`。 +- `file`。 +- `enrichment`。 +- `knowledge`。 +- `playbook`。 +- 对应 Agent API 和 serializers。 + +### Phase 3: Investigation integrations + +- `siem`。 +- `ti`。 +- `cmdb`。 +- 对应 Agent API,覆盖当前 MCP 的 SIEM/TI/CMDB 能力。 + +### Phase 4: Advanced and migration + +- `dev stream`。 +- shell completion polish。 +- generated command reference。 +- CLI 版 marketplace skills。 +- MCP deprecation 文档。 + +## 设计结论 + +ASP CLI 将成为新的 Agent 主集成面。后端提供稳定的 Agent Operations API,CLI 提供主流静态命令树和渐进式 help,operation spec 负责发版期契约同步。该方案优先保证主流 CLI 体验、低运行时复杂度、可测试性和长期可维护性,同时保留 MCP 兼容窗口降低迁移风险。