securevector-sdk-hermes 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,44 @@
1
+ """SecureVector SDK for Hermes (NousResearch ``hermes-agent``).
2
+
3
+ Zero-config (recommended) — the Hermes plugin manager auto-loads this
4
+ package's ``hermes_agent.plugins`` entry point on startup::
5
+
6
+ pip install securevector-sdk-hermes
7
+ # then just run `hermes` (or the gateway) as usual.
8
+ # Enforcement: export SECUREVECTOR_SDK_MODE=enforce
9
+
10
+ Programmatic / library embeddings (no plugin manager)::
11
+
12
+ from securevector_sdk_hermes import install
13
+ install(mode="enforce") # wraps Hermes's tool registry dispatch
14
+
15
+ Either way, every Hermes tool call — built-ins, MCP tools
16
+ (``mcp_<server>_<tool>``), plugin tools — runs the local SecureVector app's
17
+ three controls: tool-call permissions, secret/data-leak detection, and threat
18
+ detection. Each decision is written to the app's tamper-evident audit chain
19
+ with ``runtime_kind="hermes"``. Requires the SecureVector app running locally
20
+ (installed automatically as the ``securevector-ai-monitor`` dependency).
21
+ """
22
+
23
+ import logging
24
+
25
+ from ._version import __version__
26
+ from .config import Config
27
+ from .errors import AppUnreachable, SecureVectorError, ToolBlocked
28
+ from .plugin import install, register
29
+ from .tool_id import HERMES_BUILTINS, RUNTIME_KIND, candidate_tool_ids
30
+
31
+ log = logging.getLogger("securevector_sdk_hermes")
32
+
33
+ __all__ = [
34
+ "__version__",
35
+ "install",
36
+ "register",
37
+ "Config",
38
+ "RUNTIME_KIND",
39
+ "HERMES_BUILTINS",
40
+ "candidate_tool_ids",
41
+ "SecureVectorError",
42
+ "ToolBlocked",
43
+ "AppUnreachable",
44
+ ]
@@ -0,0 +1,7 @@
1
+ """Single source of truth for the package version at runtime.
2
+
3
+ The published version is stamped from pyproject.toml / the release tag by CI;
4
+ this constant is what `securevector_sdk_hermes.__version__` reports.
5
+ """
6
+
7
+ __version__ = "1.0.0"
@@ -0,0 +1,30 @@
1
+ """``import securevector_sdk_hermes.auto`` — one-line programmatic attach.
2
+
3
+ Under the Hermes CLI / gateway this module is unnecessary: the package's
4
+ ``hermes_agent.plugins`` entry point attaches the guard automatically at
5
+ startup. ``auto`` exists for library embeddings (driving ``AIAgent`` from your
6
+ own Python process) where the plugin manager never runs::
7
+
8
+ import securevector_sdk_hermes.auto # noqa: F401
9
+
10
+ Mode comes from ``SECUREVECTOR_SDK_MODE`` (default ``observe``). Best-effort:
11
+ when hermes-agent is not importable this logs a warning instead of raising, so
12
+ a stray import never breaks a process.
13
+ """
14
+
15
+ import logging
16
+ import os
17
+
18
+ from .plugin import install
19
+
20
+ log = logging.getLogger("securevector_sdk_hermes")
21
+
22
+ try:
23
+ install(mode=os.environ.get("SECUREVECTOR_SDK_MODE", "observe").strip().lower())
24
+ except ImportError as exc: # pragma: no cover - depends on hermes install
25
+ log.warning(
26
+ "SecureVector auto-attach skipped (%s). Under the hermes CLI the "
27
+ "entry-point plugin attaches automatically; for library use, call "
28
+ "securevector_sdk_hermes.install() after hermes-agent is importable.",
29
+ exc,
30
+ )
@@ -0,0 +1,252 @@
1
+ """Thin client to the local SecureVector app.
2
+
3
+ Two transports, on purpose:
4
+
5
+ * **Tool-permission resolution + audit** go over the local REST API
6
+ (``/api/tool-permissions/*``) via the stdlib (``urllib``) — no extra HTTP
7
+ dependency. The permission *decision* is computed client-side by merging the
8
+ three policy tiers exactly as the app's own OpenClaw hook does:
9
+ synced (cloud-pushed) > local override > essential registry > default-allow.
10
+ Hermes MCP names are lossy (``mcp_<server>_<tool>`` with underscores), so a
11
+ call is resolved against ALL of its candidate ids (raw name plus every
12
+ ``<server>:<tool>`` split) — tier precedence first, candidate order second.
13
+
14
+ * **Secret + threat detection** uses the running app's ``/analyze`` REST route
15
+ — the detection engine lives in the app; the adapter must not reimplement it.
16
+
17
+ Everything is best-effort and lazy: importing this module never requires the
18
+ app or hermes-agent to be installed, so unit tests run standalone.
19
+ """
20
+
21
+ import json
22
+ import logging
23
+ import urllib.error
24
+ import urllib.parse
25
+ import urllib.request
26
+ from dataclasses import dataclass
27
+ from typing import Any, Dict, List, Optional
28
+
29
+ from .config import Config
30
+ from .errors import AppUnreachable
31
+ from .tool_id import RUNTIME_KIND, candidate_tool_ids
32
+
33
+ log = logging.getLogger("securevector_sdk_hermes")
34
+
35
+ _VALID_AUDIT_ACTIONS = ("block", "allow", "log_only")
36
+
37
+
38
+ @dataclass
39
+ class Verdict:
40
+ """Resolved permission decision for one tool id."""
41
+
42
+ action: str # allow | block | log_only
43
+ risk: str
44
+ reason: str
45
+ is_essential: bool
46
+ tool_id: str
47
+
48
+
49
+ @dataclass
50
+ class AnalysisVerdict:
51
+ """Outcome of a secret/threat scan over a piece of text."""
52
+
53
+ is_threat: bool
54
+ risk_score: int
55
+ reason: str
56
+
57
+
58
+ class LocalAppClient:
59
+ def __init__(self, cfg: Config):
60
+ self.cfg = cfg
61
+
62
+ # ------------------------------------------------------------------ #
63
+ # REST transport (stdlib) #
64
+ # ------------------------------------------------------------------ #
65
+ def _request(self, method: str, path: str, body: Optional[dict]) -> Any:
66
+ url = f"{self.cfg.base_url.rstrip('/')}{path}"
67
+ data = json.dumps(body).encode("utf-8") if body is not None else None
68
+ headers = {"Content-Type": "application/json"}
69
+ # Forward a credential to remote, token-gated deployments (no-op for the
70
+ # default loopback app, which has no inbound auth).
71
+ if getattr(self.cfg, "api_key", ""):
72
+ headers["Authorization"] = f"Bearer {self.cfg.api_key}"
73
+ req = urllib.request.Request(
74
+ url,
75
+ data=data,
76
+ method=method,
77
+ headers=headers,
78
+ )
79
+ timeout = max(self.cfg.timeout_ms / 1000.0, 0.1)
80
+ with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 (localhost)
81
+ raw = resp.read()
82
+ return json.loads(raw) if raw else {}
83
+
84
+ def _get(self, path: str) -> Any:
85
+ return self._request("GET", path, None)
86
+
87
+ def _post(self, path: str, body: dict) -> Any:
88
+ return self._request("POST", path, body)
89
+
90
+ def reachable(self) -> bool:
91
+ try:
92
+ self._get("/api/tool-permissions/essential")
93
+ return True
94
+ except Exception:
95
+ return False
96
+
97
+ # ------------------------------------------------------------------ #
98
+ # (a) Permissions — synced > override > essential > default-allow #
99
+ # ------------------------------------------------------------------ #
100
+ def resolve_permission(self, tool_id: str) -> Verdict:
101
+ try:
102
+ essential = self._get("/api/tool-permissions/essential") or {}
103
+ overrides = self._get("/api/tool-permissions/overrides") or {}
104
+ synced = self._get(
105
+ "/api/tool-permissions/synced-overrides?"
106
+ + urllib.parse.urlencode({"runtime": RUNTIME_KIND})
107
+ ) or {}
108
+ except (urllib.error.URLError, OSError, json.JSONDecodeError) as exc:
109
+ raise AppUnreachable(str(exc)) from exc
110
+ return self._resolve(tool_id, essential, overrides, synced)
111
+
112
+ @staticmethod
113
+ def _index(arr: Optional[List[dict]], key: str) -> Dict[str, dict]:
114
+ """Index rows by their id, with a case-insensitive fallback (exact
115
+ casing wins, mirroring the app's lookup)."""
116
+ out: Dict[str, dict] = {}
117
+ for item in arr or []:
118
+ k = item.get(key)
119
+ if k is not None:
120
+ out.setdefault(str(k).lower(), item)
121
+ for item in arr or []:
122
+ k = item.get(key)
123
+ if k is not None:
124
+ out[str(k)] = item
125
+ return out
126
+
127
+ @staticmethod
128
+ def _lookup(index: Dict[str, dict], candidates: List[str]) -> Optional[dict]:
129
+ """First candidate that matches (exact casing, then lowercased)."""
130
+ for cand in candidates:
131
+ hit = index.get(cand) or index.get(cand.lower())
132
+ if hit:
133
+ return hit
134
+ return None
135
+
136
+ def _resolve(self, tool_id, essential, overrides, synced) -> Verdict:
137
+ name = tool_id
138
+ # Tier precedence dominates candidate specificity: a synced rule that
139
+ # matches ANY candidate beats an override that matches the raw name.
140
+ candidates = candidate_tool_ids(tool_id)
141
+ emap = self._index(essential.get("tools"), "tool_id")
142
+ omap = self._index(overrides.get("overrides"), "tool_id")
143
+ smap = self._index(synced.get("synced"), "tool_id")
144
+ in_essential = self._lookup(emap, candidates) is not None
145
+
146
+ # 1. Cloud-pushed synced policy wins.
147
+ s = self._lookup(smap, candidates)
148
+ if s:
149
+ effect = str(s.get("effect", "")).lower()
150
+ action = "allow" if effect == "allow" else "block"
151
+ policy = s.get("policy_name") or s.get("policy_id") or "synced"
152
+ ver = f" v{s['policy_version']}" if s.get("policy_version") is not None else ""
153
+ return Verdict(
154
+ action, "synced", f"Synced policy '{policy}'{ver}: {effect}",
155
+ in_essential, name,
156
+ )
157
+ # 2. Local user override.
158
+ o = self._lookup(omap, candidates)
159
+ if o:
160
+ return Verdict(
161
+ o.get("action", "allow"), "overridden",
162
+ f"User override: {o.get('action')}",
163
+ in_essential, name,
164
+ )
165
+ # 3. Essential registry default.
166
+ e = self._lookup(emap, candidates)
167
+ if e:
168
+ return Verdict(
169
+ e.get("effective_action") or e.get("default_action") or "allow",
170
+ e.get("risk", "unknown"), e.get("reason", "Essential tool policy"),
171
+ True, name,
172
+ )
173
+ # 4. Not in registry — allowed by default.
174
+ return Verdict("allow", "unknown", "Not in registry — allowed by default", False, name)
175
+
176
+ # ------------------------------------------------------------------ #
177
+ # (b)+(c) Secret + threat detection — the running app's /analyze #
178
+ # ------------------------------------------------------------------ #
179
+ # We deliberately use the app's REST `/analyze` (same engine, same HTTP
180
+ # transport as permissions/audit) rather than constructing an in-process
181
+ # SecureVectorClient: the SDK already requires the app running, and the
182
+ # in-process local analyzer needs its own config/license and can raise on
183
+ # init. Tool input is user→tool ("outgoing"); tool output is fetched
184
+ # context→model, which is exactly the app's IDPI "incoming" scan mode.
185
+ _DIRECTION_MODE = {"tool_input": "outgoing", "tool_output": "incoming"}
186
+
187
+ def analyze(self, text: str, direction: str) -> AnalysisVerdict:
188
+ if not text:
189
+ return AnalysisVerdict(False, 0, "empty")
190
+ wire_direction = self._DIRECTION_MODE.get(direction, "outgoing")
191
+ try:
192
+ # The analyze route is mounted at /analyze (no /api prefix). The
193
+ # request field is `direction` (AnalysisRequest.direction); `mode`
194
+ # is also sent for tolerance of older app builds, which ignore
195
+ # unknown fields either way.
196
+ res = self._post(
197
+ "/analyze",
198
+ {
199
+ "text": str(text)[:102400],
200
+ "direction": wire_direction,
201
+ "mode": wire_direction,
202
+ },
203
+ )
204
+ except (urllib.error.URLError, OSError, json.JSONDecodeError) as exc:
205
+ raise AppUnreachable(f"analyze failed: {exc}") from exc
206
+ if not isinstance(res, dict):
207
+ return AnalysisVerdict(False, 0, "no-result")
208
+ risk = int(res.get("risk_score") or 0)
209
+ is_threat = bool(res.get("is_threat", False))
210
+ # Secrets/data-leaks also surface via redaction: the app sets
211
+ # redacted_text (and action_taken=redact/block) when it catches a secret.
212
+ # Control (b) keys on that, control (c) on is_threat. A finding is either.
213
+ has_secret = bool(res.get("redacted_text")) or (res.get("action_taken") in ("redact", "block"))
214
+ finding = is_threat or has_secret
215
+ return AnalysisVerdict(
216
+ finding, risk,
217
+ f"{direction} threat={is_threat} secret={has_secret} risk={risk}",
218
+ )
219
+
220
+ # ------------------------------------------------------------------ #
221
+ # Audit — append to the tamper-evident chain #
222
+ # ------------------------------------------------------------------ #
223
+ def record_audit(
224
+ self,
225
+ *,
226
+ tool_id: str,
227
+ function_name: Optional[str],
228
+ action: str,
229
+ risk: Optional[str],
230
+ reason: Optional[str],
231
+ is_essential: bool,
232
+ args_preview: Optional[str],
233
+ session_id: Optional[str] = None,
234
+ request_id: Optional[str] = None,
235
+ ) -> None:
236
+ act = action if action in _VALID_AUDIT_ACTIONS else "log_only"
237
+ body = {
238
+ "tool_id": tool_id,
239
+ "function_name": function_name or tool_id,
240
+ "action": act,
241
+ "risk": risk,
242
+ "reason": reason,
243
+ "is_essential": bool(is_essential),
244
+ "args_preview": args_preview,
245
+ "runtime_kind": RUNTIME_KIND,
246
+ "session_id": session_id,
247
+ "request_id": (request_id or None) and str(request_id)[:64],
248
+ }
249
+ try:
250
+ self._post("/api/tool-permissions/call-audit", body)
251
+ except Exception as exc: # never let audit failure break the agent
252
+ log.debug("audit post failed: %s", exc)
@@ -0,0 +1,72 @@
1
+ """Adapter configuration — explicit kwargs override environment overrides
2
+ defaults.
3
+
4
+ Environment variables (all optional):
5
+ SECUREVECTOR_ENGINE_ENDPOINT local app / engine base URL — the unified
6
+ variable shared with the native-hook
7
+ plugins (default http://127.0.0.1:8741)
8
+ SECUREVECTOR_SDK_APP_URL legacy alias for the base URL, kept for
9
+ parity with the sibling framework SDKs;
10
+ SECUREVECTOR_ENGINE_ENDPOINT wins when
11
+ both are set
12
+ SECUREVECTOR_SDK_MODE observe | enforce (default observe)
13
+ SECUREVECTOR_SDK_TIMEOUT_MS per-call verdict timeout (default 3000)
14
+ SECUREVECTOR_SDK_RISK_THRESHOLD enforce-block risk cutoff (default 70)
15
+ SECUREVECTOR_SDK_DISABLED set truthy to no-op entirely
16
+ SECUREVECTOR_API_KEY credential forwarded to the app as
17
+ Authorization: Bearer — required when the
18
+ app is a remote, token-gated deployment
19
+ (e.g. the Terraform self-host modules);
20
+ unused for the default loopback app
21
+
22
+ Note: we deliberately do not read the existing SECUREVECTOR_URL — that points
23
+ at the *cloud* API in the rest of the ecosystem, whereas the SDK talks to the
24
+ *local* app.
25
+ """
26
+
27
+ import os
28
+ from dataclasses import dataclass
29
+
30
+ DEFAULT_BASE_URL = "http://127.0.0.1:8741"
31
+
32
+
33
+ def _truthy(val: str) -> bool:
34
+ return str(val).strip().lower() in ("1", "true", "yes", "on")
35
+
36
+
37
+ def _base_url_from_env() -> str:
38
+ return (
39
+ os.environ.get("SECUREVECTOR_ENGINE_ENDPOINT")
40
+ or os.environ.get("SECUREVECTOR_SDK_APP_URL")
41
+ or DEFAULT_BASE_URL
42
+ )
43
+
44
+
45
+ @dataclass
46
+ class Config:
47
+ base_url: str = DEFAULT_BASE_URL
48
+ mode: str = "observe" # observe (fail-open) | enforce (fail-closed)
49
+ timeout_ms: int = 3000
50
+ threat_risk_threshold: int = 70 # risk_score >= this blocks in enforce mode
51
+ enabled: bool = True
52
+ api_key: str = "" # forwarded as Authorization: Bearer to the app
53
+
54
+ @classmethod
55
+ def from_env(cls, **overrides) -> "Config":
56
+ cfg = cls(
57
+ base_url=_base_url_from_env(),
58
+ mode=os.environ.get("SECUREVECTOR_SDK_MODE", "observe").strip().lower(),
59
+ timeout_ms=int(os.environ.get("SECUREVECTOR_SDK_TIMEOUT_MS", "3000")),
60
+ threat_risk_threshold=int(
61
+ os.environ.get("SECUREVECTOR_SDK_RISK_THRESHOLD", "70")
62
+ ),
63
+ enabled=not _truthy(os.environ.get("SECUREVECTOR_SDK_DISABLED", "")),
64
+ api_key=os.environ.get("SECUREVECTOR_API_KEY", ""),
65
+ )
66
+ # Explicit kwargs win over env, but only when actually provided.
67
+ for key, value in overrides.items():
68
+ if value is not None and hasattr(cfg, key):
69
+ setattr(cfg, key, value)
70
+ if cfg.mode not in ("observe", "enforce"):
71
+ cfg.mode = "observe"
72
+ return cfg
@@ -0,0 +1,181 @@
1
+ """Control routing — the three checks on every tool call.
2
+
3
+ This is the framework-agnostic engine. Detection already exists in the app; the
4
+ job here is **routing + the observe/enforce state machine**. The public surface
5
+ is deliberately non-raising so each adapter can choose how to *act* on a block:
6
+
7
+ * the Hermes ``pre_tool_call`` plugin hook returns Hermes's documented block
8
+ directive (``{"action": "block", "message": ...}``);
9
+ * the ``install()`` dispatch wrap returns a registry-style error string
10
+ instead of executing the tool.
11
+
12
+ Per intercepted call, in order:
13
+
14
+ (a) PERMISSIONS — resolve allow/block for the tool id
15
+ (b) SECRET scan — \\
16
+ (c) THREAT scan — // over the serialized tool input (and, on end, output)
17
+
18
+ ``observe`` (default) is fail-open: everything is logged, nothing is blocked,
19
+ and an unreachable app degrades to allow. ``enforce`` is fail-closed: a policy
20
+ block, a high-risk input finding, or an unreachable app all mark the decision
21
+ ``blocked``.
22
+ """
23
+
24
+ import logging
25
+ import re
26
+ import sys
27
+ from dataclasses import dataclass
28
+ from typing import Optional
29
+
30
+ from .client import LocalAppClient
31
+ from .config import Config
32
+ from .errors import AppUnreachable, ToolBlocked
33
+
34
+ log = logging.getLogger("securevector_sdk_hermes")
35
+
36
+ # Belt-and-braces preview redaction (the app redacts too; this keeps obvious
37
+ # secrets out of the args_preview we send).
38
+ _REDACTIONS = [
39
+ (re.compile(r"AKIA[A-Z0-9]{16}"), "AKIA[REDACTED]"),
40
+ (re.compile(r"ghp_[A-Za-z0-9]{36}"), "ghp_[REDACTED]"),
41
+ (re.compile(r"sk-[A-Za-z0-9]{20,}"), "sk-[REDACTED]"),
42
+ (re.compile(r"(?i)(password\"?\s*[:=]\s*\")[^\"]+(\")"), r"\1[REDACTED]\2"),
43
+ ]
44
+
45
+
46
+ def redact(text: object, limit: int = 500) -> str:
47
+ if not text:
48
+ return ""
49
+ s = str(text)
50
+ for pattern, repl in _REDACTIONS:
51
+ s = pattern.sub(repl, s)
52
+ return s[:limit]
53
+
54
+
55
+ @dataclass
56
+ class Decision:
57
+ """Result of evaluating a tool call's input. ``blocked`` already accounts
58
+ for mode — it is only True when the call should actually be stopped (i.e.
59
+ enforce mode + a deny). ``action`` is the audited action."""
60
+
61
+ blocked: bool
62
+ action: str # allow | block | log_only
63
+ reason: str
64
+ risk: str
65
+
66
+
67
+ class Interceptor:
68
+ def __init__(self, cfg: Config, client: Optional[LocalAppClient] = None):
69
+ self.cfg = cfg
70
+ self.client = client or LocalAppClient(cfg)
71
+ self._disclosed = False
72
+
73
+ @property
74
+ def enforce(self) -> bool:
75
+ return self.cfg.mode == "enforce"
76
+
77
+ def _disclose_once(self) -> None:
78
+ if self.enforce and not self._disclosed:
79
+ self._disclosed = True
80
+ sys.stderr.write(
81
+ "[SecureVector] SDK is in ENFORCE mode — tool calls will be "
82
+ "BLOCKED if a policy denies them or the local app is unreachable.\n"
83
+ )
84
+
85
+ # ------------------------------------------------------------------ #
86
+ # Primary, non-raising API #
87
+ # ------------------------------------------------------------------ #
88
+ def evaluate_input(
89
+ self,
90
+ tool_id: str,
91
+ args_text: str,
92
+ *,
93
+ session_id: Optional[str] = None,
94
+ request_id: Optional[str] = None,
95
+ ) -> Decision:
96
+ """Run the three controls on a tool's input and return a Decision.
97
+ Records the audit row as a side effect. Never raises."""
98
+ if not self.cfg.enabled:
99
+ return Decision(False, "allow", "sdk disabled", "")
100
+ self._disclose_once()
101
+ preview = redact(args_text)
102
+
103
+ # (a) PERMISSIONS
104
+ try:
105
+ verdict = self.client.resolve_permission(tool_id)
106
+ except AppUnreachable:
107
+ if self.enforce:
108
+ return Decision(True, "block", "local app unreachable (fail-closed)", "unreachable")
109
+ log.warning("app unreachable; observe mode allows %s", tool_id)
110
+ return Decision(False, "allow", "app unreachable (observe, fail-open)", "unreachable")
111
+
112
+ if verdict.action == "block":
113
+ self.client.record_audit(
114
+ tool_id=tool_id, function_name=tool_id, action="block",
115
+ risk=verdict.risk, reason=verdict.reason,
116
+ is_essential=verdict.is_essential, args_preview=preview,
117
+ session_id=session_id, request_id=request_id,
118
+ )
119
+ return Decision(self.enforce, "block", verdict.reason, verdict.risk)
120
+
121
+ # (b)+(c) SECRET + THREAT on the tool input
122
+ a_in = None
123
+ try:
124
+ a_in = self.client.analyze(args_text, "tool_input")
125
+ except AppUnreachable:
126
+ a_in = None # analysis best-effort; permissions already passed
127
+
128
+ if a_in and a_in.is_threat:
129
+ should_block = self.enforce and a_in.risk_score >= self.cfg.threat_risk_threshold
130
+ act = "block" if should_block else "log_only"
131
+ self.client.record_audit(
132
+ tool_id=tool_id, function_name=tool_id, action=act,
133
+ risk=str(a_in.risk_score),
134
+ reason=f"Input secret/threat detected (risk={a_in.risk_score})",
135
+ is_essential=verdict.is_essential, args_preview=preview,
136
+ session_id=session_id, request_id=request_id,
137
+ )
138
+ return Decision(should_block, act, f"input secret/threat risk={a_in.risk_score}", str(a_in.risk_score))
139
+
140
+ # Allowed — record the decision.
141
+ self.client.record_audit(
142
+ tool_id=tool_id, function_name=tool_id, action="allow",
143
+ risk=verdict.risk, reason=verdict.reason,
144
+ is_essential=verdict.is_essential, args_preview=preview,
145
+ session_id=session_id, request_id=request_id,
146
+ )
147
+ return Decision(False, "allow", verdict.reason, verdict.risk)
148
+
149
+ def scan_output(
150
+ self,
151
+ tool_id: str,
152
+ output_text: str,
153
+ *,
154
+ session_id: Optional[str] = None,
155
+ request_id: Optional[str] = None,
156
+ ) -> None:
157
+ """Scan the tool RESULT for secrets / exfiltration (observe-only — the
158
+ tool already ran). Records a row if anything is found. Never raises."""
159
+ if not self.cfg.enabled:
160
+ return
161
+ try:
162
+ a_out = self.client.analyze(output_text, "tool_output")
163
+ except AppUnreachable:
164
+ return
165
+ if a_out and a_out.is_threat:
166
+ self.client.record_audit(
167
+ tool_id=tool_id, function_name=tool_id, action="log_only",
168
+ risk=str(a_out.risk_score),
169
+ reason=f"Output secret/threat detected (risk={a_out.risk_score})",
170
+ is_essential=False, args_preview=redact(output_text),
171
+ session_id=session_id, request_id=request_id,
172
+ )
173
+
174
+ # ------------------------------------------------------------------ #
175
+ # Raising convenience (adapters where blocking == raising) #
176
+ # ------------------------------------------------------------------ #
177
+ def guard_input(self, tool_id: str, args_text: str, **kwargs) -> Decision:
178
+ decision = self.evaluate_input(tool_id, args_text, **kwargs)
179
+ if decision.blocked:
180
+ raise ToolBlocked(tool_id, decision.reason)
181
+ return decision
@@ -0,0 +1,27 @@
1
+ """Exceptions raised by the SecureVector Hermes adapter.
2
+
3
+ ``ToolBlocked`` is available for callers using ``Interceptor.guard_input``
4
+ directly; the shipped Hermes attach paths never raise into the agent loop —
5
+ the plugin hook blocks via Hermes's own block directive and the ``install()``
6
+ dispatch wrap returns a registry-style error string. In ``observe`` mode
7
+ nothing is ever blocked — every call is logged and allowed through.
8
+ """
9
+
10
+
11
+ class SecureVectorError(Exception):
12
+ """Base class for all adapter errors."""
13
+
14
+
15
+ class ToolBlocked(SecureVectorError):
16
+ """Raised in enforce mode to abort a tool call (policy block, input threat,
17
+ or fail-closed when the local app is unreachable)."""
18
+
19
+ def __init__(self, tool_id: str, reason: str):
20
+ self.tool_id = tool_id
21
+ self.reason = reason
22
+ super().__init__(f"SecureVector blocked tool '{tool_id}': {reason}")
23
+
24
+
25
+ class AppUnreachable(SecureVectorError):
26
+ """The local SecureVector app could not be reached. Mode decides the
27
+ consequence: observe → allow (fail-open); enforce → deny (fail-closed)."""