Skip to content
Open
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
2 changes: 1 addition & 1 deletion authbridge/sparc-service/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ COPY sparc_service ./sparc_service
RUN pip install --upgrade pip && pip install .

# Drop privileges.
RUN useradd --create-home --uid 10001 sparc
RUN useradd --create-home --uid 10001 sparc && chown -R sparc:sparc /app
USER sparc

EXPOSE 8090
Expand Down
7 changes: 6 additions & 1 deletion authbridge/sparc-service/deploy/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,12 @@ image: ## Build the sparc-service image locally and load it into kind
@echo "[*] building $(IMAGE)"
$(CONTAINER_RUNTIME) build -t $(IMAGE) ..
@echo "[*] kind load $(IMAGE) into $(KIND_CLUSTER_NAME)"
kind load docker-image $(IMAGE) --name $(KIND_CLUSTER_NAME)
@# kind load docker-image fails on Linux with rootful Podman — use image-archive instead.
if kind load docker-image $(IMAGE) --name $(KIND_CLUSTER_NAME) 2>/dev/null; then \
true; \
else \
$(CONTAINER_RUNTIME) save $(IMAGE) | kind load image-archive /dev/stdin --name $(KIND_CLUSTER_NAME); \
fi
@# Let containerd resolve the bare docker.io/library/<img> ref kubelet uses.
-$(CONTAINER_RUNTIME) exec $(KIND_NODE) ctr -n k8s.io images tag localhost/$(IMAGE) docker.io/library/$(IMAGE) >/dev/null 2>&1 || true

Expand Down
10 changes: 9 additions & 1 deletion authbridge/sparc-service/sparc_service/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,16 @@


def main() -> None:
import logging
import os
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s:%(name)s:%(message)s", datefmt="%Y-%m-%dT%H:%M:%SZ")
# Demote noisy third-party loggers — their INFO adds no operational value
logging.getLogger("LiteLLM").setLevel(logging.WARNING)
if os.getenv("SPARC_DEBUG_LLM", "").strip().lower() in ("1", "true", "yes"):
logging.getLogger("sparc_service.llm_debug").setLevel(logging.DEBUG)
logging.getLogger("altk").setLevel(logging.DEBUG)
settings = Settings.from_env()
uvicorn.run("sparc_service.api:app", host=settings.host, port=settings.port, log_level="info")
uvicorn.run("sparc_service.api:app", host=settings.host, port=settings.port, log_level="info", access_log=False)


if __name__ == "__main__":
Expand Down
66 changes: 66 additions & 0 deletions authbridge/sparc-service/sparc_service/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,18 @@
POST /reflect — run SPARC on a proposed tool call, return the verdict.
GET /healthz — liveness (always ok if the process is up).
GET /readyz — readiness (config valid and component buildable).

Log levels:
INFO — clean operational log: startup skip list + evaluated verdicts only.
DEBUG — adds per-call skip entries and full request payloads
(payloads only when SPARC_LOG_REQUESTS=true).
"""

from __future__ import annotations

import json
import logging
import os

from fastapi import FastAPI, HTTPException
from fastapi.concurrency import run_in_threadpool
Expand All @@ -19,6 +26,43 @@

log = logging.getLogger(__name__)

# SPARC_LOG_REQUESTS=true — log the full incoming ReflectRequest JSON at DEBUG.
# Useful for diagnosing unexpected tool argument keys. Disabled by default —
# payloads can be large. Requires LOG_LEVEL=DEBUG to be visible.
_LOG_REQUESTS: bool = os.getenv("SPARC_LOG_REQUESTS", "").strip().lower() in {"1", "true", "yes"}

# SPARC_STRIP_TOOL_ARG_KEYS — comma-separated keys to remove from every
# tool_calls[].function.arguments before SPARC evaluates the call.
# Example: SPARC_STRIP_TOOL_ARG_KEYS=session_id,request_id
_STRIP_KEYS: frozenset[str] = frozenset(
k.strip() for k in os.getenv("SPARC_STRIP_TOOL_ARG_KEYS", "").split(",") if k.strip()
)

# SPARC_SKIP_TOOLS — comma-separated tool names to auto-approve without SPARC.
# Use for infrastructure tools (e.g. message, calculate) that have no policy
# risk and would cause false-positive rejects.
# Example: SPARC_SKIP_TOOLS=message,calculate
_SKIP_TOOLS: frozenset[str] = frozenset(
t.strip() for t in os.getenv("SPARC_SKIP_TOOLS", "").split(",") if t.strip()
)


def _strip_tool_arg_keys(tool_calls: list[dict], keys: frozenset[str]) -> list[dict]:
"""Return a copy of tool_calls with the named argument keys removed."""
result = []
for tc in tool_calls:
fn = tc.get("function", {})
raw_args = fn.get("arguments", "")
try:
args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
if isinstance(args, dict):
args = {k: v for k, v in args.items() if k not in keys}
new_args = json.dumps(args) if isinstance(args, dict) else raw_args
except (json.JSONDecodeError, TypeError):
new_args = raw_args
result.append({**tc, "function": {**fn, "arguments": new_args}})
return result


def create_app(engine: ReflectionEngine | None = None) -> FastAPI:
"""Build the FastAPI app. Inject ``engine`` in tests; defaults to env config."""
Expand All @@ -33,6 +77,10 @@
app.state.engine = engine
app.state.settings = settings

# INFO: announce skip list once at startup so operators know what is bypassed
if _SKIP_TOOLS:
log.info("SPARC_SKIP_TOOLS: the following tools will be auto-approved without evaluation: %s", sorted(_SKIP_TOOLS))

@app.get("/healthz")
def healthz() -> dict[str, object]:
return {
Expand All @@ -51,6 +99,24 @@

@app.post("/reflect", response_model=ReflectResponse)
async def reflect(request: ReflectRequest) -> ReflectResponse:
# DEBUG: full request payload — only when SPARC_LOG_REQUESTS=true
if _LOG_REQUESTS:
log.debug("incoming reflect request: %s", request.model_dump_json())

if _STRIP_KEYS and request.tool_calls:
request = request.model_copy(
update={"tool_calls": _strip_tool_arg_keys(request.tool_calls, _STRIP_KEYS)}
)
if _LOG_REQUESTS:
log.debug("after strip (%s): tool_calls=%s", sorted(_STRIP_KEYS), request.tool_calls)

if _SKIP_TOOLS and request.tool_calls:
tool_name = request.tool_calls[0].get("function", {}).get("name", "")
if tool_name in _SKIP_TOOLS:
# DEBUG: per-call skip entry — visible only at DEBUG level
log.debug("reflect tool=%s skipped (SPARC_SKIP_TOOLS)", tool_name)

Check warning

Code scanning / CodeQL

Log Injection Medium

This log entry depends on a
user-provided value
.
This log entry depends on a
user-provided value
.
return ReflectResponse(decision="approve", issues=[], overall_avg_score=None, execution_time_ms=None)

# SPARCReflectionComponent.process is synchronous (and CPU/IO bound on the
# LLM call); run it off the event loop so the service stays responsive.
try:
Expand Down
46 changes: 41 additions & 5 deletions authbridge/sparc-service/sparc_service/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@

from __future__ import annotations

import json
import logging
import threading
from dataclasses import replace
from datetime import datetime, timezone
from typing import Any, Callable

from .models import ReflectionIssue, ReflectRequest, ReflectResponse
Expand Down Expand Up @@ -118,13 +120,47 @@ def reflect(self, request: ReflectRequest) -> ReflectResponse:
decision = _decision_str(reflection.decision)
score = _extract_overall_score(raw_pipeline)
execution_ms = getattr(output, "execution_time_ms", None)

# Extract tool name + args from the first tool call for correlation.
first_tc = request.tool_calls[0] if request.tool_calls else {}
fn = first_tc.get("function", {})
if not fn:
log.warning("reflect: tool_calls[0] has no 'function' key; tool correlation unavailable. call=%s", first_tc)
tool_name = fn.get("name", "-")
raw_args = fn.get("arguments", "{}")
try:
tool_args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
except (json.JSONDecodeError, TypeError):
tool_args = raw_args
try:
args_str = json.dumps(tool_args, separators=(",", ":"))
except (TypeError, ValueError):
args_str = repr(tool_args)

ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
score_str = f"{score:.2f}" if score is not None else "-"
ms_str = f"{execution_ms:.1f}" if isinstance(execution_ms, (int, float)) else "-"

def _tok(*keys: str) -> str:
for k in keys:
v = raw_pipeline.get(k)
if v is not None:
return str(v)
return "-"

log.info(
"reflect session=%s track=%s decision=%s score=%s ms=%s",
request.session_id or "-",
"reflect ts=%s tool=%s args=%s decision=%s score=%s ms=%s",
ts, tool_name, args_str, decision, score_str, ms_str,
)
log.debug(
"reflect ts=%s tool=%s args=%s decision=%s score=%s ms=%s"
" track=%s session=%s tokens_in=%s tokens_out=%s messages=%s",
ts, tool_name, args_str, decision, score_str, ms_str,
track,
decision,
f"{score:.2f}" if score is not None else "-",
f"{execution_ms:.1f}" if isinstance(execution_ms, (int, float)) else "-",
request.session_id or "-",
_tok("tokens_in", "input_tokens"),
_tok("tokens_out", "output_tokens"),
len(request.messages),
)

return ReflectResponse(
Expand Down
Loading
Loading