edangx100's picture
Update app and control-plane modules to latest
fbe9dad
Raw
History Blame Contribute Delete
12.2 kB
"""Function-Call backend — the advisory/reasoning execution backend.
WHAT THIS IS
------------
This backend stands in for the "tool calling" that an agent framework normally
does with plain local functions. Where the Direct-API backend *changes things*
(it can create a ticket), this backend only **thinks out loud**: it looks things
up, scores risk, and drafts text for a human to read. It produces *analysis and
recommendations* — never an action against a real system.
* LOOK-UPS / SCORING — cve_lookup, risk_score (deterministic facts)
* DRAFTS — draft_remediation, (generated text, for a
draft_incident human to review)
* RECOMMENDATIONS — recommend_containment (suggested next steps;
it suggests, never does)
THE ONE PROPERTY THAT DEFINES THIS BACKEND: NO SIDE EFFECTS
-----------------------------------------------------------
Every handler here is **pure**: given the same arguments it returns the same
result, and it changes *nothing* — not the mock data, not any external system,
not even an internal store (unlike Direct-API, this backend has no writable
store at all). That is what lets the policy allow these actions at the low
"recommend-only" autonomy tier (L1): a recommendation to contain a host is just
words until a human acts on it. "recommend_containment" returns a *plan*; it
does not contain anything.
WHERE IT SITS IN THE TRUST MODEL (same as every backend)
--------------------------------------------------------
This backend never decides *whether* an action is allowed — that already
happened upstream. By the time ``run`` is called, the gate has said ``ALLOW``
and the dispatcher has verified the gate's signed grant.
gate decides ─► dispatcher verifies grant ─► THIS backend executes
It still keeps the same defense-in-depth guard as the other backends: an
**allow-list of handlers**. An action this backend doesn't know how to perform
(anything not in the table below) is refused, not guessed at.
"""
from __future__ import annotations
from typing import Any, Callable
import data
from control_plane.schema import ProposedAction
class FunctionBackendError(Exception):
"""Base error for anything this backend refuses or cannot do."""
class FunctionUnsupportedActionError(FunctionBackendError):
"""The requested action is not in this backend's allow-list of handlers."""
class FunctionRecordNotFoundError(FunctionBackendError):
"""A look-up referenced an ID that does not exist in the mock data."""
class FunctionMissingArgumentError(FunctionBackendError):
"""A required argument was absent from the action's ``arguments`` map."""
# How much an asset's business criticality amplifies a raw vulnerability score.
# Kept as a fixed table (not a formula) so risk scores are deterministic and easy
# to explain: the same CVE on a more critical asset scores higher, by a known amount.
_CRITICALITY_WEIGHT: dict[str, float] = {
"low": 0.6,
"medium": 0.8,
"high": 1.0,
}
class FunctionCallBackend:
"""Advisory backend satisfying the dispatcher's ``BackendAdapter`` contract.
Construct it once and let the dispatcher call :meth:`run` per authorized
action. It holds only **read-only** references to the mock data — there is
deliberately no mutable state here, because none of its actions may change
anything.
"""
def __init__(self) -> None:
# Read-only lookups by ID. Indexed once at construction for O(1) access.
# Nothing in this class ever writes back to these.
self._cves = data.index_by_id("cves")
self._assets = data.index_by_id("assets")
self._alerts = data.load_alerts() # scanned by incident_id when drafting
# The allow-list: action_name -> handler. Membership here is what makes an
# action executable by this backend; the names match the policy exactly
# (policies/agt_policy.yaml, function_call section). Anything not listed is
# refused by :meth:`run`.
self._handlers: dict[str, Callable[[dict[str, Any]], Any]] = {
# deterministic facts
"cve_lookup": self._cve_lookup,
"risk_score": self._risk_score,
# generated text for a human to review
"draft_remediation": self._draft_remediation,
"draft_incident": self._draft_incident,
# suggested next steps (advisory only)
"recommend_containment": self._recommend_containment,
}
# -- dispatcher entry point ------------------------------------------------
def run(self, action: ProposedAction) -> Any:
"""Execute *action* and return its advisory result.
Routes on ``action.action_name`` to a handler in the allow-list. An action
this backend doesn't handle raises :class:`FunctionUnsupportedActionError` — it is
never silently ignored or guessed at.
"""
handler = self._handlers.get(action.action_name)
if handler is None:
raise FunctionUnsupportedActionError(
f"function_call backend cannot perform {action.action_name!r} "
f"(not in its allow-list of advisory actions)"
)
return handler(action.arguments)
# -- look-ups / scoring (deterministic facts) ------------------------------
def _cve_lookup(self, args: dict[str, Any]) -> dict[str, Any]:
"""Return the mock CVE record for ``cve_id`` (a pure read of the fixtures)."""
cve_id = _require(args, "cve_id")
return _found(self._cves.get(cve_id), "cve", cve_id)
def _risk_score(self, args: dict[str, Any]) -> dict[str, Any]:
"""Score a CVE, optionally amplified by the criticality of a named asset.
The score is deterministic: it combines the CVE's published CVSS score
with a fixed per-asset weight (see :data:`_CRITICALITY_WEIGHT`). The same
inputs always yield the same score and band, which is what makes this
usable as an advisory signal a human can sanity-check.
"""
cve = self._cve_lookup(args) # reuses the look-up (and its not-found guard)
cvss = float(cve.get("cvss_score", 0.0))
# Asset is optional: if given, a more business-critical asset raises the
# effective risk; if absent, we score the raw vulnerability on its own.
asset_id = args.get("asset_id")
if asset_id:
asset = _found(self._assets.get(asset_id), "asset", asset_id)
criticality = asset.get("criticality", "medium")
weight = _CRITICALITY_WEIGHT.get(criticality, 0.8)
else:
criticality, weight = None, 1.0
score = round(cvss * weight, 1)
return {
"cve_id": cve["cve_id"],
"asset_id": asset_id,
"cvss_score": cvss,
"asset_criticality": criticality,
"risk_score": score,
"risk_band": _band(score),
"rationale": (
f"CVSS {cvss} weighted by asset criticality "
f"{criticality or 'n/a'}{weight}) → {score}"
),
}
# -- drafts (generated text, no side effects) ------------------------------
def _draft_remediation(self, args: dict[str, Any]) -> dict[str, Any]:
"""Draft a remediation write-up for a CVE — text only, nothing is applied.
This is the "L1 draft" action. It assembles human-readable
guidance from the mock CVE record; it does **not** patch, restart, or
change anything. The ``is_draft`` flag makes that explicit to any consumer.
"""
cve = self._cve_lookup(args)
content = (
f"Remediation draft for {cve['cve_id']}{cve['title']}\n"
f"Severity: {cve.get('severity', 'unknown')} "
f"(CVSS {cve.get('cvss_score', 'n/a')})\n\n"
f"Summary: {cve.get('summary', '')}\n\n"
f"Recommended steps: {cve.get('remediation', 'No guidance on file.')}\n\n"
f"NOTE: This is a draft for analyst review. No change has been applied."
)
return {
"kind": "remediation_draft",
"cve_id": cve["cve_id"],
"is_draft": True,
"content": content,
}
def _draft_incident(self, args: dict[str, Any]) -> dict[str, Any]:
"""Draft an incident summary from the alerts attached to an incident_id.
Pulls every mock alert sharing the incident_id and folds them into a short
narrative a human could paste into an incident record. Read-only: it never
creates or edits the incident itself.
"""
incident_id = _require(args, "incident_id")
alerts = [a for a in self._alerts if a.get("incident_id") == incident_id]
if not alerts:
raise FunctionRecordNotFoundError(f"no alerts found for incident {incident_id!r}")
lines = [f"Incident draft for {incident_id}", ""]
for alert in alerts:
lines.append(
f"- [{alert.get('severity', '?')}] {alert['alert_id']}: "
f"{alert.get('title', '')} (asset {alert.get('asset_id', 'n/a')})"
)
lines += ["", "NOTE: Draft for analyst review. The incident record is unchanged."]
return {
"kind": "incident_draft",
"incident_id": incident_id,
"alert_ids": [a["alert_id"] for a in alerts],
"is_draft": True,
"content": "\n".join(lines),
}
def _recommend_containment(self, args: dict[str, Any]) -> dict[str, Any]:
"""Recommend containment steps for an asset — a suggestion, not an action.
Returns an ordered list of *suggested* steps (e.g. isolate the host, stop a
suspicious container). Crucially this backend cannot perform any of them;
executing a containment step is a high-tier action that would have to go
back through the gate on a separate, approval-gated request.
"""
asset_id = _require(args, "asset_id")
asset = _found(self._assets.get(asset_id), "asset", asset_id)
steps = [f"Isolate host {asset.get('hostname', asset_id)} from the network."]
# If a specific container was named, recommend stopping just that one;
# otherwise call out any container the mock data already flags suspicious.
container_id = args.get("container_id")
suspicious = [
c for c in asset.get("containers", [])
if c.get("suspicious") and (container_id is None or c["container_id"] == container_id)
]
for c in suspicious:
steps.append(f"Stop suspicious container {c['name']} ({c['container_id']}).")
steps.append("Preserve volatile evidence (memory, logs) before remediation.")
return {
"kind": "containment_recommendation",
"asset_id": asset_id,
"is_recommendation": True, # advisory only — nothing here is executed
"steps": steps,
}
# Module-level helpers keep the handlers above short and uniform. (They mirror the
# small guards in the Direct-API backend; each backend keeps its own copy so the
# backends stay independent of one another.)
def _require(args: dict[str, Any], key: str) -> Any:
"""Return ``args[key]`` or raise a clear error naming the missing argument."""
if key not in args or args[key] in (None, ""):
raise FunctionMissingArgumentError(f"missing required argument {key!r}")
return args[key]
def _found(record: dict[str, Any] | None, kind: str, record_id: str) -> dict[str, Any]:
"""Return *record* if present, else raise a clear not-found error."""
if record is None:
raise FunctionRecordNotFoundError(f"no {kind} with id {record_id!r}")
return record
def _band(score: float) -> str:
"""Map a 0–10 risk score to a human-readable band (fixed, deterministic cut-offs)."""
if score >= 9.0:
return "critical"
if score >= 7.0:
return "high"
if score >= 4.0:
return "medium"
return "low"