intentseal-core 0.1.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.
- intentseal_core/__init__.py +15 -0
- intentseal_core/__main__.py +5 -0
- intentseal_core/action_guard.py +174 -0
- intentseal_core/classifier.py +127 -0
- intentseal_core/cli.py +163 -0
- intentseal_core/config.py +185 -0
- intentseal_core/costs.py +35 -0
- intentseal_core/decision_model.py +144 -0
- intentseal_core/decode.py +92 -0
- intentseal_core/defaults/models.yaml +88 -0
- intentseal_core/defaults/policy-templates/blank.yaml +8 -0
- intentseal_core/defaults/policy-templates/coding-agent.yaml +14 -0
- intentseal_core/defaults/policy-templates/customer-support.yaml +16 -0
- intentseal_core/defaults/policy-templates/rag-assistant.yaml +27 -0
- intentseal_core/defaults/policy-templates/research-assistant.yaml +18 -0
- intentseal_core/defaults/providers.yaml +144 -0
- intentseal_core/engine.py +749 -0
- intentseal_core/extract.py +402 -0
- intentseal_core/llm/__init__.py +4 -0
- intentseal_core/llm/adapters.py +278 -0
- intentseal_core/llm/anthropic_adapter.py +294 -0
- intentseal_core/llm/router.py +449 -0
- intentseal_core/logger.py +245 -0
- intentseal_core/markers.py +29 -0
- intentseal_core/memory.py +157 -0
- intentseal_core/normalise.py +88 -0
- intentseal_core/outbound.py +76 -0
- intentseal_core/policy.py +156 -0
- intentseal_core/secrets.py +101 -0
- intentseal_core/session.py +151 -0
- intentseal_core/siem.py +252 -0
- intentseal_core/store.py +194 -0
- intentseal_core/taint.py +87 -0
- intentseal_core/types.py +64 -0
- intentseal_core-0.1.0.dist-info/METADATA +73 -0
- intentseal_core-0.1.0.dist-info/RECORD +41 -0
- intentseal_core-0.1.0.dist-info/WHEEL +5 -0
- intentseal_core-0.1.0.dist-info/entry_points.txt +2 -0
- intentseal_core-0.1.0.dist-info/licenses/LICENSE +202 -0
- intentseal_core-0.1.0.dist-info/licenses/NOTICE +4 -0
- intentseal_core-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""IntentSeal Core Engine."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def adopt_legacy_env() -> None:
|
|
9
|
+
"""Settings were named ASG_* before 8 Oct 2026 (D-038). An old name still works when the new one is unset."""
|
|
10
|
+
for name, value in list(os.environ.items()):
|
|
11
|
+
if name.startswith("ASG_"):
|
|
12
|
+
os.environ.setdefault("INTENTSEAL_" + name[4:], value)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
adopt_legacy_env()
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""CORE-9 Action guard: deterministic checks on every tool call before it runs.
|
|
2
|
+
|
|
3
|
+
Checks, in order (first hard violation wins; all observations are recorded):
|
|
4
|
+
1. tool allowed for this agent
|
|
5
|
+
2. pinned fields: values come from trusted session context, never from the model
|
|
6
|
+
3. outbound scan: canaries / secrets / registered values in any argument
|
|
7
|
+
4. recipient allow-list (send-type tools)
|
|
8
|
+
5. URL domain allow-list (fetch-type tools) - stops data leaving through a URL
|
|
9
|
+
6. path stays inside the sandbox (file tools)
|
|
10
|
+
7. provenance: high-risk target that appears only in untrusted content
|
|
11
|
+
8. session risk: high-risk tool while the session carries risk from untrusted content
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import fnmatch
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from email.utils import getaddresses
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from .outbound import scan_args
|
|
23
|
+
from .policy import AgentPolicy, ToolPolicy
|
|
24
|
+
from .session import Session, domain_of
|
|
25
|
+
from .types import ActionResult, Decision
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ActionCheck:
|
|
30
|
+
rule: str
|
|
31
|
+
decision: Decision
|
|
32
|
+
reason: str
|
|
33
|
+
labels: list[str] = field(default_factory=list)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _recipient_ok(addr: str, allowed: list[str]) -> bool:
|
|
37
|
+
a = addr.strip().lower()
|
|
38
|
+
if a.count("@") != 1 or not a.split("@")[0] or not a.split("@")[1]:
|
|
39
|
+
return False
|
|
40
|
+
for rule in allowed:
|
|
41
|
+
r = rule.lower()
|
|
42
|
+
if (r.startswith("@") and a.endswith(r)) or a == r:
|
|
43
|
+
return True
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def recipients(value: Any) -> list[str]:
|
|
48
|
+
"""Addresses a mailer would deliver to, parsed the way mail libraries parse them (display names, angle
|
|
49
|
+
brackets, comma/semicolon lists). A piece that does not parse to an address is kept as is, so it fails."""
|
|
50
|
+
raw = ", ".join(map(str, value)) if isinstance(value, (list, tuple)) else str(value)
|
|
51
|
+
out: list[str] = []
|
|
52
|
+
for piece in raw.replace(";", ",").split(","):
|
|
53
|
+
if not piece.strip():
|
|
54
|
+
continue
|
|
55
|
+
parsed = [a for _, a in getaddresses([piece])]
|
|
56
|
+
out += parsed if parsed and all(parsed) else [piece]
|
|
57
|
+
# getaddresses splits "Name <x@a> @b" into two entries; any leftover fragment must also pass.
|
|
58
|
+
for _, a in getaddresses([raw.replace(";", ",")]):
|
|
59
|
+
if a and a not in out:
|
|
60
|
+
out.append(a)
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _domain_ok(url: str, allowed: list[str]) -> bool:
|
|
65
|
+
d = domain_of(url)
|
|
66
|
+
if not d:
|
|
67
|
+
return False
|
|
68
|
+
return any(d == a.lower() or d.endswith("." + a.lower()) or fnmatch.fnmatch(d, a.lower()) for a in allowed)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _path_ok(path: str) -> bool:
|
|
72
|
+
p = path.replace("\\", "/")
|
|
73
|
+
return not (p.startswith("/") or ".." in p.split("/") or (len(p) > 1 and p[1] == ":"))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def check_action(tool: str, args: dict[str, Any], policy: AgentPolicy, session: Session,
|
|
77
|
+
registered_secrets: list[str] | None = None) -> ActionResult:
|
|
78
|
+
t0 = time.perf_counter()
|
|
79
|
+
args = dict(args or {})
|
|
80
|
+
checks: list[ActionCheck] = []
|
|
81
|
+
tp: ToolPolicy | None = policy.tools.get(tool)
|
|
82
|
+
|
|
83
|
+
if tp is None:
|
|
84
|
+
if policy.unknown_tools == "block":
|
|
85
|
+
checks.append(ActionCheck("tool-not-allowed", Decision.BLOCK, f"'{tool}' is not an allowed tool for this agent",
|
|
86
|
+
["Tool Abuse"]))
|
|
87
|
+
tp = ToolPolicy()
|
|
88
|
+
|
|
89
|
+
# 2. pinned fields (rewrite, never trust the model's value)
|
|
90
|
+
overrides: dict[str, Any] = {}
|
|
91
|
+
for f in tp.pinned:
|
|
92
|
+
if f in session.trusted:
|
|
93
|
+
if str(args.get(f, "")).strip() != str(session.trusted[f]).strip():
|
|
94
|
+
overrides[f] = {"requested": args.get(f), "pinned": session.trusted[f]}
|
|
95
|
+
args[f] = session.trusted[f]
|
|
96
|
+
if overrides:
|
|
97
|
+
checks.append(ActionCheck("pinned-field-override", Decision.ALLOW,
|
|
98
|
+
f"pinned field(s) {', '.join(overrides)} set from trusted context", ["Tool Abuse"]))
|
|
99
|
+
|
|
100
|
+
# 3. outbound secrets / canaries
|
|
101
|
+
hits = scan_args(args, policy.canary_values(), registered_secrets or [])
|
|
102
|
+
if hits:
|
|
103
|
+
kinds = sorted({h.kind for h in hits})
|
|
104
|
+
checks.append(ActionCheck("outbound-secret", Decision.BLOCK,
|
|
105
|
+
f"arguments contain protected data ({', '.join(kinds)}) in {', '.join(sorted({h.field for h in hits}))}",
|
|
106
|
+
["Secret Extraction", "Credential Theft"]))
|
|
107
|
+
|
|
108
|
+
# 4. recipients
|
|
109
|
+
if tp.allowed_recipients and tp.recipient_arg in args:
|
|
110
|
+
parsed = recipients(args[tp.recipient_arg])
|
|
111
|
+
bad = [r for r in parsed if not _recipient_ok(r, tp.allowed_recipients)]
|
|
112
|
+
if bad or not parsed:
|
|
113
|
+
checks.append(ActionCheck("recipient-not-allowed", Decision.BLOCK,
|
|
114
|
+
f"recipient outside allowed list ({len(bad)} address(es))", ["Tool Abuse"]))
|
|
115
|
+
|
|
116
|
+
# 5. URL domains
|
|
117
|
+
if tp.allowed_domains and tp.url_arg in args:
|
|
118
|
+
if not _domain_ok(str(args[tp.url_arg]), tp.allowed_domains):
|
|
119
|
+
checks.append(ActionCheck("domain-not-allowed", Decision.BLOCK,
|
|
120
|
+
f"URL domain '{domain_of(str(args[tp.url_arg]))}' is not approved", ["Tool Abuse"]))
|
|
121
|
+
|
|
122
|
+
# 6. sandbox paths
|
|
123
|
+
if tp.path_arg and tp.path_arg in args and not _path_ok(str(args[tp.path_arg])):
|
|
124
|
+
checks.append(ActionCheck("path-outside-sandbox", Decision.BLOCK, "path leaves the workspace", ["Tool Abuse"]))
|
|
125
|
+
|
|
126
|
+
# 7. provenance of high-risk targets
|
|
127
|
+
if tp.risk == "high":
|
|
128
|
+
for key in (tp.recipient_arg, tp.url_arg):
|
|
129
|
+
if key in args and session.from_untrusted_only(str(args[key])):
|
|
130
|
+
checks.append(ActionCheck("target-from-untrusted-content",
|
|
131
|
+
Decision.ESCALATE if tp.escalate_if_uncertain else Decision.BLOCK,
|
|
132
|
+
f"'{key}' value appears only in untrusted content, not in the user's request",
|
|
133
|
+
["Indirect Prompt Injection", "Tool Abuse"]))
|
|
134
|
+
|
|
135
|
+
# 8. session risk on high-risk tools
|
|
136
|
+
if tp.risk == "high" and session.risk >= policy.session.escalate_at:
|
|
137
|
+
hard = session.risk >= policy.session.block_at
|
|
138
|
+
checks.append(ActionCheck("high-risk-tool-in-risky-session", Decision.BLOCK if hard else Decision.ESCALATE,
|
|
139
|
+
f"session risk {session.risk:.2f} after untrusted content",
|
|
140
|
+
sorted(session.labels) or ["Multi-Step Jailbreaks"]))
|
|
141
|
+
|
|
142
|
+
# 8b. explicit intent (reported after the more specific rules above) (docs/13 L1): a share/destructive tool the user never asked for is held for approval
|
|
143
|
+
if tp.intent_terms and not session.user_asked_for(tp.intent_terms):
|
|
144
|
+
checks.append(ActionCheck("action-not-requested", Decision.ESCALATE,
|
|
145
|
+
f"the user's messages do not ask for this action ({', '.join(tp.intent_terms[:4])}…)",
|
|
146
|
+
["Tool Abuse"]))
|
|
147
|
+
|
|
148
|
+
# 9. data flow (CORE-17): where each argument's text came from; flagged content into a high-risk tool
|
|
149
|
+
taint = session.taint.trace(args, policy.taint.min_coverage) if policy.taint.enabled else []
|
|
150
|
+
flagged = sorted({h.arg for h in taint if h.flagged})
|
|
151
|
+
if tp.risk == "high" and flagged and policy.taint.flagged_to_high_risk in ("escalate", "block"):
|
|
152
|
+
checks.append(ActionCheck("tainted-from-flagged-content",
|
|
153
|
+
Decision.BLOCK if policy.taint.flagged_to_high_risk == "block" else Decision.ESCALATE,
|
|
154
|
+
f"argument(s) {', '.join(flagged)} copy text from content the gateway flagged",
|
|
155
|
+
["Indirect Prompt Injection", "Tool Abuse"]))
|
|
156
|
+
|
|
157
|
+
order = {Decision.BLOCK: 3, Decision.ESCALATE: 2, Decision.NEUTRALISE: 1, Decision.ALLOW: 0}
|
|
158
|
+
worst = max(checks, key=lambda c: order[c.decision], default=None)
|
|
159
|
+
decision = worst.decision if worst else Decision.ALLOW
|
|
160
|
+
enforced = decision if policy.mode == "enforce" else Decision.ALLOW
|
|
161
|
+
labels = sorted({l for c in checks for l in c.labels})
|
|
162
|
+
reason = "; ".join(c.reason for c in checks) or "within policy"
|
|
163
|
+
notice = None
|
|
164
|
+
if enforced == Decision.BLOCK:
|
|
165
|
+
notice = f"Action '{tool}' was blocked by the security policy: {worst.reason}. Continue the user's task without it."
|
|
166
|
+
elif enforced == Decision.ESCALATE:
|
|
167
|
+
notice = f"Action '{tool}' needs human approval ({worst.reason}). It was not performed; tell the user it is awaiting approval."
|
|
168
|
+
if decision != Decision.ALLOW:
|
|
169
|
+
session.add_risk(0.5 if decision == Decision.BLOCK else 0.3, labels)
|
|
170
|
+
return ActionResult(decision=decision, enforced=enforced,
|
|
171
|
+
final_args=args if enforced == Decision.ALLOW else None,
|
|
172
|
+
pinned_overrides=overrides, labels=labels, taint=[h.as_dict() for h in taint],
|
|
173
|
+
policy_rule=worst.rule if worst else "allow", reason=reason, notice=notice,
|
|
174
|
+
latency_ms=round((time.perf_counter() - t0) * 1000, 2))
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Tier 1 classifiers (CORE-4): cheap signals that decide what to escalate, never final decisions (D-011).
|
|
2
|
+
|
|
3
|
+
- LocalClassifier: protectai/deberta-v3-base-prompt-injection-v2 (Apache-2.0), ONNX on CPU, chunked.
|
|
4
|
+
- RemoteGuard: Llama Prompt Guard 2 via the router role `tier1_remote` (hosted, free tier).
|
|
5
|
+
Both cache by content hash (CC2).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from collections import OrderedDict
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .config import repo_root
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger("intentseal.tier1")
|
|
22
|
+
MODEL_REPO = os.environ.get("INTENTSEAL_TIER1_MODEL", "protectai/deberta-v3-base-prompt-injection-v2")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LocalClassifier:
|
|
26
|
+
def __init__(self, repo: str = MODEL_REPO, threads: int | None = None) -> None:
|
|
27
|
+
import numpy as np # noqa: F401 (import check)
|
|
28
|
+
import onnxruntime as ort
|
|
29
|
+
from huggingface_hub import snapshot_download
|
|
30
|
+
from tokenizers import Tokenizer
|
|
31
|
+
|
|
32
|
+
os.environ.setdefault("HF_HOME", str(repo_root() / "data" / "hf_cache"))
|
|
33
|
+
path = Path(snapshot_download(repo, allow_patterns=["onnx/*", "*.json"]))
|
|
34
|
+
tok = path / "onnx" / "tokenizer.json"
|
|
35
|
+
self.tok = Tokenizer.from_file(str(tok if tok.exists() else path / "tokenizer.json"))
|
|
36
|
+
so = ort.SessionOptions()
|
|
37
|
+
so.intra_op_num_threads = threads or int(os.environ.get("INTENTSEAL_TIER1_THREADS") or "2")
|
|
38
|
+
self.sess = ort.InferenceSession(str(path / "onnx" / "model.onnx"), so, providers=["CPUExecutionProvider"])
|
|
39
|
+
self.inputs = {i.name for i in self.sess.get_inputs()}
|
|
40
|
+
cfg = json.loads((path / "config.json").read_text())
|
|
41
|
+
self.pos = next(int(k) for k, v in cfg["id2label"].items() if "INJ" in v.upper())
|
|
42
|
+
self.cls, self.sep = self.tok.token_to_id("[CLS]"), self.tok.token_to_id("[SEP]")
|
|
43
|
+
self._lock = threading.Lock()
|
|
44
|
+
self._cache: OrderedDict[str, float] = OrderedDict() # bounded: CACHE_MAX entries
|
|
45
|
+
|
|
46
|
+
def score(self, text: str) -> float:
|
|
47
|
+
"""Max probability over windows of up to 256 tokens (shorter windows keep CPU latency low)."""
|
|
48
|
+
import numpy as np
|
|
49
|
+
|
|
50
|
+
key = hashlib.sha256(text.encode("utf-8", "ignore")).hexdigest()
|
|
51
|
+
if key in self._cache:
|
|
52
|
+
return self._cache[key]
|
|
53
|
+
ids = self.tok.encode(text, add_special_tokens=False).ids
|
|
54
|
+
best, window, stride = 0.0, 254, 200
|
|
55
|
+
with self._lock:
|
|
56
|
+
for start in range(0, max(1, len(ids)), stride):
|
|
57
|
+
chunk = [self.cls] + ids[start:start + window] + [self.sep]
|
|
58
|
+
feed = {"input_ids": np.array([chunk], dtype=np.int64),
|
|
59
|
+
"attention_mask": np.ones((1, len(chunk)), dtype=np.int64)}
|
|
60
|
+
if "token_type_ids" in self.inputs:
|
|
61
|
+
feed["token_type_ids"] = np.zeros((1, len(chunk)), dtype=np.int64)
|
|
62
|
+
logits = self.sess.run(None, feed)[0][0]
|
|
63
|
+
p = np.exp(logits - logits.max())
|
|
64
|
+
best = max(best, float((p / p.sum())[self.pos]))
|
|
65
|
+
if start + window >= len(ids):
|
|
66
|
+
break
|
|
67
|
+
self._cache[key] = best
|
|
68
|
+
while len(self._cache) > CACHE_MAX:
|
|
69
|
+
self._cache.popitem(last=False)
|
|
70
|
+
return best
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
CACHE_MAX = 20000
|
|
74
|
+
RETRY_S = 60.0
|
|
75
|
+
_loaded: LocalClassifier | None = None
|
|
76
|
+
_failure: tuple[float, str] | None = None # (time, error type) of the last failed load
|
|
77
|
+
_load_lock = threading.Lock()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def local_classifier() -> LocalClassifier | None:
|
|
81
|
+
"""The process-wide local classifier. A failed load is retried after RETRY_S (never cached for the life of the
|
|
82
|
+
process); while it is unavailable the engine sends content to the judge or holds it (fail closed)."""
|
|
83
|
+
global _loaded, _failure
|
|
84
|
+
if _loaded is not None:
|
|
85
|
+
return _loaded
|
|
86
|
+
with _load_lock:
|
|
87
|
+
if _loaded is None and (_failure is None or time.monotonic() - _failure[0] >= RETRY_S):
|
|
88
|
+
try:
|
|
89
|
+
_loaded, _failure = LocalClassifier(), None
|
|
90
|
+
except Exception as e: # missing model / runtime
|
|
91
|
+
_failure = (time.monotonic(), type(e).__name__)
|
|
92
|
+
log.warning("Tier 1 local classifier unavailable: %s", type(e).__name__)
|
|
93
|
+
return _loaded
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def tier1_status() -> dict[str, str | bool]:
|
|
97
|
+
"""For readiness checks: loaded, or the last load error."""
|
|
98
|
+
return {"loaded": _loaded is not None, "error": _failure[1] if _failure and _loaded is None else ""}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class RemoteGuard:
|
|
102
|
+
"""Prompt Guard 2 via the router (role `tier1_remote`). Returns None when unavailable."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, router, role: str = "tier1_remote") -> None:
|
|
105
|
+
self.router = router
|
|
106
|
+
self.role = role
|
|
107
|
+
self._cache: dict[str, float] = {}
|
|
108
|
+
|
|
109
|
+
async def score(self, text: str) -> float | None:
|
|
110
|
+
from .llm.adapters import ChatRequest
|
|
111
|
+
from .llm.router import AllProvidersFailed
|
|
112
|
+
|
|
113
|
+
if self.role not in self.router.settings.roles:
|
|
114
|
+
return None
|
|
115
|
+
key = hashlib.sha256(text.encode("utf-8", "ignore")).hexdigest()
|
|
116
|
+
if key in self._cache:
|
|
117
|
+
return self._cache[key]
|
|
118
|
+
try:
|
|
119
|
+
r = await self.router.chat(self.role, ChatRequest(messages=[{"role": "user", "content": text[:2000]}],
|
|
120
|
+
max_tokens=8, temperature=None))
|
|
121
|
+
val = float((r.content or "").strip())
|
|
122
|
+
except (AllProvidersFailed, ValueError):
|
|
123
|
+
return None
|
|
124
|
+
self._cache[key] = val
|
|
125
|
+
if len(self._cache) > CACHE_MAX:
|
|
126
|
+
self._cache.pop(next(iter(self._cache)))
|
|
127
|
+
return val
|
intentseal_core/cli.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Command line.
|
|
2
|
+
|
|
3
|
+
intentseal init [--agent my-agent] [--template research-assistant] [--mode enforce] [--home DIR]
|
|
4
|
+
write a starter policy (and a .env template) for the SDK's embedded mode
|
|
5
|
+
intentseal init --list the policy templates
|
|
6
|
+
intentseal providers check [--no-call] [--models]
|
|
7
|
+
providers, key *aliases* and health; one minimal test call per key (key values are never printed)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import asyncio
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import yaml
|
|
19
|
+
|
|
20
|
+
from .config import config_path, load_settings, repo_root
|
|
21
|
+
from .llm.adapters import ChatRequest, ProviderError
|
|
22
|
+
from .llm.router import Router
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _check_model(settings, provider: str) -> str | None:
|
|
26
|
+
p = settings.providers[provider]
|
|
27
|
+
if p.options.get("check_model"):
|
|
28
|
+
return str(p.options["check_model"])
|
|
29
|
+
for role in settings.roles.values():
|
|
30
|
+
for m in role.models:
|
|
31
|
+
if m.provider == provider:
|
|
32
|
+
return m.model
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def providers_check(call: bool, show_models: bool) -> int:
|
|
37
|
+
settings = load_settings()
|
|
38
|
+
router = Router(settings)
|
|
39
|
+
bad = 0
|
|
40
|
+
try:
|
|
41
|
+
configured = {(m.provider, m.model) for r in settings.roles.values() for m in r.models}
|
|
42
|
+
for name, p in sorted(settings.providers.items(), key=lambda kv: kv[1].priority):
|
|
43
|
+
aliases = ", ".join(k.alias for k in p.keys) or "-"
|
|
44
|
+
print(f"\n[{name}] type={p.type} priority={p.priority} keys={len(p.keys)} ({aliases})")
|
|
45
|
+
print(f" status: {router.provider_status(name)}")
|
|
46
|
+
if not p.active:
|
|
47
|
+
continue
|
|
48
|
+
if show_models:
|
|
49
|
+
try:
|
|
50
|
+
ids = await router.list_models(name)
|
|
51
|
+
print(f" models available: {len(ids)}")
|
|
52
|
+
for prov, model in sorted(configured):
|
|
53
|
+
if prov == name:
|
|
54
|
+
found = model in ids or f"models/{model}" in ids
|
|
55
|
+
print(f" configured {model}: {'FOUND' if found else 'NOT LISTED'}")
|
|
56
|
+
except ProviderError as e:
|
|
57
|
+
print(f" models: error {e.kind}")
|
|
58
|
+
if not call:
|
|
59
|
+
continue
|
|
60
|
+
model = _check_model(settings, name)
|
|
61
|
+
if not model:
|
|
62
|
+
print(" test call: skipped (no model configured for this provider)")
|
|
63
|
+
continue
|
|
64
|
+
for key in p.keys:
|
|
65
|
+
req = ChatRequest(messages=[{"role": "user", "content": "Reply with the single word: ok"}],
|
|
66
|
+
max_tokens=64, temperature=0)
|
|
67
|
+
try:
|
|
68
|
+
r = await router.call_direct(name, model, req, key=key)
|
|
69
|
+
text = (r.content or "").strip().replace("\n", " ")[:20]
|
|
70
|
+
print(f" {key.alias}: OK {r.latency_ms:.0f} ms model={model} reply='{text}'")
|
|
71
|
+
except ProviderError as e:
|
|
72
|
+
bad += 1
|
|
73
|
+
print(f" {key.alias}: FAIL {e.kind} (HTTP {e.http_status}) model={model}")
|
|
74
|
+
finally:
|
|
75
|
+
await router.aclose()
|
|
76
|
+
return 1 if bad else 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
ENV_TEMPLATE = """# IntentSeal settings (read from this folder and from your project's .env). Keys are never printed or logged.
|
|
80
|
+
# The AI judge (Tier 2) uses free-tier providers by default; set at least one key:
|
|
81
|
+
GROQ_API_KEY=
|
|
82
|
+
GEMINI_API_KEY=
|
|
83
|
+
NVIDIA_API_KEY=
|
|
84
|
+
OPENROUTER_API_KEY=
|
|
85
|
+
# Values that must never leave through a tool argument (comma-separated), e.g. a test canary:
|
|
86
|
+
# INTENTSEAL_CANARIES=
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def init(agent: str, template: str, mode: str, home: str | None, list_only: bool) -> int:
|
|
91
|
+
templates = {f.stem: f for f in sorted(config_path("policy-templates").glob("*.yaml"))}
|
|
92
|
+
if list_only:
|
|
93
|
+
for name, f in templates.items():
|
|
94
|
+
data = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
|
|
95
|
+
print(f"{name:20} {data.get('description', '')}")
|
|
96
|
+
return 0
|
|
97
|
+
if template not in templates:
|
|
98
|
+
print(f"unknown template '{template}'; choose one of: {', '.join(templates)}", file=sys.stderr)
|
|
99
|
+
return 2
|
|
100
|
+
from .policy import AgentPolicy, policy_path, save_policy
|
|
101
|
+
|
|
102
|
+
if home:
|
|
103
|
+
os.environ["INTENTSEAL_HOME"] = str(Path(home).resolve())
|
|
104
|
+
root = repo_root()
|
|
105
|
+
raw = dict((yaml.safe_load(templates[template].read_text(encoding="utf-8")) or {}).get("policy") or {})
|
|
106
|
+
raw.update({"agent": agent, "mode": mode})
|
|
107
|
+
try:
|
|
108
|
+
policy = AgentPolicy.model_validate(raw)
|
|
109
|
+
target = policy_path(agent)
|
|
110
|
+
except ValueError as e:
|
|
111
|
+
print(f"invalid agent id or policy: {str(e)[:200]}", file=sys.stderr)
|
|
112
|
+
return 2
|
|
113
|
+
if target.exists():
|
|
114
|
+
print(f"{target} already exists; edit it or choose another --agent")
|
|
115
|
+
return 1
|
|
116
|
+
path = save_policy(policy)
|
|
117
|
+
env = root / ".env"
|
|
118
|
+
if not env.exists():
|
|
119
|
+
env.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
env.write_text(ENV_TEMPLATE, encoding="utf-8")
|
|
121
|
+
print(f"policy {path} ({template}, {mode} mode, tools: {', '.join(policy.tools) or 'none'})")
|
|
122
|
+
print(f"keys {env} (add at least one provider key for the AI judge)")
|
|
123
|
+
print(f"""
|
|
124
|
+
Next, in your agent:
|
|
125
|
+
|
|
126
|
+
from intentseal import Guard
|
|
127
|
+
guard = Guard("{agent}") # embedded: pip install "intentseal[embedded]"
|
|
128
|
+
# guard = Guard("{agent}", remote="https://<gateway>", api_key=...) # or a shared gateway
|
|
129
|
+
|
|
130
|
+
with guard.session(user_id="u1", task=user_message):
|
|
131
|
+
text = guard.inspect(page_html, source="web") # cleaned content, or a safe notice
|
|
132
|
+
|
|
133
|
+
@guard.tool() # checked before it runs
|
|
134
|
+
def {next(iter(policy.tools), 'send_message')}(...): ...
|
|
135
|
+
|
|
136
|
+
Rename the policy's tools to your agent's tool names: any tool not listed is blocked in enforce mode.""")
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def main(argv: list[str] | None = None) -> int:
|
|
141
|
+
ap = argparse.ArgumentParser(prog="intentseal")
|
|
142
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
143
|
+
pr = sub.add_parser("providers", help="provider operations")
|
|
144
|
+
prs = pr.add_subparsers(dest="sub", required=True)
|
|
145
|
+
chk = prs.add_parser("check", help="list providers, key aliases, health; test each key")
|
|
146
|
+
chk.add_argument("--no-call", action="store_true", help="do not make test calls")
|
|
147
|
+
chk.add_argument("--models", action="store_true", help="verify configured model IDs against /models")
|
|
148
|
+
ini = sub.add_parser("init", help="write a starter policy and a .env template")
|
|
149
|
+
ini.add_argument("--agent", default="my-agent", help="agent id (letters, digits, '.', '_' or '-')")
|
|
150
|
+
ini.add_argument("--template", default="research-assistant", help="policy template (see --list)")
|
|
151
|
+
ini.add_argument("--mode", default="enforce", choices=["enforce", "monitor"])
|
|
152
|
+
ini.add_argument("--home", help="folder for policies and settings (default: INTENTSEAL_HOME or ~/.intentseal)")
|
|
153
|
+
ini.add_argument("--list", action="store_true", help="list the templates and exit")
|
|
154
|
+
args = ap.parse_args(argv)
|
|
155
|
+
if args.cmd == "init":
|
|
156
|
+
return init(args.agent, args.template, args.mode, args.home, args.list)
|
|
157
|
+
if args.cmd == "providers" and args.sub == "check":
|
|
158
|
+
return asyncio.run(providers_check(call=not args.no_call, show_models=args.models))
|
|
159
|
+
return 2
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
if __name__ == "__main__":
|
|
163
|
+
sys.exit(main())
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Configuration loading. Providers and models come from YAML; keys and paths from the environment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
from dotenv import load_dotenv
|
|
12
|
+
|
|
13
|
+
from .secrets import ApiKey, load_keys
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
DEFAULTS = Path(__file__).resolve().parent / "defaults" # bundled config, policy templates, starter policy
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def repo_root() -> Path:
|
|
20
|
+
"""Where IntentSeal keeps its configuration and data: INTENTSEAL_HOME, else the source checkout this package runs
|
|
21
|
+
from (development), else a per-user folder (~/.intentseal) for an installed package."""
|
|
22
|
+
env = os.environ.get("INTENTSEAL_HOME")
|
|
23
|
+
if env:
|
|
24
|
+
return Path(env)
|
|
25
|
+
here = Path(__file__).resolve()
|
|
26
|
+
for parent in here.parents:
|
|
27
|
+
if (parent / "config" / "providers.yaml").exists():
|
|
28
|
+
return parent
|
|
29
|
+
return Path.home() / ".intentseal"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def config_path(*parts: str) -> Path:
|
|
33
|
+
"""A configuration file or folder: the home's own copy if there is one, else the copy bundled with the package."""
|
|
34
|
+
own = repo_root().joinpath("config", *parts)
|
|
35
|
+
return own if own.exists() else DEFAULTS.joinpath(*parts)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_env() -> None:
|
|
39
|
+
load_dotenv(repo_root() / ".env", override=False)
|
|
40
|
+
load_dotenv(Path.cwd() / ".env", override=False) # an installed package: the project's own .env
|
|
41
|
+
from . import adopt_legacy_env
|
|
42
|
+
|
|
43
|
+
adopt_legacy_env()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class ProviderConfig:
|
|
48
|
+
name: str
|
|
49
|
+
type: str
|
|
50
|
+
enabled: bool
|
|
51
|
+
base_url: str
|
|
52
|
+
keys: list[ApiKey]
|
|
53
|
+
priority: int = 50
|
|
54
|
+
timeout_s: float = 30
|
|
55
|
+
connect_timeout_s: float = 5
|
|
56
|
+
retries: int = 1
|
|
57
|
+
backoff_base_s: float = 1.0
|
|
58
|
+
backoff_max_s: float = 60
|
|
59
|
+
circuit_failures: int = 3
|
|
60
|
+
circuit_open_s: float = 60
|
|
61
|
+
key_rotation: str = "on_auth_failure"
|
|
62
|
+
rpm: float | None = None
|
|
63
|
+
tpm: float | None = None # tokens per minute, per model (provider limits are per model)
|
|
64
|
+
daily_token_budget: int | None = None
|
|
65
|
+
extra_headers: dict[str, str] = field(default_factory=dict)
|
|
66
|
+
options: dict[str, Any] = field(default_factory=dict)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def active(self) -> bool:
|
|
70
|
+
return self.enabled and bool(self.keys)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def disabled_reason(self) -> str | None:
|
|
74
|
+
if not self.enabled:
|
|
75
|
+
return "disabled in config"
|
|
76
|
+
if not self.keys:
|
|
77
|
+
return "no keys in environment"
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class ModelRef:
|
|
83
|
+
provider: str
|
|
84
|
+
model: str
|
|
85
|
+
price_in: float = 0.0 # USD per million input tokens (list price, for reporting)
|
|
86
|
+
price_out: float = 0.0
|
|
87
|
+
params: dict[str, Any] = field(default_factory=dict)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class RoleConfig:
|
|
92
|
+
name: str
|
|
93
|
+
models: list[ModelRef]
|
|
94
|
+
cache: bool = True
|
|
95
|
+
cache_ttl_s: int = 86400
|
|
96
|
+
defaults: dict[str, Any] = field(default_factory=dict)
|
|
97
|
+
wait_on_rate_limit_s: float = 0.0 # >0: wait out a provider cooldown (up to this long) instead of failing
|
|
98
|
+
max_queue_s: float | None = None # longest client-side throttle wait before failing over (latency-sensitive roles)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class Settings:
|
|
103
|
+
providers: dict[str, ProviderConfig]
|
|
104
|
+
roles: dict[str, RoleConfig]
|
|
105
|
+
db_path: Path | str # SQLite file, or a postgresql:// URL (INTENTSEAL_DATABASE_URL)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _read_yaml(path: Path) -> dict[str, Any]:
|
|
109
|
+
with open(path, encoding="utf-8") as f:
|
|
110
|
+
return yaml.safe_load(f) or {}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def load_providers(path: Path | None = None) -> dict[str, ProviderConfig]:
|
|
114
|
+
path = path or config_path("providers.yaml")
|
|
115
|
+
raw = _read_yaml(path)
|
|
116
|
+
defaults = raw.get("defaults", {}) or {}
|
|
117
|
+
out: dict[str, ProviderConfig] = {}
|
|
118
|
+
for name, p in (raw.get("providers") or {}).items():
|
|
119
|
+
merged = {**defaults, **p}
|
|
120
|
+
keys_cfg = merged.get("keys") or {}
|
|
121
|
+
limits = merged.get("limits") or {}
|
|
122
|
+
budget = merged.get("budget") or {}
|
|
123
|
+
known = {
|
|
124
|
+
"type", "enabled", "base_url", "keys", "priority", "timeout_s", "connect_timeout_s",
|
|
125
|
+
"retries", "backoff_base_s", "backoff_max_s", "circuit_failures", "circuit_open_s",
|
|
126
|
+
"key_rotation", "limits", "budget", "extra_headers", "no_auth",
|
|
127
|
+
}
|
|
128
|
+
out[name] = ProviderConfig(
|
|
129
|
+
name=name,
|
|
130
|
+
type=merged.get("type", "openai_compatible"),
|
|
131
|
+
enabled=bool(merged.get("enabled", True)),
|
|
132
|
+
base_url=str(merged.get("base_url", "")).rstrip("/"),
|
|
133
|
+
# `no_auth: true` (local servers such as Ollama or vLLM): one empty key, so the provider is usable.
|
|
134
|
+
keys=([ApiKey(f"{name}#local", "")] if merged.get("no_auth")
|
|
135
|
+
else load_keys(name, keys_cfg.get("env_prefix"), keys_cfg.get("env_list"))),
|
|
136
|
+
priority=int(merged.get("priority", 50)),
|
|
137
|
+
timeout_s=float(merged.get("timeout_s", 30)),
|
|
138
|
+
connect_timeout_s=float(merged.get("connect_timeout_s", 5)),
|
|
139
|
+
retries=int(merged.get("retries", 1)),
|
|
140
|
+
backoff_base_s=float(merged.get("backoff_base_s", 1.0)),
|
|
141
|
+
backoff_max_s=float(merged.get("backoff_max_s", 60)),
|
|
142
|
+
circuit_failures=int(merged.get("circuit_failures", 3)),
|
|
143
|
+
circuit_open_s=float(merged.get("circuit_open_s", 60)),
|
|
144
|
+
key_rotation=str(merged.get("key_rotation", "on_auth_failure")),
|
|
145
|
+
rpm=limits.get("rpm"),
|
|
146
|
+
tpm=limits.get("tpm"),
|
|
147
|
+
daily_token_budget=budget.get("daily_tokens"),
|
|
148
|
+
extra_headers=dict(merged.get("extra_headers") or {}),
|
|
149
|
+
options={k: v for k, v in merged.items() if k not in known},
|
|
150
|
+
)
|
|
151
|
+
return out
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def load_roles(path: Path | None = None) -> dict[str, RoleConfig]:
|
|
155
|
+
path = path or config_path("models.yaml")
|
|
156
|
+
raw = _read_yaml(path)
|
|
157
|
+
out: dict[str, RoleConfig] = {}
|
|
158
|
+
for name, r in (raw.get("roles") or {}).items():
|
|
159
|
+
models = [
|
|
160
|
+
ModelRef(
|
|
161
|
+
provider=m["provider"],
|
|
162
|
+
model=m["model"],
|
|
163
|
+
price_in=float((m.get("price_per_mtok") or {}).get("input", 0.0)),
|
|
164
|
+
price_out=float((m.get("price_per_mtok") or {}).get("output", 0.0)),
|
|
165
|
+
params=dict(m.get("params") or {}),
|
|
166
|
+
)
|
|
167
|
+
for m in r.get("models", [])
|
|
168
|
+
]
|
|
169
|
+
out[name] = RoleConfig(
|
|
170
|
+
name=name,
|
|
171
|
+
models=models,
|
|
172
|
+
cache=bool(r.get("cache", True)),
|
|
173
|
+
cache_ttl_s=int(r.get("cache_ttl_s", 86400)),
|
|
174
|
+
defaults=dict(r.get("defaults") or {}),
|
|
175
|
+
wait_on_rate_limit_s=float(r.get("wait_on_rate_limit_s", 0.0)),
|
|
176
|
+
max_queue_s=r.get("max_queue_s"),
|
|
177
|
+
)
|
|
178
|
+
return out
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def load_settings() -> Settings:
|
|
182
|
+
load_env()
|
|
183
|
+
url = os.environ.get("INTENTSEAL_DATABASE_URL", "").strip()
|
|
184
|
+
db: Path | str = url or Path(os.environ.get("INTENTSEAL_DB_PATH") or repo_root() / "data" / "intentseal.db")
|
|
185
|
+
return Settings(providers=load_providers(), roles=load_roles(), db_path=db)
|