Files
agent-compliance-auditor/agent.py

336 lines
33 KiB
Python

from __future__ import annotations
import hashlib
import json
import re
import time
from datetime import UTC, datetime
from enum import Enum
from fnmatch import fnmatch
from typing import Any, Literal
from urllib.parse import urlparse
import httpx
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, field_validator
import a2a_pack as a2a
from a2a_pack import A2AAgent, ConsumerSetup, ConsumerSetupField, EgressPolicy, NoAuth, Pricing, Resources, RunContext, WorkspaceAccess, WorkspaceMode
from a2a_pack.context import AgentEvent
from a2a_pack.workspace import FileType
MAX_TEXT_BYTES = 256_000
MAX_EVIDENCE_BYTES = 8_000
MAX_FILES_PER_AUDIT = 80
MAX_FINDINGS = 200
HTTP_TIMEOUT_SECONDS = 8.0
DENIED_REPO_GLOBS = (".git/**", ".env", ".env.*", "*.pem", "*.key", "*secret*", "*credential*", "**/.env", "**/.env.*", "**/*secret*", "**/*credential*", "**/*.pem", "**/*.key")
ALLOWED_REPO_GLOBS = ("agent.py", "a2a.yaml", "requirements.txt", "README.md", "*.py", "*.yaml", "*.yml", "*.json", "*.md", "tests/*.py", "tests/**/*.py", "skills/**/SKILL.md")
SECRET_PATTERNS = (re.compile(r"(?i)(api[_-]?key|token|secret|password)\s*[:=]\s*['\"]?([A-Za-z0-9_./+=-]{12,})"), re.compile(r"sk-[A-Za-z0-9]{20,}"), re.compile(r"(?i)bearer\s+[A-Za-z0-9_./+=-]{16,}"))
WRITE_INDICATORS = ("kubectl apply", "kubectl delete", "kubectl patch", "kubectl scale", "helm upgrade", "git push", "ctx.secret(", "OPENAI_API_KEY", "A2A_LITELLM_KEY")
class StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid")
def __getitem__(self, key: str) -> Any:
return getattr(self, key)
class Severity(str, Enum):
CRITICAL="critical"; HIGH="high"; MEDIUM="medium"; LOW="low"; INFO="info"
class Confidence(str, Enum):
HIGH="high"; MEDIUM="medium"; LOW="low"
class ProbeMode(str, Enum):
NONE="none"; HEALTH_ONLY="health_only"; SYNTHETIC="synthetic"
class AuditTarget(StrictModel):
agent_name: str = Field(min_length=1, max_length=128, pattern=r"^[a-z0-9][a-z0-9-]{0,127}$")
agent_url: AnyHttpUrl | None = None
repository: str | None = Field(default=None, max_length=256)
namespace: str | None = Field(default=None, max_length=128)
deployment: str | None = Field(default=None, max_length=128)
@field_validator("repository", "namespace", "deployment")
@classmethod
def clean_optional(cls, value: str | None) -> str | None:
return value.strip() or None if value is not None else None
class AuditOptions(StrictModel):
audit_id: str | None = Field(default=None, max_length=80, pattern=r"^[A-Za-z0-9_.-]+$")
max_files: int = Field(default=40, ge=0, le=MAX_FILES_PER_AUDIT)
probe_mode: ProbeMode = ProbeMode.HEALTH_ONLY
include_repository: bool = True
include_runtime: bool = True
include_remediation: bool = True
allowed_hosts: list[str] = Field(default_factory=list, max_length=20)
allowed_namespaces: list[str] = Field(default_factory=list, max_length=20)
class Finding(StrictModel):
id: str
control_id: str
title: str
severity: Severity
confidence: Confidence
status: Literal["open", "passed", "unknown", "not_tested"] = "open"
evidence_refs: list[str] = Field(default_factory=list, max_length=20)
impact: str
remediation: str
verification_procedure: str
class Evidence(StrictModel):
id: str
source: str
path: str | None = None
status: Literal["observed", "missing", "unknown", "not_tested", "redacted"]
excerpt: str = Field(default="", max_length=MAX_EVIDENCE_BYTES)
sha256: str | None = None
class OutputFile(StrictModel):
path: str
artifact_uri: str | None = None
mime_type: str
size_bytes: int = Field(ge=0)
def _default_target() -> AuditTarget:
return AuditTarget(agent_name="agent-compliance-auditor")
class InventoryRequest(StrictModel):
target: AuditTarget = Field(default_factory=_default_target)
options: AuditOptions = Field(default_factory=AuditOptions)
class AuditConfigurationRequest(StrictModel):
target: AuditTarget = Field(default_factory=_default_target)
options: AuditOptions = Field(default_factory=AuditOptions)
inventory: dict[str, Any] | None = None
class AuditRuntimeRequest(StrictModel):
target: AuditTarget = Field(default_factory=_default_target)
options: AuditOptions = Field(default_factory=AuditOptions)
inventory: dict[str, Any] | None = None
class AssessRiskRequest(StrictModel):
target: AuditTarget
options: AuditOptions = Field(default_factory=AuditOptions)
inventory: dict[str, Any] | None = None
configuration_findings: list[Finding] = Field(default_factory=list, max_length=MAX_FINDINGS)
runtime_findings: list[Finding] = Field(default_factory=list, max_length=MAX_FINDINGS)
class GenerateRemediationRequest(StrictModel):
target: AuditTarget
options: AuditOptions = Field(default_factory=AuditOptions)
findings: list[Finding] = Field(default_factory=list, max_length=MAX_FINDINGS)
risk_summary: dict[str, Any] | None = None
class InventoryResult(StrictModel):
audit_id: str; target: AuditTarget; status: Literal["ok", "partial", "unreachable", "setup_required"]; output_dir: str
generated_files: list[OutputFile] = Field(default_factory=list); inventory: dict[str, Any]; evidence: list[Evidence] = Field(default_factory=list); warnings: list[str] = Field(default_factory=list); mutations_performed: bool = False
class FindingResult(StrictModel):
audit_id: str; target: AuditTarget; status: Literal["ok", "partial", "setup_required"]; output_dir: str
generated_files: list[OutputFile] = Field(default_factory=list); findings: list[Finding]; evidence: list[Evidence] = Field(default_factory=list); warnings: list[str] = Field(default_factory=list); mutations_performed: bool = False
class RiskSummaryResult(StrictModel):
audit_id: str; target: AuditTarget; status: Literal["ok", "partial"]; output_dir: str
generated_files: list[OutputFile] = Field(default_factory=list); risk_summary: dict[str, Any]; findings: list[Finding]; warnings: list[str] = Field(default_factory=list); mutations_performed: bool = False
class RemediationResult(StrictModel):
audit_id: str; target: AuditTarget; status: Literal["ok", "partial"]; output_dir: str
generated_files: list[OutputFile] = Field(default_factory=list); remediation_markdown: str; sarif: dict[str, Any]; warnings: list[str] = Field(default_factory=list); mutations_performed: bool = False
class AgentComplianceAuditorConfig(StrictModel):
default_allowed_hosts: list[str] = Field(default_factory=list, max_length=50)
default_allowed_namespaces: list[str] = Field(default_factory=list, max_length=50)
class AgentComplianceAuditor(A2AAgent[AgentComplianceAuditorConfig, NoAuth]):
name="agent-compliance-auditor"; description="Read-only compliance auditor for deployed A2A agents and repositories."; version="0.1.4"
config_model=AgentComplianceAuditorConfig; auth_model=NoAuth
pricing=Pricing(price_per_call_usd=0.0, caller_pays_llm=False, notes="Deterministic read-only auditing; no LLM calls and no production mutations.")
resources=Resources(cpu="500m", memory="512Mi", max_runtime_seconds=900)
workspace_access=WorkspaceAccess.dynamic(max_files=MAX_FILES_PER_AUDIT+10, allowed_modes=(WorkspaceMode.READ_ONLY,), require_reason=False, deny_patterns=DENIED_REPO_GLOBS, max_total_size_bytes=12*1024*1024)
egress=EgressPolicy(allow_hosts=("gitea.a2a.svc.cluster.local",), deny_internet_by_default=True)
tools_used=("httpx","pydantic","workspace")
consumer_setup=ConsumerSetup.from_fields(
ConsumerSetupField.config("GITEA_BASE_URL", label="Gitea base URL", required=False, input_type="url"),
ConsumerSetupField.config("KUBERNETES_BASE_URL", label="Kubernetes API base URL", required=False, input_type="url"),
ConsumerSetupField.config("ARGOCD_BASE_URL", label="Argo CD base URL", required=False, input_type="url"),
ConsumerSetupField.config("REGISTRY_BASE_URL", label="Registry metadata base URL", required=False, input_type="url"))
@a2a.tool(description="Inventory a deployed A2A agent card, repository evidence, manifests, exposure, provenance, resources, and health", timeout_seconds=180, idempotent=True, cost_class="read-only")
async def inventory_agent(self, ctx: RunContext[NoAuth], request: InventoryRequest) -> InventoryResult:
audit_id=_audit_id(request.target, request.options.audit_id); await _event(ctx,"audit_started",audit_id=audit_id,skill="inventory_agent",target=request.target.agent_name)
inventory,evidence,warnings,status=await _collect_inventory(self,ctx,request.target,request.options,audit_id); output_dir=_output_dir(audit_id)
files=await _write_outputs(ctx,output_dir,{"inventory.json":inventory},warnings); await _event(ctx,"audit_completed",audit_id=audit_id,skill="inventory_agent",findings=0)
return InventoryResult(audit_id=audit_id,target=request.target,status=status,output_dir=output_dir,generated_files=files,inventory=inventory,evidence=evidence,warnings=warnings)
@a2a.tool(description="Audit static configuration and repository files against A2A security, privacy, and platform controls", timeout_seconds=180, idempotent=True, cost_class="read-only")
async def audit_configuration(self, ctx: RunContext[NoAuth], request: AuditConfigurationRequest) -> FindingResult:
audit_id=_audit_id(request.target,request.options.audit_id); await _event(ctx,"audit_started",audit_id=audit_id,skill="audit_configuration",target=request.target.agent_name)
inventory=request.inventory or (await _collect_inventory(self,ctx,request.target,request.options,audit_id))[0]; findings,evidence,warnings=_configuration_findings(request.target,inventory); findings=_dedupe(findings); output_dir=_output_dir(audit_id)
files=await _write_outputs(ctx,output_dir,{"findings.json":{"findings":[_dump(f) for f in findings]}},warnings); await _event(ctx,"audit_completed",audit_id=audit_id,skill="audit_configuration",findings=len(findings))
return FindingResult(audit_id=audit_id,target=request.target,status="ok",output_dir=output_dir,generated_files=files,findings=findings,evidence=evidence,warnings=warnings)
@a2a.tool(description="Run bounded non-destructive runtime probes and audit deployment health without mutations", timeout_seconds=180, idempotent=True, cost_class="read-only")
async def audit_runtime(self, ctx: RunContext[NoAuth], request: AuditRuntimeRequest) -> FindingResult:
audit_id=_audit_id(request.target,request.options.audit_id); await _event(ctx,"audit_started",audit_id=audit_id,skill="audit_runtime",target=request.target.agent_name)
inventory=request.inventory or (await _collect_inventory(self,ctx,request.target,request.options,audit_id))[0]; findings,evidence,warnings=_runtime_findings(request.target,inventory); findings=_dedupe(findings); output_dir=_output_dir(audit_id)
files=await _write_outputs(ctx,output_dir,{"findings.json":{"findings":[_dump(f) for f in findings]}},warnings); await _event(ctx,"audit_completed",audit_id=audit_id,skill="audit_runtime",findings=len(findings))
return FindingResult(audit_id=audit_id,target=request.target,status="ok",output_dir=output_dir,generated_files=files,findings=findings,evidence=evidence,warnings=warnings)
@a2a.tool(description="Deduplicate findings, map evidence to controls, and score severity, confidence, and aggregate risk", timeout_seconds=120, idempotent=True, cost_class="read-only")
async def assess_risk(self, ctx: RunContext[NoAuth], request: AssessRiskRequest) -> RiskSummaryResult:
audit_id=_audit_id(request.target,request.options.audit_id); await _event(ctx,"audit_started",audit_id=audit_id,skill="assess_risk",target=request.target.agent_name)
findings=_dedupe([*request.configuration_findings,*request.runtime_findings]); summary=_risk_summary(findings); output_dir=_output_dir(audit_id)
files=await _write_outputs(ctx,output_dir,{"risk-summary.json":summary,"findings.json":{"findings":[_dump(f) for f in findings]}},[]); await _event(ctx,"audit_completed",audit_id=audit_id,skill="assess_risk",findings=len(findings),score=summary["risk_score"])
return RiskSummaryResult(audit_id=audit_id,target=request.target,status="ok",output_dir=output_dir,generated_files=files,risk_summary=summary,findings=findings)
@a2a.tool(description="Generate actionable remediation and a machine-readable SARIF report for compliance findings", timeout_seconds=120, idempotent=True, cost_class="read-only")
async def generate_remediation(self, ctx: RunContext[NoAuth], request: GenerateRemediationRequest) -> RemediationResult:
audit_id=_audit_id(request.target,request.options.audit_id); await _event(ctx,"audit_started",audit_id=audit_id,skill="generate_remediation",target=request.target.agent_name)
findings=_dedupe(request.findings); summary=request.risk_summary or _risk_summary(findings); md=_remediation_md(request.target,audit_id,findings,summary); sarif=_sarif(request.target,findings); output_dir=_output_dir(audit_id)
files=await _write_outputs(ctx,output_dir,{"remediation.md":md,"sarif.json":sarif,"risk-summary.json":summary},[]); await _event(ctx,"audit_completed",audit_id=audit_id,skill="generate_remediation",findings=len(findings))
return RemediationResult(audit_id=audit_id,target=request.target,status="ok",output_dir=output_dir,generated_files=files,remediation_markdown=md,sarif=sarif)
async def _collect_inventory(agent:AgentComplianceAuditor,ctx:RunContext[NoAuth],target:AuditTarget,options:AuditOptions,audit_id:str)->tuple[dict[str,Any],list[Evidence],list[str],Literal["ok","partial","unreachable","setup_required"]]:
warnings=[]; evidence=[]; allowed_hosts={h.lower().strip() for h in [*agent.config.default_allowed_hosts,*options.allowed_hosts] if h.strip()}; allowed_ns={n.strip() for n in [*agent.config.default_allowed_namespaces,*options.allowed_namespaces] if n.strip()}
authorized=not allowed_ns or bool(target.namespace and target.namespace in allowed_ns); status="ok" if authorized else "partial"
if not authorized: warnings.append("Target namespace is outside the configured allowlist; runtime probes skipped.")
card=await _fetch_card(str(target.agent_url) if target.agent_url else None,allowed_hosts,warnings); status="unreachable" if status=="ok" and card.get("status")=="unreachable" else status; evidence.append(_evidence("card","agent_card","observed" if card.get("card") else "unknown",card))
repo_files=await _read_repo(ctx,options.max_files if options.include_repository else 0,warnings); evidence.extend(_repo_evidence(repo_files)); manifests=_manifests(repo_files)
health={"status":"not_tested","reason":"probe_mode is none"}
if options.probe_mode is not ProbeMode.NONE and authorized: health=await _health(str(target.agent_url) if target.agent_url else None,allowed_hosts,warnings)
inv={"schema_version":"2026-07-13","audit_id":audit_id,"generated_at":datetime.now(UTC).isoformat(),"identity":{"agent_name":target.agent_name,"agent_url":str(target.agent_url) if target.agent_url else None,"repository":target.repository,"namespace":target.namespace,"deployment":target.deployment,"authorized":authorized},"agent_card":card,"skills":_skills(card.get("card"),repo_files),"schemas":_schemas(card.get("card")),"manifests":manifests,"rbac":_rbac(manifests),"secrets_references":_secret_refs(repo_files),"network_exposure":_exposure(manifests),"image_provenance":_provenance(manifests,repo_files),"resource_limits":_resources(card.get("card"),manifests),"deployment_health":health,"repository":_repo_summary(repo_files),"controls":_controls(),"unknowns":_unknowns(card,manifests,health,repo_files),"mutations_performed":False}
return inv,evidence,warnings,status # type: ignore[return-value]
async def _fetch_card(agent_url:str|None,allowed_hosts:set[str],warnings:list[str])->dict[str,Any]:
if not agent_url: return {"status":"unknown","reason":"agent_url not provided"}
if not _safe_url(urlparse(agent_url),allowed_hosts): warnings.append("Agent URL host is not allowlisted or is unsafe; card fetch skipped."); return {"status":"not_tested","reason":"host_not_allowlisted"}
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(HTTP_TIMEOUT_SECONDS),follow_redirects=False) as client: resp=await client.get(agent_url.rstrip("/")+"/.well-known/agent-card",headers={"accept":"application/json"})
return {"status":"unreachable","http_status":resp.status_code,"body_excerpt":_redact(resp.text[:500])} if resp.status_code>=400 else {"status":"ok","card":_bounded_json(resp.text,warnings)}
except Exception as exc: warnings.append(f"Agent card fetch failed: {type(exc).__name__}"); return {"status":"unreachable","error_type":type(exc).__name__}
async def _health(agent_url:str|None,allowed_hosts:set[str],warnings:list[str])->dict[str,Any]:
if not agent_url: return {"status":"unknown","reason":"agent_url not provided"}
if not _safe_url(urlparse(agent_url),allowed_hosts): return {"status":"not_tested","reason":"host_not_allowlisted"}
started=time.perf_counter()
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(HTTP_TIMEOUT_SECONDS),follow_redirects=False) as client: resp=await client.get(agent_url.rstrip("/")+"/healthz")
return {"status":"ok" if resp.status_code<500 else "degraded","http_status":resp.status_code,"latency_ms":round((time.perf_counter()-started)*1000,2)}
except Exception as exc: warnings.append(f"Health probe failed: {type(exc).__name__}"); return {"status":"unreachable","error_type":type(exc).__name__}
async def _read_repo(ctx:RunContext[NoAuth],max_files:int,warnings:list[str])->dict[str,str]:
if max_files<=0: return {}
try: view=await ctx.workspace.open_view(purpose="Read-only static inspection of A2A agent source files",hints=("agent.py a2a.yaml requirements README tests skills",),file_types=(FileType.PYTHON,FileType.YAML,FileType.JSON,FileType.MARKDOWN,FileType.TOML),max_files=max_files,mode=WorkspaceMode.READ_ONLY,reason="Compliance audit reads bounded source files and never writes repository paths.")
except Exception as exc: warnings.append(f"Repository workspace unavailable: {type(exc).__name__}"); return {}
files={}
for m in view.files[:max_files]:
path=m.path.replace("\\","/").lstrip("/")
if _path_allowed(path):
try: files[path]=(await view.read(path))[:MAX_TEXT_BYTES].decode("utf-8",errors="replace")
except Exception as exc: warnings.append(f"Could not read {path}: {type(exc).__name__}")
return files
async def _write_outputs(ctx:RunContext[NoAuth],output_dir:str,files:dict[str,Any],warnings:list[str])->list[OutputFile]:
out=[]
for name,payload in files.items():
rel=f"{output_dir}/{name}"; data=payload.encode() if isinstance(payload,str) else json.dumps(payload,indent=2,sort_keys=True,default=str).encode(); mime="text/markdown" if name.endswith(".md") else "application/json"; uri=None
try: ref=await ctx.write_artifact(rel,data,mime); await ctx.emit_artifact(ref); uri=ref.uri
except Exception as exc: warnings.append(f"Artifact write failed for {rel}: {type(exc).__name__}")
out.append(OutputFile(path=rel,artifact_uri=uri,mime_type=mime,size_bytes=len(data)))
return out
def _configuration_findings(target:AuditTarget,inv:dict[str,Any])->tuple[list[Finding],list[Evidence],list[str]]:
fs=[]; card=(inv.get("agent_card") or {}).get("card") or {}; skills=inv.get("skills") or []
if not card: fs.append(_finding(target,"A2A-CARD-001","Agent Card could not be verified",Severity.HIGH,Confidence.MEDIUM,"card","Consumers cannot validate skills, schemas, runtime, or billing metadata.","Ensure /.well-known/agent-card is reachable and valid.","Fetch and validate /.well-known/agent-card."))
if not skills: fs.append(_finding(target,"A2A-SCHEMA-001","No public skills discovered",Severity.HIGH,Confidence.MEDIUM,"skills","Callers cannot rely on a stable typed API contract.","Expose typed @a2a.tool methods.","Confirm skills in live Agent Card."))
for s in skills:
schema=s.get("input_schema") or s.get("inputSchema") or {}; out=s.get("output_schema") or s.get("outputSchema")
if not (isinstance(schema,dict) and schema.get("type")=="object" and schema.get("additionalProperties") is False) or not out: fs.append(_finding(target,"A2A-SCHEMA-002",f"Skill {s.get('name','<unknown>')} has weak schema",Severity.MEDIUM,Confidence.HIGH,"schemas","Weak schemas increase integration errors.","Use strict Pydantic schemas.","Inspect Agent Card schemas."))
if (inv.get("network_exposure") or {}).get("public") is True: fs.append(_finding(target,"A2A-NET-001","Agent is publicly exposed",Severity.MEDIUM,Confidence.MEDIUM,"network_exposure","Unexpected public exposure broadens abuse paths.","Set expose.public=false unless required.","Review a2a.yaml."))
if (inv.get("resource_limits") or {}).get("max_runtime_seconds") in (None,"unknown"): fs.append(_finding(target,"A2A-OPS-001","Runtime limit is unknown",Severity.LOW,Confidence.MEDIUM,"resource_limits","Unknown runtime budgets can cause reliability issues.","Declare Resources and manifest runtime resources.","Check Agent Card runtime.resources."))
if inv.get("secrets_references"): fs.append(_finding(target,"A2A-SECRET-001","Potential secret or provider-key reference found in source",Severity.HIGH,Confidence.HIGH,"secrets_references","Secrets can be exfiltrated.","Remove literal secrets and provider-key reads.","Run secret scan."))
if (inv.get("repository") or {}).get("suspicious_instruction_count",0)>0: fs.append(_finding(target,"A2A-UNTRUSTED-001","Repository contains malicious instruction-like text",Severity.MEDIUM,Confidence.HIGH,"repository","Untrusted text could manipulate auditors.","Treat repository content only as evidence.","Confirm text remains evidence only."))
return fs[:MAX_FINDINGS],[_evidence("configuration","configuration_rules","observed",{"finding_count":len(fs)})],[]
def _runtime_findings(target:AuditTarget,inv:dict[str,Any])->tuple[list[Finding],list[Evidence],list[str]]:
fs=[]; health=inv.get("deployment_health") or {"status":"unknown"}
if (inv.get("identity") or {}).get("authorized") is False: fs.append(_finding(target,"A2A-TENANT-001","Runtime audit skipped by namespace allowlist",Severity.HIGH,Confidence.HIGH,"identity","Tenant isolation boundary prevented probes.","Run from owning tenant or allowlist namespace.","Verify namespace allowlist."))
if health.get("status") in {"unreachable","degraded"}: fs.append(_finding(target,"A2A-HEALTH-001","Agent health probe failed or degraded",Severity.MEDIUM,Confidence.MEDIUM,"deployment_health","Consumers may experience failed calls.","Inspect readiness and routing.","Repeat health probe."))
if health.get("status") in {"unknown","not_tested"}: fs.append(_finding(target,"A2A-HEALTH-002","Runtime health state is unknown",Severity.LOW,Confidence.MEDIUM,"deployment_health","Operational state is unmeasured.","Provide allowlisted agent_url.","Run health_only probe."))
return fs[:MAX_FINDINGS],[_evidence("runtime","runtime_rules","observed",{"finding_count":len(fs),"health":health})],[]
def _finding(t:AuditTarget,cid:str,title:str,sev:Severity,conf:Confidence,eref:str,impact:str,rem:str,ver:str)->Finding:
suffix=hashlib.sha256(f"{t.agent_name}|{cid}|{title}".encode()).hexdigest()[:10]; return Finding(id=f"{cid.lower()}-{suffix}",control_id=cid,title=title,severity=sev,confidence=conf,evidence_refs=[eref],impact=impact,remediation=rem,verification_procedure=ver)
def _dedupe(findings:list[Finding])->list[Finding]:
rank={Severity.CRITICAL:5,Severity.HIGH:4,Severity.MEDIUM:3,Severity.LOW:2,Severity.INFO:1}; seen={}
for f in findings:
if f.id not in seen or rank[f.severity]>rank[seen[f.id].severity]: seen[f.id]=f
return sorted(seen.values(),key=lambda f:(-rank[f.severity],f.control_id,f.id))[:MAX_FINDINGS]
def _risk_summary(findings:list[Finding])->dict[str,Any]:
weights={Severity.CRITICAL:100,Severity.HIGH:40,Severity.MEDIUM:15,Severity.LOW:5,Severity.INFO:1}; counts={s.value:0 for s in Severity}; score=0
for f in findings: counts[f.severity.value]+=1; score+=weights[f.severity]
score=min(score,100); rating="critical" if counts["critical"] else "high" if score>=70 else "medium" if score>=30 else "low" if score else "clean"
return {"schema_version":"2026-07-13","risk_score":score,"risk_rating":rating,"finding_counts":counts,"top_controls":sorted({f.control_id for f in findings})[:20],"unknown_or_not_tested":[f.control_id for f in findings if f.status in {"unknown","not_tested"}],"mutations_performed":False}
def _remediation_md(t:AuditTarget,audit_id:str,findings:list[Finding],summary:dict[str,Any])->str:
lines=[f"# Remediation plan for {t.agent_name}","",f"Audit ID: `{audit_id}`",f"Risk rating: **{summary.get('risk_rating','unknown')}** ({summary.get('risk_score',0)}/100)","","This plan is read-only output. No production systems or repositories were changed.",""]
if not findings: lines += ["## No open findings","","The synthetic audit found no policy violations in the supplied evidence. Unknown/not-tested areas should still be reviewed."]
for f in findings: lines += [f"## {f.severity.value.upper()}{f.title}","",f"- Finding ID: `{f.id}`",f"- Control: `{f.control_id}`",f"- Confidence: `{f.confidence.value}`",f"- Evidence references: {', '.join(f.evidence_refs) or 'none'}",f"- Impact: {f.impact}",f"- Remediation: {f.remediation}",f"- Verification: {f.verification_procedure}",""]
return "\n".join(lines)
def _sarif(t:AuditTarget,findings:list[Finding])->dict[str,Any]:
rules=[{"id":f.control_id,"name":f.title,"shortDescription":{"text":f.title},"help":{"text":f.remediation}} for f in findings]
results=[{"ruleId":f.control_id,"level":"error" if f.severity in {Severity.CRITICAL,Severity.HIGH} else "warning" if f.severity is Severity.MEDIUM else "note","message":{"text":f"{f.title}: {f.impact}"},"partialFingerprints":{"findingId":f.id},"properties":{"severity":f.severity.value,"confidence":f.confidence.value,"verification":f.verification_procedure}} for f in findings]
return {"version":"2.1.0","$schema":"https://json.schemastore.org/sarif-2.1.0.json","runs":[{"tool":{"driver":{"name":"agent-compliance-auditor","rules":rules}},"invocations":[{"executionSuccessful":True,"properties":{"target":t.agent_name,"mutations_performed":False}}],"results":results}]}
def _repo_summary(files:dict[str,str])->dict[str,Any]:
paths=[p for p,t in files.items() if any(tok in t.lower() for tok in ("ignore previous instructions","exfiltrate","send secrets","system prompt"))]; return {"files_inspected":len(files),"paths":sorted(files),"suspicious_instruction_count":len(paths),"suspicious_instruction_paths":paths[:20],"bounded_bytes_per_file":MAX_TEXT_BYTES}
def _manifests(files:dict[str,str])->dict[str,Any]: return {p:({"present":True,"sha256":_sha(files[p]),"excerpt":_redact(files[p][:1200])} if p in files else {"present":False}) for p in ("a2a.yaml","agent.py","requirements.txt","Dockerfile")}
def _skills(card:Any,files:dict[str,str])->list[dict[str,Any]]:
if isinstance(card,dict) and isinstance(card.get("skills"),list): return [s for s in card["skills"] if isinstance(s,dict)]
return [{"name":n,"source":"agent.py"} for n in re.findall(r"async\s+def\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(",files.get("agent.py",""))]
def _schemas(card:Any)->dict[str,Any]:
out={}
if isinstance(card,dict):
for s in card.get("skills") or []:
if isinstance(s,dict): out[str(s.get("name") or s.get("id") or "unknown")]={"input_schema":s.get("input_schema") or s.get("inputSchema"),"output_schema":s.get("output_schema") or s.get("outputSchema")}
return out
def _secret_refs(files:dict[str,str])->list[dict[str,str]]:
refs=[]
for path,text in files.items():
for pattern in SECRET_PATTERNS:
for found in pattern.finditer(text):
refs.append({"path": path, "match": _redact(found.group(0))})
for indicator in WRITE_INDICATORS:
if indicator in text:
refs.append({"path": path, "match": _redact(indicator)})
return refs[:50]
def _exposure(manifests:dict[str,Any])->dict[str,Any]:
text=json.dumps(manifests,default=str).lower(); public=True if "public: true" in text or "public=true" in text else False if "public: false" in text or "public=false" in text else None
return {"public":public,"documented_public_reason":"public_reason" in text or "exposure_reason" in text,"unknown_when_null":public is None}
def _resources(card:Any,manifests:dict[str,Any])->dict[str,Any]:
runtime=card.get("runtime") if isinstance(card,dict) else {}; res=runtime.get("resources") if isinstance(runtime,dict) else {}; out=dict(res or {}) if isinstance(res,dict) else {}
if "max_runtime_seconds" not in out:
resource_match = re.search(r"max_runtime_seconds[\s:=]+([0-9]+)",json.dumps(manifests,default=str))
out["max_runtime_seconds"] = int(resource_match.group(1)) if resource_match else "unknown"
return out
def _provenance(manifests:dict[str,Any],files:dict[str,str])->dict[str,Any]:
docker=files.get("Dockerfile",""); return {"dockerfile_present":bool(docker),"digest_pinned_base_image":("@sha256:" in docker) if docker else "unknown","source_hashes":{k:v.get("sha256") for k,v in manifests.items() if isinstance(v,dict) and v.get("sha256")}}
def _rbac(manifests:dict[str,Any])->dict[str,Any]:
text=json.dumps(manifests,default=str).lower(); return {"service_account_mentions":text.count("serviceaccount"),"cluster_role_mentions":text.count("clusterrole"),"status":"observed" if "role" in text else "unknown"}
def _unknowns(card:dict[str,Any],manifests:dict[str,Any],health:dict[str,Any],files:dict[str,str])->list[str]:
out=[]
if not card.get("card"): out.append("agent_card")
if not files: out.append("repository_files")
if health.get("status") in {"unknown","not_tested"}: out.append("deployment_health")
if not any(v.get("present") for v in manifests.values() if isinstance(v,dict)): out.append("source_manifests")
return out
def _controls()->list[dict[str,str]]: return [{"control_id":cid,"title":title} for cid,title in [("A2A-CARD-001","Agent Card availability and integrity"),("A2A-SCHEMA-001","Public skill inventory"),("A2A-SCHEMA-002","Strict non-null schemas"),("A2A-NET-001","Network exposure minimization"),("A2A-OPS-001","Runtime resource limits"),("A2A-SECRET-001","Secret handling and redaction"),("A2A-UNTRUSTED-001","Untrusted content isolation"),("A2A-TENANT-001","Tenant and namespace isolation"),("A2A-HEALTH-001","Deployment health")]]
def _repo_evidence(files:dict[str,str])->list[Evidence]: return [_evidence(f"repo:{p}","repository","observed",t[:1000],path=p) for p,t in list(files.items())[:MAX_FILES_PER_AUDIT]]
def _evidence(eid:str,source:str,status:Literal["observed","missing","unknown","not_tested","redacted"],payload:Any,path:str|None=None)->Evidence:
text=payload if isinstance(payload,str) else json.dumps(payload,sort_keys=True,default=str); return Evidence(id=eid,source=source,path=path,status=status,excerpt=_redact(text)[:MAX_EVIDENCE_BYTES],sha256=_sha(text))
def _audit_id(target:AuditTarget,supplied:str|None)->str:
if supplied: return supplied
seed=f"{target.agent_name}|{target.repository or ''}|{int(time.time())}"; return f"audit-{target.agent_name}-{hashlib.sha256(seed.encode()).hexdigest()[:12]}"
def _output_dir(audit_id:str)->str: return f"outputs/compliance/{re.sub(r'[^A-Za-z0-9_.-]','-',audit_id)[:80]}"
def _path_allowed(path:str)->bool:
clean=path.replace("\\","/").lstrip("/"); return ".." not in clean.split("/") and not any(fnmatch(clean,p) for p in DENIED_REPO_GLOBS) and any(fnmatch(clean,p) for p in ALLOWED_REPO_GLOBS)
def _safe_url(parsed:Any,allowed_hosts:set[str])->bool:
host=(parsed.hostname or "").lower(); return parsed.scheme in {"https","http"} and bool(host) and host not in {"localhost","127.0.0.1","0.0.0.0"} and not host.startswith("169.254.") and not host.endswith(".local") and host in allowed_hosts
def _bounded_json(text:str,warnings:list[str])->Any:
if len(text.encode())>MAX_TEXT_BYTES: warnings.append("HTTP JSON response exceeded evidence bound and was truncated before parsing."); text=text[:MAX_TEXT_BYTES]
try: return json.loads(text)
except json.JSONDecodeError: warnings.append("HTTP response was not valid JSON."); return {"invalid_json_excerpt":_redact(text[:1000])}
def _redact_match(redact_match: re.Match[str]) -> str:
return redact_match.group(0)[:8]+"…REDACTED"
def _redact(text:str)->str:
out=text
for pat in SECRET_PATTERNS: out=pat.sub(_redact_match,out)
return out
def _sha(text:str)->str: return hashlib.sha256(text.encode("utf-8",errors="replace")).hexdigest()
def _dump(value:Any)->Any: return value.model_dump(mode="json") if isinstance(value,BaseModel) else value
async def _event(ctx:RunContext[NoAuth],kind:str,**payload:Any)->None:
safe=json.loads(json.dumps(payload,default=str)); await ctx.emit_event(AgentEvent(kind=kind,payload=safe))
if kind in {"audit_started","audit_progress","audit_completed"}: await ctx.emit_progress(f"{kind}: {safe.get('phase') or safe.get('skill') or kind}")