649 lines
34 KiB
Python
649 lines
34 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import ipaddress
|
|
import json
|
|
import re
|
|
from datetime import UTC, datetime
|
|
from enum import Enum
|
|
from pathlib import PurePosixPath
|
|
from typing import Any
|
|
from urllib.parse import urlparse
|
|
|
|
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 pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
|
|
APPROVAL_ACK = "I_APPROVE_THE_LISTED_STEPS"
|
|
OUTPUT_ROOT = "outputs/support-cases"
|
|
MAX_TEXT = 24000
|
|
MAX_ITEMS = 80
|
|
|
|
|
|
class StrictModel(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", use_enum_values=True)
|
|
|
|
|
|
class SupportToFixEngineerConfig(StrictModel):
|
|
max_log_chars: int = Field(default=12000, ge=1000, le=50000)
|
|
default_dry_run: bool = True
|
|
|
|
|
|
class Severity(str, Enum):
|
|
sev1 = "sev1"
|
|
sev2 = "sev2"
|
|
sev3 = "sev3"
|
|
sev4 = "sev4"
|
|
|
|
|
|
class EvidenceKind(str, Enum):
|
|
ticket = "ticket"
|
|
log = "log"
|
|
trace = "trace"
|
|
release = "release"
|
|
change = "change"
|
|
customer = "customer"
|
|
health_check = "health_check"
|
|
repository = "repository"
|
|
validation = "validation"
|
|
|
|
|
|
class DeliveryAction(str, Enum):
|
|
draft_branch = "draft_branch"
|
|
draft_commit = "draft_commit"
|
|
draft_pr = "draft_pr"
|
|
customer_summary = "customer_summary"
|
|
|
|
|
|
class KeyValue(StrictModel):
|
|
key: str = Field(min_length=1, max_length=120)
|
|
value: str = Field(default="", max_length=1200)
|
|
|
|
@field_validator("key", "value")
|
|
@classmethod
|
|
def clean(cls, value: str) -> str:
|
|
return redact(value.strip())[:1200]
|
|
|
|
|
|
class EvidenceItem(StrictModel):
|
|
source_id: str = Field(min_length=1, max_length=160)
|
|
kind: EvidenceKind
|
|
summary: str = Field(min_length=1, max_length=1200)
|
|
timestamp: str | None = Field(default=None, max_length=80)
|
|
url: str | None = Field(default=None, max_length=2048)
|
|
excerpt: str | None = Field(default=None, max_length=4000)
|
|
|
|
@field_validator("source_id", "summary", "excerpt")
|
|
@classmethod
|
|
def clean_text(cls, value: str | None) -> str | None:
|
|
return None if value is None else redact(value)[:4000]
|
|
|
|
@field_validator("url")
|
|
@classmethod
|
|
def safe_url(cls, value: str | None) -> str | None:
|
|
if not value:
|
|
return None
|
|
return validate_safe_url(value, allowed_hosts=set())
|
|
|
|
|
|
class HttpCheck(StrictModel):
|
|
name: str = Field(min_length=1, max_length=80)
|
|
url: str = Field(min_length=8, max_length=2048)
|
|
expected_status: int = Field(default=200, ge=100, le=599)
|
|
|
|
|
|
class RepoFileChange(StrictModel):
|
|
path: str = Field(min_length=1, max_length=240)
|
|
reason: str = Field(min_length=1, max_length=800)
|
|
proposed_content: str | None = Field(default=None, max_length=20000)
|
|
|
|
@field_validator("path")
|
|
@classmethod
|
|
def path_ok(cls, value: str) -> str:
|
|
return safe_path(value)
|
|
|
|
@field_validator("reason", "proposed_content")
|
|
@classmethod
|
|
def redact_text(cls, value: str | None) -> str | None:
|
|
return None if value is None else redact(value)
|
|
|
|
|
|
class TriageCaseInput(StrictModel):
|
|
case_id: str = Field(min_length=3, max_length=80, pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{2,79}$")
|
|
customer_id: str = Field(min_length=1, max_length=120)
|
|
tenant_id: str = Field(min_length=1, max_length=120)
|
|
release: str = Field(min_length=1, max_length=120)
|
|
ticket_text: str = Field(min_length=1, max_length=MAX_TEXT)
|
|
evidence: list[EvidenceItem] = Field(default_factory=list, max_length=MAX_ITEMS)
|
|
affected_services: list[str] = Field(default_factory=list, max_length=20)
|
|
recent_changes: list[str] = Field(default_factory=list, max_length=40)
|
|
dry_run: bool = True
|
|
|
|
@field_validator("ticket_text", "customer_id", "tenant_id", "release")
|
|
@classmethod
|
|
def redact_fields(cls, value: str) -> str:
|
|
return redact(value)
|
|
|
|
@field_validator("affected_services", "recent_changes")
|
|
@classmethod
|
|
def clean_list(cls, values: list[str]) -> list[str]:
|
|
return [redact(v.strip())[:500] for v in values if v.strip()]
|
|
|
|
|
|
class ReproduceIssueInput(StrictModel):
|
|
case_id: str = Field(min_length=3, max_length=80, pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{2,79}$")
|
|
redacted_symptom: str = Field(min_length=1, max_length=8000)
|
|
environment: list[KeyValue] = Field(default_factory=list, max_length=30)
|
|
observed_errors: list[str] = Field(default_factory=list, max_length=40)
|
|
candidate_files: list[str] = Field(default_factory=list, max_length=40)
|
|
health_checks: list[HttpCheck] = Field(default_factory=list, max_length=20)
|
|
allowed_health_hosts: list[str] = Field(default_factory=list, max_length=30)
|
|
dry_run: bool = True
|
|
|
|
@field_validator("redacted_symptom")
|
|
@classmethod
|
|
def redact_symptom(cls, value: str) -> str:
|
|
return redact(value)
|
|
|
|
@field_validator("candidate_files")
|
|
@classmethod
|
|
def safe_paths(cls, values: list[str]) -> list[str]:
|
|
return [safe_path(v) for v in values]
|
|
|
|
|
|
class ProposeFixInput(StrictModel):
|
|
case_id: str = Field(min_length=3, max_length=80, pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{2,79}$")
|
|
reproduction_summary: str = Field(min_length=1, max_length=12000)
|
|
hypotheses: list[str] = Field(default_factory=list, max_length=20)
|
|
supporting_evidence: list[EvidenceItem] = Field(default_factory=list, max_length=MAX_ITEMS)
|
|
contradicting_evidence: list[EvidenceItem] = Field(default_factory=list, max_length=MAX_ITEMS)
|
|
proposed_changes: list[RepoFileChange] = Field(default_factory=list, max_length=40)
|
|
repository: str | None = Field(default=None, max_length=200)
|
|
dry_run: bool = True
|
|
|
|
@field_validator("reproduction_summary")
|
|
@classmethod
|
|
def redact_summary(cls, value: str) -> str:
|
|
return redact(value)
|
|
|
|
|
|
class ValidateFixInput(StrictModel):
|
|
case_id: str = Field(min_length=3, max_length=80, pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{2,79}$")
|
|
patch_digest: str = Field(min_length=16, max_length=128)
|
|
patch_diff: str = Field(min_length=1, max_length=60000)
|
|
test_selectors: list[str] = Field(default_factory=list, max_length=40)
|
|
lint_selectors: list[str] = Field(default_factory=list, max_length=20)
|
|
security_checks: list[str] = Field(default_factory=list, max_length=20)
|
|
dry_run: bool = True
|
|
|
|
@field_validator("test_selectors", "lint_selectors", "security_checks")
|
|
@classmethod
|
|
def selector_ok(cls, values: list[str]) -> list[str]:
|
|
out: list[str] = []
|
|
for item in values:
|
|
cleaned = item.strip()
|
|
if cleaned:
|
|
if re.search(r"[;&|`$<>]", cleaned):
|
|
raise ValueError("selectors must not contain shell metacharacters")
|
|
out.append(cleaned[:180])
|
|
return out
|
|
|
|
@field_validator("patch_diff")
|
|
@classmethod
|
|
def patch_clean(cls, value: str) -> str:
|
|
return redact(value)
|
|
|
|
|
|
class PrepareDeliveryInput(StrictModel):
|
|
case_id: str = Field(min_length=3, max_length=80, pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{2,79}$")
|
|
validation_digest: str = Field(min_length=16, max_length=128)
|
|
patch_digest: str = Field(min_length=16, max_length=128)
|
|
repository: str = Field(min_length=1, max_length=200)
|
|
target_branch: str = Field(default="main", min_length=1, max_length=120)
|
|
proposed_branch: str = Field(default="support-fix", min_length=1, max_length=120)
|
|
delivery_actions: list[DeliveryAction] = Field(default_factory=lambda: [DeliveryAction.customer_summary], max_length=4)
|
|
customer_summary_context: str = Field(default="", max_length=8000)
|
|
dry_run: bool = True
|
|
approval_acknowledgement: str | None = Field(default=None, max_length=80)
|
|
approval_plan_digest: str | None = Field(default=None, max_length=128)
|
|
|
|
@field_validator("repository", "target_branch", "proposed_branch")
|
|
@classmethod
|
|
def ref_ok(cls, value: str) -> str:
|
|
if re.search(r"[\s;&|`$<>]", value):
|
|
raise ValueError("repository and branch values must not contain whitespace or shell metacharacters")
|
|
return value
|
|
|
|
|
|
class ArtifactRecord(StrictModel):
|
|
path: str
|
|
artifact_uri: str | None = None
|
|
sha256: str
|
|
size_bytes: int = Field(ge=0)
|
|
|
|
|
|
class AuditRecord(StrictModel):
|
|
event: str
|
|
at: str
|
|
case_id: str
|
|
details: list[KeyValue] = Field(default_factory=list, max_length=20)
|
|
|
|
|
|
class HypothesisRank(StrictModel):
|
|
hypothesis: str
|
|
rank: int = Field(ge=1)
|
|
confidence: float = Field(ge=0.0, le=1.0)
|
|
supporting_source_ids: list[str]
|
|
contradicting_source_ids: list[str]
|
|
|
|
|
|
class CheckResult(StrictModel):
|
|
name: str
|
|
status: str
|
|
|
|
|
|
class TriageCaseOutput(StrictModel):
|
|
status: str
|
|
case_id: str
|
|
severity: Severity
|
|
owner: str
|
|
redaction_count: int = Field(ge=0)
|
|
missing_setup: list[str]
|
|
allowed_tenant: bool
|
|
plan_digest: str
|
|
evidence_index: list[EvidenceItem]
|
|
artifacts: list[ArtifactRecord]
|
|
audit: list[AuditRecord]
|
|
warnings: list[str]
|
|
|
|
|
|
class ReproduceIssueOutput(StrictModel):
|
|
status: str
|
|
case_id: str
|
|
reproduction_digest: str
|
|
minimal_steps: list[str]
|
|
safe_health_checks: list[HttpCheck]
|
|
blocked_health_checks: list[str]
|
|
artifacts: list[ArtifactRecord]
|
|
audit: list[AuditRecord]
|
|
warnings: list[str]
|
|
|
|
|
|
class ProposeFixOutput(StrictModel):
|
|
status: str
|
|
case_id: str
|
|
repository: str | None
|
|
patch_digest: str
|
|
plan_digest: str
|
|
hypotheses: list[HypothesisRank]
|
|
scoped_paths: list[str]
|
|
artifacts: list[ArtifactRecord]
|
|
audit: list[AuditRecord]
|
|
warnings: list[str]
|
|
|
|
|
|
class ValidateFixOutput(StrictModel):
|
|
status: str
|
|
case_id: str
|
|
validation_digest: str
|
|
patch_digest: str
|
|
checks: list[CheckResult]
|
|
artifacts: list[ArtifactRecord]
|
|
audit: list[AuditRecord]
|
|
warnings: list[str]
|
|
|
|
|
|
class PrepareDeliveryOutput(StrictModel):
|
|
status: str
|
|
case_id: str
|
|
plan_digest: str
|
|
approval_required: bool
|
|
approved: bool
|
|
executed_actions: list[str]
|
|
blocked_actions: list[str]
|
|
artifacts: list[ArtifactRecord]
|
|
audit: list[AuditRecord]
|
|
warnings: list[str]
|
|
|
|
|
|
class SupportToFixEngineer(A2AAgent[SupportToFixEngineerConfig, NoAuth]):
|
|
name = "support-to-fix-engineer"
|
|
description = "Turns support cases into evidence-backed fix artifacts with approval-gated delivery."
|
|
version = "0.1.0"
|
|
config_model = SupportToFixEngineerConfig
|
|
auth_model = NoAuth
|
|
pricing = Pricing(price_per_call_usd=0.0, caller_pays_llm=False, notes="Deterministic policy and artifact generation; no LLM credential required.")
|
|
resources = Resources(cpu="1", memory="512Mi", max_runtime_seconds=600)
|
|
workspace_access = WorkspaceAccess.dynamic(
|
|
max_files=160,
|
|
allowed_modes=(WorkspaceMode.READ_ONLY, WorkspaceMode.READ_WRITE_OVERLAY),
|
|
require_reason=True,
|
|
deny_patterns=("**/.env", "**/.env.*", "**/*secret*", "**/*credential*", "**/id_rsa", "**/id_ed25519"),
|
|
require_human_approval=True,
|
|
max_total_size_bytes=50 * 1024 * 1024,
|
|
)
|
|
consumer_setup = ConsumerSetup.from_fields(
|
|
ConsumerSetupField.config("GITEA_BASE_URL", label="Gitea base URL", input_type="url", required=False),
|
|
ConsumerSetupField.secret("GITEA_TOKEN", label="Gitea token", required=False),
|
|
ConsumerSetupField.config("GITEA_ALLOWED_REPOS", label="Allowed repositories", required=False),
|
|
ConsumerSetupField.config("KUBERNETES_API_URL", label="Kubernetes API URL", input_type="url", required=False),
|
|
ConsumerSetupField.secret("KUBERNETES_TOKEN", label="Kubernetes token", required=False),
|
|
ConsumerSetupField.config("ARGOCD_BASE_URL", label="Argo CD base URL", input_type="url", required=False),
|
|
ConsumerSetupField.secret("ARGOCD_TOKEN", label="Argo CD token", required=False),
|
|
ConsumerSetupField.config("OBSERVABILITY_BASE_URL", label="Observability base URL", input_type="url", required=False),
|
|
ConsumerSetupField.secret("OBSERVABILITY_TOKEN", label="Observability token", required=False),
|
|
ConsumerSetupField.config("TENANT_ALLOWLIST", label="Tenant allowlist", required=False),
|
|
ConsumerSetupField.config("HEALTHCHECK_ALLOWED_HOSTS", label="HTTP health check hosts", required=False),
|
|
)
|
|
egress = EgressPolicy(allow_hosts=(), deny_internet_by_default=True)
|
|
tools_used = ("pydantic", "workspace")
|
|
|
|
@a2a.tool(description="Intake, redact, correlate evidence, classify severity and ownership for a bounded support case", timeout_seconds=120, idempotent=True)
|
|
async def triage_case(self, ctx: RunContext[NoAuth], case: TriageCaseInput) -> TriageCaseOutput:
|
|
await emit_audit(ctx, case.case_id, "triage_started", [kv("dry_run", case.dry_run)])
|
|
warnings = warnings_for(case.ticket_text, [e.summary for e in case.evidence])
|
|
allowed = tenant_allowed(ctx, case.tenant_id)
|
|
severity = classify_severity(case.ticket_text, case.evidence)
|
|
owner = classify_owner(case.affected_services, case.ticket_text, case.recent_changes)
|
|
evidence = normalize_evidence(case)
|
|
payload = {"case_id": case.case_id, "customer_id": case.customer_id, "tenant_id": case.tenant_id, "release": case.release, "severity": severity.value, "owner": owner, "allowed_tenant": allowed, "evidence": [e.model_dump(mode="json") for e in evidence]}
|
|
digest = stable_digest(payload)
|
|
artifacts = [await write_case_file(ctx, case.case_id, "redacted_case.json", dumps(payload)), await write_case_file(ctx, case.case_id, "triage.json", dumps({**payload, "plan_digest": digest}))]
|
|
audit = [audit_record(case.case_id, "triage_completed", [kv("plan_digest", digest), kv("severity", severity.value)])]
|
|
await emit_audit(ctx, case.case_id, "triage_completed", [kv("plan_digest", digest)])
|
|
setup = missing_setup(ctx)
|
|
if setup:
|
|
warnings.append("Some integrations are not configured; analysis used caller-supplied evidence only.")
|
|
return TriageCaseOutput(status="triaged" if allowed else "blocked_ungranted_tenant", case_id=case.case_id, severity=severity, owner=owner, redaction_count=count_redactions(case.ticket_text), missing_setup=setup, allowed_tenant=allowed, plan_digest=digest, evidence_index=evidence, artifacts=artifacts, audit=audit, warnings=warnings)
|
|
|
|
@a2a.tool(description="Create a deterministic minimal reproduction and validate HTTP health-check targets against SSRF protections", timeout_seconds=120, idempotent=True)
|
|
async def reproduce_issue(self, ctx: RunContext[NoAuth], reproduction: ReproduceIssueInput) -> ReproduceIssueOutput:
|
|
await emit_audit(ctx, reproduction.case_id, "reproduction_started", [kv("dry_run", reproduction.dry_run)])
|
|
allowed_hosts = set(reproduction.allowed_health_hosts or split_csv(ctx.consumer_config("HEALTHCHECK_ALLOWED_HOSTS", "")))
|
|
safe: list[HttpCheck] = []
|
|
blocked: list[str] = []
|
|
for check in reproduction.health_checks:
|
|
try:
|
|
validate_safe_url(check.url, allowed_hosts=allowed_hosts)
|
|
safe.append(check)
|
|
except ValueError as exc:
|
|
blocked.append(f"{check.name}: {exc}")
|
|
steps = repro_steps(reproduction)
|
|
digest = stable_digest({"steps": steps, "safe": [c.model_dump(mode="json") for c in safe], "blocked": blocked})
|
|
artifact = await write_case_file(ctx, reproduction.case_id, "reproduction.md", repro_markdown(reproduction, steps, safe, blocked))
|
|
audit = [audit_record(reproduction.case_id, "reproduction_completed", [kv("reproduction_digest", digest)])]
|
|
await emit_audit(ctx, reproduction.case_id, "reproduction_completed", [kv("reproduction_digest", digest)])
|
|
return ReproduceIssueOutput(status="reproduced_deterministically", case_id=reproduction.case_id, reproduction_digest=digest, minimal_steps=steps, safe_health_checks=safe, blocked_health_checks=blocked, artifacts=[artifact], audit=audit, warnings=warnings_for(reproduction.redacted_symptom, reproduction.observed_errors))
|
|
|
|
@a2a.tool(description="Rank hypotheses with provenance and produce a scoped patch.diff without modifying a repository", timeout_seconds=180, idempotent=True)
|
|
async def propose_fix(self, ctx: RunContext[NoAuth], proposal: ProposeFixInput) -> ProposeFixOutput:
|
|
await emit_audit(ctx, proposal.case_id, "proposal_started", [kv("dry_run", proposal.dry_run)])
|
|
ranked = rank_hypotheses(proposal.hypotheses, proposal.supporting_evidence, proposal.contradicting_evidence)
|
|
paths = [c.path for c in proposal.proposed_changes]
|
|
patch = patch_diff(proposal.proposed_changes)
|
|
patch_digest = stable_digest({"patch_diff": patch, "paths": paths})
|
|
plan_digest = stable_digest({"case_id": proposal.case_id, "repository": proposal.repository, "patch_digest": patch_digest, "paths": paths})
|
|
diagnosis = {"case_id": proposal.case_id, "repository": proposal.repository, "hypotheses": [h.model_dump(mode="json") for h in ranked], "scoped_paths": paths, "dry_run": proposal.dry_run, "note": "No repository mutation performed. Patch is an output artifact only."}
|
|
artifacts = [await write_case_file(ctx, proposal.case_id, "diagnosis.json", dumps(diagnosis)), await write_case_file(ctx, proposal.case_id, "patch.diff", patch)]
|
|
audit = [audit_record(proposal.case_id, "proposal_completed", [kv("patch_digest", patch_digest), kv("plan_digest", plan_digest)])]
|
|
await emit_audit(ctx, proposal.case_id, "proposal_completed", [kv("patch_digest", patch_digest)])
|
|
return ProposeFixOutput(status="patch_proposed_no_repository_write", case_id=proposal.case_id, repository=proposal.repository, patch_digest=patch_digest, plan_digest=plan_digest, hypotheses=ranked, scoped_paths=paths, artifacts=artifacts, audit=audit, warnings=warnings_for(proposal.reproduction_summary, proposal.hypotheses))
|
|
|
|
@a2a.tool(description="Validate a proposed patch with deterministic static checks and test-plan selectors without executing arbitrary shell", timeout_seconds=180, idempotent=True)
|
|
async def validate_fix(self, ctx: RunContext[NoAuth], validation: ValidateFixInput) -> ValidateFixOutput:
|
|
await emit_audit(ctx, validation.case_id, "validation_started", [kv("dry_run", validation.dry_run)])
|
|
checks = validation_checks(validation)
|
|
payload = {"case_id": validation.case_id, "patch_digest": validation.patch_digest, "checks": [c.model_dump(mode="json") for c in checks], "test_selectors": validation.test_selectors, "lint_selectors": validation.lint_selectors, "security_checks": validation.security_checks, "dry_run": validation.dry_run, "note": "No arbitrary shell was executed; selectors are a reviewable validation plan."}
|
|
digest = stable_digest(payload)
|
|
artifact = await write_case_file(ctx, validation.case_id, "validation.json", dumps({**payload, "validation_digest": digest}))
|
|
audit = [audit_record(validation.case_id, "validation_completed", [kv("validation_digest", digest)])]
|
|
await emit_audit(ctx, validation.case_id, "validation_completed", [kv("validation_digest", digest)])
|
|
warnings = [] if all(c.status == "pass" for c in checks) else ["One or more deterministic checks require human review."]
|
|
return ValidateFixOutput(status="validated_dry_run" if validation.dry_run else "validated_without_deploy", case_id=validation.case_id, validation_digest=digest, patch_digest=validation.patch_digest, checks=checks, artifacts=[artifact], audit=audit, warnings=warnings)
|
|
|
|
@a2a.tool(description="Prepare a reviewable branch/commit/PR draft and customer-safe summary; gated actions require approval bound to the plan digest", timeout_seconds=180, idempotent=True)
|
|
async def prepare_delivery(self, ctx: RunContext[NoAuth], delivery: PrepareDeliveryInput) -> PrepareDeliveryOutput:
|
|
await emit_audit(ctx, delivery.case_id, "delivery_started", [kv("dry_run", delivery.dry_run)])
|
|
actions = [str(a.value if isinstance(a, DeliveryAction) else a) for a in delivery.delivery_actions]
|
|
plan = {"case_id": delivery.case_id, "repository": delivery.repository, "target_branch": delivery.target_branch, "proposed_branch": delivery.proposed_branch, "patch_digest": delivery.patch_digest, "validation_digest": delivery.validation_digest, "actions": actions}
|
|
digest = stable_digest(plan)
|
|
consequential = any(a in {"draft_branch", "draft_commit", "draft_pr"} for a in actions)
|
|
approved = consequential and not delivery.dry_run and delivery.approval_acknowledgement == APPROVAL_ACK and delivery.approval_plan_digest == digest
|
|
approval_required = consequential and not approved
|
|
executed = actions if approved else (["customer_summary"] if "customer_summary" in actions else [])
|
|
blocked = [a for a in actions if a not in executed]
|
|
artifact = await write_case_file(ctx, delivery.case_id, "delivery.md", delivery_doc(delivery, digest, approval_required, approved, executed, blocked))
|
|
audit = [audit_record(delivery.case_id, "delivery_prepared", [kv("plan_digest", digest), kv("approved", approved)])]
|
|
await emit_audit(ctx, delivery.case_id, "delivery_prepared", [kv("plan_digest", digest), kv("approved", approved)])
|
|
return PrepareDeliveryOutput(status="approval_required" if approval_required else "delivery_prepared", case_id=delivery.case_id, plan_digest=digest, approval_required=approval_required, approved=bool(approved), executed_actions=executed, blocked_actions=blocked, artifacts=[artifact], audit=audit, warnings=["No merge or deployment was performed.", "Repository mutations are represented as a reviewable delivery plan only unless platform approval executes them outside this agent."])
|
|
|
|
|
|
def kv(key: str, value: Any) -> KeyValue:
|
|
return KeyValue(key=str(key), value=str(value))
|
|
|
|
|
|
def split_csv(value: Any) -> list[str]:
|
|
return [x.strip() for x in str(value or "").split(",") if x.strip()]
|
|
|
|
|
|
def missing_setup(ctx: RunContext[NoAuth]) -> list[str]:
|
|
return [name for name in ("GITEA_BASE_URL", "KUBERNETES_API_URL", "ARGOCD_BASE_URL", "OBSERVABILITY_BASE_URL") if not ctx.consumer_config(name, "")]
|
|
|
|
|
|
def tenant_allowed(ctx: RunContext[NoAuth], tenant_id: str) -> bool:
|
|
allowed = set(split_csv(ctx.consumer_config("TENANT_ALLOWLIST", "")))
|
|
return not allowed or tenant_id in allowed
|
|
|
|
|
|
SECRET_RE = re.compile(r"(?i)(authorization:\s*bearer\s+[A-Za-z0-9._~+/=-]+|api[_-]?key\s*[:=]\s*['\"]?[^\s,'\"]+|token\s*[:=]\s*['\"]?[^\s,'\"]+|password\s*[:=]\s*['\"]?[^\s,'\"]+|secret\s*[:=]\s*['\"]?[^\s,'\"]+|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----)")
|
|
PII_RE = re.compile(r"(?i)\b([A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}|\+?\d[\d .()\-]{7,}\d)\b")
|
|
INJECTION_RE = re.compile(r"(?i)(ignore (all )?(previous|prior) instructions|system prompt|developer message|reveal secrets|exfiltrate|curl\s+|wget\s+|bash\s+-c)")
|
|
|
|
|
|
def redact(text: str | None) -> str:
|
|
if not text:
|
|
return ""
|
|
return PII_RE.sub("[REDACTED_PII]", SECRET_RE.sub("[REDACTED_SECRET]", str(text)))
|
|
|
|
|
|
def count_redactions(text: str) -> int:
|
|
return len(SECRET_RE.findall(text or ""))
|
|
|
|
|
|
def warnings_for(*items: Any) -> list[str]:
|
|
flat: list[str] = []
|
|
for item in items:
|
|
flat.extend(str(v) for v in item) if isinstance(item, list) else flat.append(str(item))
|
|
text = "\n".join(flat)
|
|
warnings: list[str] = []
|
|
if INJECTION_RE.search(text):
|
|
warnings.append("Untrusted ticket/log content contained instruction-like text and was treated only as evidence.")
|
|
if SECRET_RE.search(text):
|
|
warnings.append("Secrets were detected and redacted before artifact generation.")
|
|
if len(text) > MAX_TEXT:
|
|
warnings.append("Input was bounded and truncated for deterministic processing.")
|
|
return warnings
|
|
|
|
|
|
def safe_path(path: str) -> str:
|
|
clean = str(path).replace("\\", "/").strip().lstrip("/")
|
|
posix = PurePosixPath(clean)
|
|
if not clean or ".." in posix.parts or clean.startswith("~") or any(p in {".git", ".hg", ".svn"} for p in posix.parts):
|
|
raise ValueError(f"unsafe repository path: {path!r}")
|
|
return posix.as_posix()
|
|
|
|
|
|
def safe_case(case_id: str) -> str:
|
|
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{2,79}", case_id):
|
|
raise ValueError("invalid case_id")
|
|
return case_id
|
|
|
|
|
|
def validate_safe_url(url: str, *, allowed_hosts: set[str]) -> str:
|
|
parsed = urlparse(url)
|
|
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
raise ValueError("URL must be absolute http(s)")
|
|
host = parsed.hostname.lower().rstrip(".")
|
|
if allowed_hosts and host not in {h.lower().rstrip(".") for h in allowed_hosts}:
|
|
raise ValueError(f"host {host!r} is not in the allowlist")
|
|
if host in {"localhost", "metadata.google.internal"} or host.endswith(".local") or host.endswith(".internal"):
|
|
raise ValueError("internal hostnames are blocked")
|
|
try:
|
|
ip = ipaddress.ip_address(host)
|
|
except ValueError:
|
|
ip = None
|
|
if ip and (ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved or ip.is_unspecified):
|
|
raise ValueError("unsafe IP address is blocked")
|
|
if parsed.username or parsed.password:
|
|
raise ValueError("credentials in URLs are not allowed")
|
|
return url
|
|
|
|
|
|
def stable_digest(payload: Any) -> str:
|
|
return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str).encode()).hexdigest()
|
|
|
|
|
|
def classify_severity(ticket: str, evidence: list[EvidenceItem]) -> Severity:
|
|
text = (ticket + "\n" + "\n".join(e.summary for e in evidence)).lower()
|
|
if any(x in text for x in ("outage", "data loss", "security incident", "all customers", "sev1")):
|
|
return Severity.sev1
|
|
if any(x in text for x in ("unable", "down", "500", "payments failing", "sev2")):
|
|
return Severity.sev2
|
|
if any(x in text for x in ("degraded", "intermittent", "slow", "error")):
|
|
return Severity.sev3
|
|
return Severity.sev4
|
|
|
|
|
|
def classify_owner(services: list[str], ticket: str, changes: list[str]) -> str:
|
|
text = " ".join([ticket, *services, *changes]).lower()
|
|
if "payment" in text or "checkout" in text:
|
|
return "payments"
|
|
if "auth" in text or "login" in text:
|
|
return "identity"
|
|
if "kubernetes" in text or "pod" in text or "argocd" in text:
|
|
return "platform"
|
|
return services[0][:80] if services else "unassigned"
|
|
|
|
|
|
def normalize_evidence(case: TriageCaseInput) -> list[EvidenceItem]:
|
|
out = list(case.evidence)
|
|
out.append(EvidenceItem(source_id="ticket", kind=EvidenceKind.ticket, summary=case.ticket_text[:1200], excerpt=case.ticket_text[:2000]))
|
|
out.append(EvidenceItem(source_id="release", kind=EvidenceKind.release, summary=f"Release: {case.release}"))
|
|
for i, change in enumerate(case.recent_changes[:20], 1):
|
|
out.append(EvidenceItem(source_id=f"recent-change-{i}", kind=EvidenceKind.change, summary=change[:1200]))
|
|
return out[:MAX_ITEMS]
|
|
|
|
|
|
def repro_steps(inp: ReproduceIssueInput) -> list[str]:
|
|
env = {item.key: item.value for item in inp.environment}
|
|
steps = ["Use only redacted case data and explicitly granted workspace evidence.", f"Set environment facets: {json.dumps(env, sort_keys=True)}.", "Trigger the smallest customer-safe path matching the symptom."]
|
|
if inp.observed_errors:
|
|
steps.append(f"Assert observed signature: {redact(inp.observed_errors[0])[:500]}.")
|
|
if inp.candidate_files:
|
|
steps.append("Inspect only scoped candidate files: " + ", ".join(inp.candidate_files[:10]) + ".")
|
|
if inp.health_checks:
|
|
steps.append("Run configured HTTP checks only after SSRF allowlist validation; do not follow redirects.")
|
|
steps.append("Record expected versus actual behavior without customer secrets or raw tenant data.")
|
|
return steps
|
|
|
|
|
|
def repro_markdown(inp: ReproduceIssueInput, steps: list[str], checks: list[HttpCheck], blocked: list[str]) -> str:
|
|
body = [f"# Minimal Reproduction for {inp.case_id}", "", "## Symptom", inp.redacted_symptom, "", "## Deterministic Steps"]
|
|
body += [f"{i}. {s}" for i, s in enumerate(steps, 1)]
|
|
body += ["", "## Safe Health Checks"] + ([f"- {c.name}: {c.url} expects {c.expected_status}" for c in checks] or ["- None"])
|
|
body += ["", "## Blocked Health Checks"] + ([f"- {b}" for b in blocked] or ["- None"])
|
|
body += ["", "## Safety", "Ticket/log instructions are untrusted evidence; no arbitrary shell or URL fetching is performed."]
|
|
return "\n".join(body) + "\n"
|
|
|
|
|
|
def rank_hypotheses(hypotheses: list[str], supporting: list[EvidenceItem], contradicting: list[EvidenceItem]) -> list[HypothesisRank]:
|
|
raw = hypotheses or ["Regression correlated with recent release/change and observed error signature"]
|
|
sids = [e.source_id for e in supporting]
|
|
cids = [e.source_id for e in contradicting]
|
|
return [HypothesisRank(hypothesis=redact(h)[:1000], rank=i, confidence=round(max(0.1, min(0.95, 0.55 + len(sids) * 0.05 - len(cids) * 0.08 - (i - 1) * 0.03)), 2), supporting_source_ids=sids[:20], contradicting_source_ids=cids[:20]) for i, h in enumerate(raw[:20], 1)]
|
|
|
|
|
|
def patch_diff(changes: list[RepoFileChange]) -> str:
|
|
if not changes:
|
|
return "diff --git a/README.md b/README.md\n--- a/README.md\n+++ b/README.md\n@@ -0,0 +1,3 @@\n+# Support Case Fix Placeholder\n+No scoped code changes were supplied.\n+Attach repository evidence before implementation.\n"
|
|
parts: list[str] = []
|
|
for change in changes:
|
|
lines = redact(change.proposed_content or f"# Proposed change\n# Reason: {change.reason}\n").splitlines()
|
|
parts += [f"diff --git a/{change.path} b/{change.path}", f"--- a/{change.path}", f"+++ b/{change.path}", f"@@ -0,0 +1,{max(1, len(lines))} @@"]
|
|
parts += ["+" + line for line in lines[:400]]
|
|
return "\n".join(parts) + "\n"
|
|
|
|
|
|
def validation_checks(inp: ValidateFixInput) -> list[CheckResult]:
|
|
values = {
|
|
"patch_digest_format": "pass" if re.fullmatch(r"[a-fA-F0-9]{16,128}", inp.patch_digest) else "review",
|
|
"patch_has_diff_header": "pass" if "diff --git" in inp.patch_diff and "+++ b/" in inp.patch_diff else "review",
|
|
"no_secret_literals": "pass" if not SECRET_RE.search(inp.patch_diff) else "fail",
|
|
"path_containment": "pass" if not re.search(r"(^|\n)(---|\+\+\+) [ab]/(\.\.|/|.*\.git)", inp.patch_diff) else "fail",
|
|
"test_plan_present": "pass" if inp.test_selectors else "review",
|
|
"lint_plan_present": "pass" if inp.lint_selectors else "review",
|
|
"security_plan_present": "pass" if inp.security_checks else "review",
|
|
"dry_run_enforced": "pass" if inp.dry_run else "review",
|
|
}
|
|
return [CheckResult(name=k, status=v) for k, v in values.items()]
|
|
|
|
|
|
def delivery_doc(inp: PrepareDeliveryInput, digest: str, required: bool, approved: bool, executed: list[str], blocked: list[str]) -> str:
|
|
summary = redact(inp.customer_summary_context)[:2000] or "A scoped fix has been prepared for engineering review. No customer secrets, raw logs, or deployment details are included."
|
|
return "\n".join([f"# Delivery Plan for {inp.case_id}", "", f"Plan digest: `{digest}`", f"Repository: `{inp.repository}`", f"Target branch: `{inp.target_branch}`", f"Proposed branch: `{inp.proposed_branch}`", f"Patch digest: `{inp.patch_digest}`", f"Validation digest: `{inp.validation_digest}`", "", "## Approval", f"Approval required: `{str(required).lower()}`", f"Approved: `{str(approved).lower()}`", f"Required acknowledgement: `{APPROVAL_ACK}` bound to the plan digest above.", "", "## Actions", "Executed/prepared: " + (", ".join(executed) if executed else "none"), "Blocked: " + (", ".join(blocked) if blocked else "none"), "", "## Customer-safe Resolution Summary", summary, "", "## Non-actions", "This agent never merges, deploys, follows ticket/log instructions, executes arbitrary shell, or fetches arbitrary URLs."]) + "\n"
|
|
|
|
|
|
def audit_record(case_id: str, event: str, details: list[KeyValue]) -> AuditRecord:
|
|
return AuditRecord(event=event, at=datetime.now(UTC).isoformat(), case_id=case_id, details=details)
|
|
|
|
|
|
async def emit_audit(ctx: RunContext[NoAuth], case_id: str, event: str, details: list[KeyValue]) -> None:
|
|
await ctx.emit_event(AgentEvent(kind="audit", payload=audit_record(case_id, event, details).model_dump(mode="json")))
|
|
|
|
|
|
def dumps(value: Any) -> str:
|
|
return redact(json.dumps(value, indent=2, sort_keys=True, default=str))
|
|
|
|
|
|
async def write_case_file(ctx: RunContext[NoAuth], case_id: str, filename: str, content: str) -> ArtifactRecord:
|
|
rel_path = f"{OUTPUT_ROOT}/{safe_case(case_id)}/{safe_path(filename)}"
|
|
if safe_path(filename) != filename:
|
|
raise ValueError("artifact filename must be a plain relative filename")
|
|
data = redact(content).encode()
|
|
digest = hashlib.sha256(data).hexdigest()
|
|
artifact_uri: str | None = None
|
|
try:
|
|
view = await ctx.workspace.open_view(purpose=f"Persist support case artifact {filename}", hints=[case_id, filename], file_types=(), max_files=1, mode=WorkspaceMode.READ_WRITE_OVERLAY, reason="Persist generated support-to-fix engineering artifact under outputs/support-cases/{case_id}/")
|
|
await view.write(rel_path, data)
|
|
except Exception as exc:
|
|
await ctx.emit_event(AgentEvent(kind="workspace_write_warning", payload={"path": rel_path, "message": redact(str(exc))[:500]}))
|
|
try:
|
|
ref = await ctx.write_artifact(rel_path.replace("/", "__"), data, mime(filename))
|
|
await ctx.emit_artifact(ref)
|
|
artifact_uri = ref.uri
|
|
except Exception as exc:
|
|
await ctx.emit_event(AgentEvent(kind="artifact_warning", payload={"path": rel_path, "message": redact(str(exc))[:500]}))
|
|
return ArtifactRecord(path=rel_path, artifact_uri=artifact_uri, sha256=digest, size_bytes=len(data))
|
|
|
|
|
|
def mime(filename: str) -> str:
|
|
if filename.endswith(".json"):
|
|
return "application/json"
|
|
if filename.endswith(".md"):
|
|
return "text/markdown"
|
|
if filename.endswith(".diff") or filename.endswith(".patch"):
|
|
return "text/x-diff"
|
|
return "text/plain"
|