Merge branch 'dev'

This commit is contained in:
funnywolf
2026-07-03 23:06:32 +08:00
26 changed files with 4230 additions and 3 deletions
+4 -1
View File
@@ -29,9 +29,12 @@ node_modules/
# Local git worktrees
.worktrees/
/asf-doc/
/asp-marketplace/
# OS
.DS_Store
Thumbs.db
.claude/*
.claude/*
.asp/
Submodule asf-doc deleted from 03df728601
Submodule asp-marketplace deleted from b6f9aaff82
View File
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AgentApiConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.agent_api"
+24
View File
@@ -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,
}
+162
View File
@@ -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
+65
View File
@@ -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/<str:case_id>/", CaseDetailView.as_view(), name="agent-api-case-detail"),
path("cases/<str:case_id>/ai-analysis/", CaseAIAnalysisView.as_view(), name="agent-api-case-ai-analysis"),
path("alerts/", AlertListView.as_view(), name="agent-api-alert-list"),
path("alerts/<str:alert_id>/", AlertDetailView.as_view(), name="agent-api-alert-detail"),
path("artifacts/", ArtifactListView.as_view(), name="agent-api-artifact-list"),
path("artifacts/<str:artifact_id>/", ArtifactDetailView.as_view(), name="agent-api-artifact-detail"),
path("knowledge/", KnowledgeListView.as_view(), name="agent-api-knowledge-list"),
path("knowledge/<str:knowledge_id>/", 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/<uuid:file_key>/", FileDetailView.as_view(), name="agent-api-file-detail"),
path("files/<uuid:file_key>/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/<str:playbook_id>/", 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"),
]
+42
View File
@@ -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)
+750
View File
@@ -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
+1
View File
@@ -51,6 +51,7 @@ INSTALLED_APPS = [
"apps.webhook",
"apps.mcp",
"apps.agentic",
"apps.agent_api",
]
MIDDLEWARE = [
+1
View File
@@ -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")),
]
+21
View File
@@ -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.
+25
View File
@@ -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
```
+48
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
__version__ = "0.1.0"
+118
View File
@@ -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
+176
View File
@@ -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
+26
View File
@@ -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
File diff suppressed because it is too large Load Diff
+66
View File
@@ -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
View File
View File
+404
View File
@@ -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"]
}
]
}
Generated
+333
View File
@@ -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" },
]
@@ -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 specCLI 包内携带对应 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` 和 capabilitiesCLI 用于兼容检查。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 <path>`
- `asp file info <file_key>`
- `asp file download <file_key> --output-path <path>`
- `asp file read-text <file_key> --max-bytes <n>`
默认不输出文件 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 版 skillsmetadata 标记依赖 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 APICLI 提供主流静态命令树和渐进式 help,operation spec 负责发版期契约同步。该方案优先保证主流 CLI 体验、低运行时复杂度、可测试性和长期可维护性,同时保留 MCP 兼容窗口降低迁移风险。