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.
- securevector_sdk_hermes/__init__.py +44 -0
- securevector_sdk_hermes/_version.py +7 -0
- securevector_sdk_hermes/auto.py +30 -0
- securevector_sdk_hermes/client.py +252 -0
- securevector_sdk_hermes/config.py +72 -0
- securevector_sdk_hermes/core.py +181 -0
- securevector_sdk_hermes/errors.py +27 -0
- securevector_sdk_hermes/plugin.py +214 -0
- securevector_sdk_hermes/tool_id.py +115 -0
- securevector_sdk_hermes-1.0.0.dist-info/METADATA +155 -0
- securevector_sdk_hermes-1.0.0.dist-info/RECORD +16 -0
- securevector_sdk_hermes-1.0.0.dist-info/WHEEL +5 -0
- securevector_sdk_hermes-1.0.0.dist-info/entry_points.txt +2 -0
- securevector_sdk_hermes-1.0.0.dist-info/licenses/LICENSE +202 -0
- securevector_sdk_hermes-1.0.0.dist-info/licenses/NOTICE +16 -0
- securevector_sdk_hermes-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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,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)."""
|