Spaces:
Sleeping
Sleeping
File size: 12,192 Bytes
fbe9dad ebd50f7 fbe9dad ebd50f7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 | """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"
|