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.
Files changed (41) hide show
  1. intentseal_core/__init__.py +15 -0
  2. intentseal_core/__main__.py +5 -0
  3. intentseal_core/action_guard.py +174 -0
  4. intentseal_core/classifier.py +127 -0
  5. intentseal_core/cli.py +163 -0
  6. intentseal_core/config.py +185 -0
  7. intentseal_core/costs.py +35 -0
  8. intentseal_core/decision_model.py +144 -0
  9. intentseal_core/decode.py +92 -0
  10. intentseal_core/defaults/models.yaml +88 -0
  11. intentseal_core/defaults/policy-templates/blank.yaml +8 -0
  12. intentseal_core/defaults/policy-templates/coding-agent.yaml +14 -0
  13. intentseal_core/defaults/policy-templates/customer-support.yaml +16 -0
  14. intentseal_core/defaults/policy-templates/rag-assistant.yaml +27 -0
  15. intentseal_core/defaults/policy-templates/research-assistant.yaml +18 -0
  16. intentseal_core/defaults/providers.yaml +144 -0
  17. intentseal_core/engine.py +749 -0
  18. intentseal_core/extract.py +402 -0
  19. intentseal_core/llm/__init__.py +4 -0
  20. intentseal_core/llm/adapters.py +278 -0
  21. intentseal_core/llm/anthropic_adapter.py +294 -0
  22. intentseal_core/llm/router.py +449 -0
  23. intentseal_core/logger.py +245 -0
  24. intentseal_core/markers.py +29 -0
  25. intentseal_core/memory.py +157 -0
  26. intentseal_core/normalise.py +88 -0
  27. intentseal_core/outbound.py +76 -0
  28. intentseal_core/policy.py +156 -0
  29. intentseal_core/secrets.py +101 -0
  30. intentseal_core/session.py +151 -0
  31. intentseal_core/siem.py +252 -0
  32. intentseal_core/store.py +194 -0
  33. intentseal_core/taint.py +87 -0
  34. intentseal_core/types.py +64 -0
  35. intentseal_core-0.1.0.dist-info/METADATA +73 -0
  36. intentseal_core-0.1.0.dist-info/RECORD +41 -0
  37. intentseal_core-0.1.0.dist-info/WHEEL +5 -0
  38. intentseal_core-0.1.0.dist-info/entry_points.txt +2 -0
  39. intentseal_core-0.1.0.dist-info/licenses/LICENSE +202 -0
  40. intentseal_core-0.1.0.dist-info/licenses/NOTICE +4 -0
  41. 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,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -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)