Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
.git
.github
.planning
.env
.coverage
.venv
__pycache__
.pytest_cache
.mypy_cache
.ruff_cache
*.py[cod]
tests
docs

1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ Dockerfile text eol=lf
*.md text eol=lf
*.toml text eol=lf
*.ps1 text eol=crlf
tests/contracts/cas-contracts/v0.1.0/*.json -text -diff
5 changes: 3 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ FROM python:3.12-slim AS runtime

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PORT=8080

WORKDIR /app
Expand All @@ -10,12 +11,12 @@ RUN addgroup --system app && adduser --system --ingroup app app

COPY pyproject.toml README.md ./
COPY src ./src
RUN pip install --no-cache-dir .
RUN pip install --no-cache-dir --no-compile .

USER app
EXPOSE 8080
STOPSIGNAL SIGTERM
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=2)"

CMD ["uvicorn", "cas_reference_product.app:app", "--host", "0.0.0.0", "--port", "8080"]

5 changes: 0 additions & 5 deletions deployment/cas-platform.interface.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,3 @@ spec:
- FOUNDRY_AGENT_NAME
deploymentInjected:
- APPLICATIONINSIGHTS_CONNECTION_STRING
outputsConsumed:
- workloadPrincipalId
- applicationInsightsId
- logAnalyticsWorkspaceId

10 changes: 8 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,18 @@ The Foundry call is isolated behind the `WorkflowAgentService` protocol. This ke

## Deployment Interface

`deployment/cas-platform.interface.yaml` records the contract: Linux AMD64 image, port 8080, internal ingress by default, system-assigned identity, probes, non-secret identifiers, and platform outputs. It does not deploy resources.
`deployment/cas-platform.interface.yaml` records the application contract: Linux AMD64 image, port
8080, internal ingress by default, system-assigned identity, probes, and configuration inputs. It does
not deploy resources.

The application consumes only the environment values listed in that interface. Platform resource IDs
and principal IDs remain deployment-orchestration outputs and are not application configuration.

## Observability Boundaries

- Incoming HTTP requests are instrumented by Azure Monitor OpenTelemetry when configured.
- `cas.workflow.execute` covers core orchestration.
- `foundry.responses.create` covers the external Foundry call.
- CAS correlation IDs are attached to workflow spans and canonical events preserve W3C trace context.

- Broad Azure SDK and outbound HTTP auto-instrumentation is disabled to avoid capturing prompt or
output content. The application records only explicit boundary spans and safe identifiers.
15 changes: 15 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,28 @@

Submit `examples/prompt-envelope.json` to `POST /api/v1/workflows`. Liveness is `/health/live`; readiness is `/health/ready`.

For a hardened local container run:

```powershell
docker run --rm --read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges `
-p 8080:8080 cas-reference-product:local
```

## Foundry Mode

Set `ENVIRONMENT` to `dev`, `test`, or `prod`; set `WORKFLOW_BACKEND=foundry`; provide the non-secret `FOUNDRY_PROJECT_ENDPOINT` and `FOUNDRY_AGENT_NAME`. The Azure-hosted workload uses its system-assigned managed identity. Do not configure API keys or client secrets.

Readiness fails until required Foundry identifiers are present. Foundry connectivity is exercised only by workflow requests, not probes.

When `APPLICATIONINSIGHTS_CONNECTION_STRING` is supplied, telemetry export also authenticates with
the environment credential. Grant the system-assigned identity the minimum Azure Monitor publishing
role required by the deployment. Retry-file storage and broad outbound HTTP/SDK auto-instrumentation
are disabled; explicit spans do not record prompt or output content.

## Platform Handoff

Build a Linux AMD64 image and pass its immutable image reference to the `containerImage` parameter of `cas-platform`. Review `deployment/cas-platform.interface.yaml` before platform changes. This repository intentionally contains no Azure deployment command.

The Docker build context excludes local `.env` files and development artifacts. The application does
not consume platform resource IDs; deployment orchestration retains those outputs for RBAC and
operations workflows.
4 changes: 3 additions & 1 deletion docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
| Credential disclosure | No keys or tokens in code; system-assigned `ManagedIdentityCredential` in Azure | Operators must maintain least-privilege RBAC |
| Legacy API use | Adapter uses project Responses client with `agent_reference`; no Classic Assistants code | SDK behavior must be reviewed during upgrades |
| Prompt injection | Workflow treats prompts as untrusted data and exposes no tools in v0.1 | Downstream agent policy remains product-specific |
| Sensitive telemetry | Events contain identifiers and status, not prompt text or agent output | Operators must review SDK and platform log settings |
| Sensitive telemetry | Explicit spans contain safe identifiers only; broad outbound HTTP/SDK auto-instrumentation and retry-file storage are disabled | Operators must review platform log settings |
| Unauthorized invocation | External ingress disabled by default in platform interface | Authentication gateway is product-specific and out of scope |
| Supply-chain compromise | Pinned CI actions, lint, tests, non-root container | Dependency update review remains required |
| Denial of service | Platform scaling bounds and request validation | Product-specific quotas and rate limits are not included |
Expand All @@ -30,3 +30,5 @@

Grant the Container App system-assigned identity only the minimum Foundry project role needed to invoke the selected agent, scoped to the narrowest resource. Do not assign subscription-wide roles.

When Application Insights export is enabled, also grant the minimum Azure Monitor publishing role
required by the deployment at the narrowest telemetry resource scope.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ dependencies = [
[project.optional-dependencies]
dev = [
"httpx>=0.28.0",
"jsonschema>=4.23.0",
"mypy>=1.14.0",
"pytest>=8.3.0",
"pytest-cov>=6.0.0",
Expand Down
7 changes: 5 additions & 2 deletions src/cas_reference_product/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from .config import Settings, get_settings
from .models import PromptEnvelope, WorkflowResult
from .telemetry import configure_telemetry
from .workflow import WorkflowOrchestrator, build_workflow_agent_service
from .workflow import WorkflowAgentServiceError, WorkflowOrchestrator, build_workflow_agent_service


def create_app(settings: Settings | None = None) -> FastAPI:
Expand Down Expand Up @@ -37,7 +37,10 @@ def execute(envelope: PromptEnvelope, request: Request) -> WorkflowResult:
raise HTTPException(status_code=503, detail="Workflow backend is not ready")
request.state.correlation_id = envelope.correlationId
orchestrator = WorkflowOrchestrator(service, app_settings.repository)
return orchestrator.execute(envelope)
try:
return orchestrator.execute(envelope)
except WorkflowAgentServiceError:
raise HTTPException(status_code=502, detail="Workflow backend request failed") from None

@app.get("/")
def root() -> dict[str, Any]:
Expand Down
17 changes: 14 additions & 3 deletions src/cas_reference_product/config.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import re
from functools import lru_cache
from typing import Literal

Expand All @@ -17,11 +18,21 @@ class Settings(BaseSettings):
applicationinsights_connection_string: str | None = Field(default=None, repr=False)

@property
def ready(self) -> bool:
return self.workflow_backend == "local" or bool(
self.foundry_project_endpoint and self.foundry_agent_name
def foundry_ready(self) -> bool:
return bool(
self.foundry_project_endpoint
and re.fullmatch(
r"https://[A-Za-z0-9.-]+\.services\.ai\.azure\.com/api/projects/[A-Za-z0-9_.-]+/?",
self.foundry_project_endpoint,
)
and self.foundry_agent_name
and self.foundry_agent_name.strip()
)

@property
def ready(self) -> bool:
return self.workflow_backend == "local" or self.foundry_ready


@lru_cache
def get_settings() -> Settings:
Expand Down
51 changes: 44 additions & 7 deletions src/cas_reference_product/models.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,42 @@
from datetime import datetime
from typing import Literal
from typing import Annotated, Any, Literal

from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator


def reject_explicit_null(data: Any, fields: tuple[str, ...]) -> Any:
if isinstance(data, dict):
null_fields = [field for field in fields if field in data and data[field] is None]
if null_fields:
raise ValueError(f"{', '.join(null_fields)} must be omitted instead of null")
return data


class Actor(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str = Field(min_length=1, max_length=256)
type: Literal["human", "agent", "service", "workflow"]
displayName: str | None = Field(default=None, min_length=1, max_length=256)
displayName: str | None = Field(
default=None, min_length=1, max_length=256, exclude_if=lambda value: value is None
)

@model_validator(mode="before")
@classmethod
def reject_null_display_name(cls, data: Any) -> Any:
return reject_explicit_null(data, ("displayName",))


class TraceContext(BaseModel):
model_config = ConfigDict(extra="forbid")
traceparent: str = Field(pattern=r"^[\da-f]{2}-[\da-f]{32}-[\da-f]{16}-[\da-f]{2}$")
tracestate: str | None = Field(default=None, max_length=512)
tracestate: str | None = Field(
default=None, max_length=512, exclude_if=lambda value: value is None
)

@model_validator(mode="before")
@classmethod
def reject_null_tracestate(cls, data: Any) -> Any:
return reject_explicit_null(data, ("tracestate",))


class LifecycleMetadata(BaseModel):
Expand All @@ -33,7 +55,16 @@ class PromptEnvelope(LifecycleMetadata):
kind: Literal["PromptEnvelope"] = "PromptEnvelope"
intent: str = Field(min_length=1, max_length=256)
prompt: str = Field(min_length=1, max_length=50_000)
constraints: list[str] = Field(default_factory=list)
constraints: list[Annotated[str, Field(min_length=1, max_length=1_000)]] = Field(
default_factory=list
)

@field_validator("constraints")
@classmethod
def constraints_must_be_unique(cls, constraints: list[str]) -> list[str]:
if len(constraints) != len(set(constraints)):
raise ValueError("constraints must contain unique values")
return constraints


class RunEvent(LifecycleMetadata):
Expand All @@ -42,11 +73,17 @@ class RunEvent(LifecycleMetadata):
eventType: str = Field(min_length=1, max_length=128)
sequence: int = Field(ge=0)
status: Literal["queued", "running", "succeeded", "failed", "cancelled"]
message: str | None = Field(default=None, max_length=5_000)
message: str | None = Field(
default=None, max_length=5_000, exclude_if=lambda value: value is None
)

@model_validator(mode="before")
@classmethod
def reject_null_message(cls, data: Any) -> Any:
return reject_explicit_null(data, ("message",))


class WorkflowResult(BaseModel):
runId: str
output: str
events: list[RunEvent]

14 changes: 11 additions & 3 deletions src/cas_reference_product/telemetry.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from opentelemetry import trace

from .config import Settings
from .identity import build_credential


def configure_telemetry(settings: Settings) -> None:
Expand All @@ -9,13 +10,20 @@ def configure_telemetry(settings: Settings) -> None:

configure_azure_monitor(
connection_string=settings.applicationinsights_connection_string,
credential=build_credential(settings.environment),
disable_offline_storage=True,
instrumentation_options={
"azure_sdk": {"enabled": False},
"requests": {"enabled": False},
"urllib": {"enabled": False},
"urllib3": {"enabled": False},
},
service_name=settings.app_name,
)


def current_traceparent() -> str:
def current_traceparent(fallback: str) -> str:
context = trace.get_current_span().get_span_context()
if context.is_valid:
return f"00-{context.trace_id:032x}-{context.span_id:016x}-01"
return "00-00000000000000000000000000000001-0000000000000001-00"

return fallback
35 changes: 26 additions & 9 deletions src/cas_reference_product/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ class WorkflowAgentService(Protocol):
def run(self, envelope: PromptEnvelope) -> str: ...


class WorkflowAgentServiceError(RuntimeError):
"""Stable application error raised when an external workflow backend fails."""


class LocalWorkflowAgentService:
def run(self, envelope: PromptEnvelope) -> str:
return (
Expand All @@ -32,20 +36,30 @@ class FoundryWorkflowAgentService:
"""Invoke a Foundry Next Gen agent reference through the project Responses client."""

def __init__(self, settings: Settings) -> None:
if not settings.foundry_project_endpoint or not settings.foundry_agent_name:
raise ValueError("Foundry backend requires project endpoint and agent name")
self._agent_name = settings.foundry_agent_name
endpoint = settings.foundry_project_endpoint
agent_name = settings.foundry_agent_name
if not settings.foundry_ready or endpoint is None or agent_name is None:
raise ValueError("Foundry backend requires a valid project endpoint and agent name")
self._agent_name = agent_name
self._client = AIProjectClient(
endpoint=settings.foundry_project_endpoint,
endpoint=endpoint,
credential=build_credential(settings.environment),
).get_openai_client()

def run(self, envelope: PromptEnvelope) -> str:
with tracer.start_as_current_span("foundry.responses.create"):
response = self._client.responses.create(
input=envelope.prompt,
extra_body={"agent": {"name": self._agent_name, "type": "agent_reference"}},
)
try:
response = self._client.responses.create(
input=envelope.prompt,
extra_body={
"agent_reference": {
"name": self._agent_name,
"type": "agent_reference",
}
},
)
except Exception:
raise WorkflowAgentServiceError("Foundry workflow invocation failed") from None
return response.output_text


Expand Down Expand Up @@ -92,14 +106,17 @@ def _event(
status: RunStatus,
message: str,
) -> RunEvent:
trace_context = {"traceparent": current_traceparent(envelope.traceContext.traceparent)}
if envelope.traceContext.tracestate is not None:
trace_context["tracestate"] = envelope.traceContext.tracestate
return RunEvent(
correlationId=envelope.correlationId,
promptId=envelope.promptId,
runId=envelope.runId,
repo=self._repository,
actor=Actor(id="cas-reference-workflow", type="workflow"),
timestamp=self._clock(),
traceContext=TraceContext(traceparent=current_traceparent()),
traceContext=TraceContext.model_validate(trace_context),
eventType=event_type,
sequence=sequence,
status=status,
Expand Down
31 changes: 31 additions & 0 deletions tests/contracts/cas-contracts/v0.1.0/artifact-manifest.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://schemas.coding-autopilot.dev/v0.1/artifact-manifest.schema.json",
"title": "ArtifactManifest",
"type": "object",
"allOf": [
{
"$ref": "common.schema.json#/$defs/lifecycleMetadata"
},
{
"type": "object",
"required": [
"kind",
"artifacts"
],
"properties": {
"kind": {
"const": "ArtifactManifest"
},
"artifacts": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "common.schema.json#/$defs/evidence"
}
}
}
}
],
"unevaluatedProperties": false
}
Loading