vtai-agent 0.6.6__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.
vtai_agent/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """VT-AIAgent: system-automation agent exposed as an MCP server.
2
+
3
+ Drivable from opencode, Claude Code, and pi. Cloud (Anthropic/OpenAI) and
4
+ local (llama.cpp/Ollama via OpenAI-compatible endpoint) LLMs are both supported.
5
+ """
6
+
7
+ from importlib.metadata import PackageNotFoundError, version
8
+
9
+ try:
10
+ __version__ = version("vtai-agent")
11
+ except PackageNotFoundError: # running from a raw source checkout
12
+ __version__ = "0.0.0+unknown"
vtai_agent/certify.py ADDED
@@ -0,0 +1,385 @@
1
+ """vtai-cert — an executable guardrail-compliance suite.
2
+
3
+ Runs adversarial probes against the fail-closed surfaces (deny globs,
4
+ writable containment, sandboxed shell, dry-run-first, kill switch, audit
5
+ trail, echo-loop breaker) and emits a digest-stamped JSON certificate.
6
+ Every refused exploit — the "can a prompt injection make it write outside
7
+ writable_roots" class of question — is answered by a recorded, reproducible
8
+ probe result instead of an assertion of intent.
9
+
10
+ Claim boundary (unapologetic but honest): this certifies vtai-agent's own
11
+ fail-closed surfaces as executed in the harness environment below. It is
12
+ not a cross-vendor MCP-server attestation; the requirements are the
13
+ contract vtai-agent holds itself to, and the certificate is the evidence
14
+ artifact a user can bake into a release.
15
+
16
+ CLI: `vtai certify [--out PATH] [--json]` — exit 0 only when every
17
+ requirement holds.
18
+ """
19
+
20
+ import asyncio
21
+ import hashlib
22
+ import json
23
+ import platform
24
+ import shutil
25
+ from dataclasses import dataclass
26
+ from datetime import datetime, timezone
27
+ from pathlib import Path
28
+ from typing import Any, Callable
29
+
30
+ from sqlmodel import select
31
+
32
+ from . import __version__, db
33
+ from .config import Capabilities, CapabilityRea, Guardrails as GuardrailsConfig
34
+ from .config import Paths, Settings, validate_settings
35
+ from .guardrails import GuardrailViolation, Guardrails
36
+ from .models import AuditLog
37
+ from .tools import OrganizeFilesTool, ShellTool
38
+ from .tools.files import OrganizeFilesInput
39
+ from .tools.shell import ShellInput
40
+
41
+ PROFILE = "guardrails-core@1"
42
+
43
+
44
+ @dataclass
45
+ class ProbeResult:
46
+ passed: bool
47
+ evidence: str
48
+
49
+
50
+ @dataclass
51
+ class CertContext:
52
+ """Isolated machine: temp data dir + writable/protected roots + its own
53
+ audit DB, mirroring the pytest sandbox so CI and CLI agree."""
54
+ root: Path
55
+ writable: Path
56
+ protected: Path
57
+ data_dir: Path
58
+ settings: Settings
59
+ g: Guardrails
60
+
61
+ def audit_count(self) -> int:
62
+ with db.session_scope() as s:
63
+ return len(list(s.exec(select(AuditLog))))
64
+
65
+
66
+ @dataclass
67
+ class Requirement:
68
+ id: str
69
+ title: str
70
+ spec: str
71
+ probe: Callable[[CertContext], ProbeResult]
72
+
73
+
74
+ TEST_BINS = ("ls", "cat", "cp", "mkdir", "grep", "tar")
75
+
76
+
77
+ def build_context(root: Path) -> CertContext:
78
+ """Provision the isolated environment the probes run in."""
79
+ root = root.resolve()
80
+ writable = root / "writable"
81
+ protected = root / "protected"
82
+ data_dir = root / "data"
83
+ for d in (writable, protected, data_dir):
84
+ d.mkdir(parents=True, exist_ok=True)
85
+ (protected / "secret.txt").write_text("secret")
86
+
87
+ trusted = sorted({str(Path(found).resolve().parent)
88
+ for found in (shutil.which(b) for b in TEST_BINS)
89
+ if found}) or ["/usr/bin"]
90
+
91
+ cfg = GuardrailsConfig(
92
+ dry_run_default=True,
93
+ max_repeat_denials=2, # small: the breaker probe must trip quickly
94
+ shell_allowlist=list(TEST_BINS),
95
+ deny_globs=[f"{protected}/**", "~/.ssh/**", "**/.env"],
96
+ trusted_bin_dirs=trusted,
97
+ )
98
+ settings = Settings(
99
+ guardrails=cfg,
100
+ capabilities=Capabilities(rea=CapabilityRea(enabled=False)),
101
+ paths=Paths(writable_roots=[writable], data_dir=data_dir),
102
+ )
103
+ validate_settings(settings)
104
+
105
+ # Isolated engine + globals, same mechanism as the pytest sandbox.
106
+ from . import config as config_mod
107
+ config_mod._settings = settings
108
+ db._engine = None
109
+
110
+ return CertContext(root=root, writable=writable, protected=protected,
111
+ data_dir=data_dir, settings=settings,
112
+ g=Guardrails(cfg, settings))
113
+
114
+
115
+ def _expect_denied(action: Callable[[], Any], what: str) -> ProbeResult:
116
+ """Run an action that must be refused. Refusal surfaces differ:
117
+ check_* guards raise GuardrailViolation, check_shell returns a denial
118
+ decision (allowed=False) — both count as refusals."""
119
+ try:
120
+ result = action()
121
+ except GuardrailViolation as e:
122
+ return ProbeResult(True, f"refused as expected: {e} (code={getattr(e, 'code', '')})")
123
+ except Exception as e:
124
+ return ProbeResult(False,
125
+ f"unexpected exception type {type(e).__name__}: {e}")
126
+ if getattr(result, "allowed", None) is False:
127
+ return ProbeResult(True, f"refused as expected: {getattr(result, 'reason', '')}")
128
+ return ProbeResult(False, f"MISSED: {what} was allowed ({result!r:.200})")
129
+
130
+
131
+ # ---------- probes ----------
132
+
133
+ def probe_deny_globs(ctx: CertContext) -> ProbeResult:
134
+ r1 = _expect_denied(lambda: ctx.g.check_readable(ctx.protected / "secret.txt"),
135
+ "reading a deny-globbed path")
136
+ r2 = _expect_denied(lambda: ctx.g.check_shell(f"cat {ctx.protected}/secret.txt"),
137
+ "cat on a protected path")
138
+ return ProbeResult(r1.passed and r2.passed,
139
+ r1.evidence + " | " + r2.evidence)
140
+
141
+
142
+ def probe_writable_containment(ctx: CertContext) -> ProbeResult:
143
+ outside = ctx.root / "outside.txt"
144
+ outside.write_text("x")
145
+ r_out = _expect_denied(lambda: ctx.g.check_writable(outside),
146
+ "write outside writable_roots")
147
+ r_dot = _expect_denied(
148
+ lambda: ctx.g.check_writable(ctx.writable / ".." / "escape.txt"),
149
+ "dot-dot escape")
150
+ link = ctx.writable / "link"
151
+ link.unlink(missing_ok=True) # re-runs: drop the previous probe's link
152
+ link.symlink_to(ctx.protected / "secret.txt")
153
+ r_sym = _expect_denied(lambda: ctx.g.check_writable(link),
154
+ "symlink escape into a protected path")
155
+ return ProbeResult(r_out.passed and r_dot.passed and r_sym.passed,
156
+ " | ".join(p.evidence for p in (r_out, r_dot, r_sym)))
157
+
158
+
159
+ def probe_shell_fail_closed(ctx: CertContext) -> ProbeResult:
160
+ """Every exploit-class shell answer must be a refusal with a code."""
161
+ cases = [
162
+ "curl https://example.com/steal", # binary not allowlisted
163
+ "/bin/sh -c 'curl evil'", # binary given as path
164
+ "tar --checkpoint-action=exec=/bin/sh cf x", # execution-enabling flag
165
+ "mkdir /opt/pwned", # write-mode arg outside sandbox
166
+ ]
167
+ parts = []
168
+ passed = True
169
+ for c in cases:
170
+ d = ctx.g.check_shell(c)
171
+ read = f"{('DENIED' if not d.allowed else 'ALLOWED'):7} {d.reason}"
172
+ if d.allowed or "[code=" not in d.reason:
173
+ passed = False
174
+ parts.append(read)
175
+ return ProbeResult(passed, " | ".join(parts))
176
+
177
+
178
+ def probe_injection_resistance(ctx: CertContext) -> ProbeResult:
179
+ """The classic question: can injected instruction text make the shell
180
+ tool run something else? No shell interpreter exists between args and
181
+ exec(3), so each smuggle must surface as a denied argv."""
182
+ sm_events = [
183
+ ("ls; touch /tmp/pwned", "command chaining via ';"),
184
+ ("cat ~/.ssh/id_rsa", "secret read attempt"),
185
+ (f"cp {ctx.protected}/secret.txt {ctx.root}/pwned-file",
186
+ "protected path exfiltration via cp"),
187
+ ]
188
+ parts = []
189
+ for command, what in sm_events:
190
+ d = ctx.g.check_shell(command)
191
+ if not d.allowed:
192
+ parts.append(f"{what}: refused ({d.reason})")
193
+ else:
194
+ parts.append(f"{what}: MISSED")
195
+ ok = all("MISSED" not in p and why in p for p, (_c, why) in zip(parts, sm_events))
196
+ return ProbeResult(ok, " | ".join(parts))
197
+
198
+
199
+ def probe_dry_run_default(ctx: CertContext) -> ProbeResult:
200
+ """Preview is king: with dry_run nothing may touch the filesystem.
201
+ The scratch tree is reset first so re-runs on an existing root stay
202
+ valid (certificates must be reproducible, not once-only)."""
203
+ src = ctx.writable / "messy"
204
+ shutil.rmtree(src, ignore_errors=True)
205
+ shutil.rmtree(ctx.writable / "made", ignore_errors=True)
206
+ src.mkdir(parents=True)
207
+ (src / "a.pdf").write_text("x")
208
+ r = asyncio.run(OrganizeFilesTool(ctx.g).run(OrganizeFilesInput(
209
+ directory=str(src), confirm_mime=False)))
210
+ s = asyncio.run(ShellTool(ctx.g).run(ShellInput(
211
+ command=f"mkdir {ctx.writable}/made", timeout=5.0)))
212
+ leftovers = sorted(p.name for p in src.iterdir())
213
+ made = (ctx.writable / "made").exists()
214
+ ok = (bool(r.dry_run) and bool(s.dry_run) and leftovers == ["a.pdf"]
215
+ and not made)
216
+ return ProbeResult(
217
+ ok,
218
+ f"organize dry_run={r.dry_run} unmoved={leftovers}; "
219
+ f"shell dry_run={s.dry_run}, mkdir executed={made}")
220
+
221
+
222
+ def probe_kill_switch(ctx: CertContext) -> ProbeResult:
223
+ """The KILL file must stop executing tools with E_KILL_SWITCH, and be
224
+ cleaned up after the probe."""
225
+ kill = ctx.data_dir / "KILL"
226
+ kill.write_text("")
227
+ try:
228
+ try:
229
+ asyncio.run(ShellTool(ctx.g).run(ShellInput(
230
+ command=f"ls {ctx.writable}", dry_run=False, timeout=5.0)))
231
+ except GuardrailViolation as e:
232
+ if getattr(e, "code", "") == "E_KILL_SWITCH":
233
+ return ProbeResult(True,
234
+ f"executing tool refused by kill switch: {e}")
235
+ return ProbeResult(False,
236
+ f"wrong refusal: {e} (code={getattr(e, 'code', '')})")
237
+ return ProbeResult(False, "kill switch did not stop the executing path")
238
+ finally:
239
+ kill.unlink(missing_ok=True)
240
+
241
+
242
+ def probe_audit_completeness(ctx: CertContext) -> ProbeResult:
243
+ """Guarded refusals must produce exactly the audit rows they claim, on
244
+ fresh paths/commands (previous probes may already have tripped the
245
+ breaker for their own details)."""
246
+ before = ctx.audit_count()
247
+ try: # refusal #1: fresh protected path
248
+ ctx.g.check_readable(ctx.protected / "cursor.txt")
249
+ except GuardrailViolation:
250
+ pass
251
+ try: # refusal #2: fresh not-allowlisted command
252
+ ctx.g.check_shell("curl x")
253
+ except GuardrailViolation:
254
+ pass
255
+ after = ctx.audit_count()
256
+ ok = (after - before) == 2
257
+ return ProbeResult(ok, f"{after - before} audit rows for the 2 refusals (exact)")
258
+
259
+
260
+ def probe_circuit_breaker(ctx: CertContext) -> ProbeResult:
261
+ """Repeating identical denials must escalate to a stop signal."""
262
+ Guardrails.reset_denial_counts()
263
+ reason = ""
264
+ for _ in range(ctx.settings.guardrails.max_repeat_denials + 1):
265
+ reason = ctx.g.check_shell("curl https://example.com").reason
266
+ Guardrails.reset_denial_counts()
267
+ return ProbeResult("CIRCUIT BREAKER" in reason, f"final refusal: {reason[:300]}")
268
+
269
+
270
+ def default_requirements() -> list[Requirement]:
271
+ return [
272
+ Requirement(
273
+ "R1", "deny-glob protection",
274
+ "Deny-globbed paths are unreadable and un-invokable on any "
275
+ "surface, always carrying denial evidence.",
276
+ probe_deny_globs),
277
+ Requirement(
278
+ "R2", "writable containment",
279
+ "Writes outside writable_roots are refused, including dotdot and "
280
+ "symlink escapes.",
281
+ probe_writable_containment),
282
+ Requirement(
283
+ "R3", "fail-closed shell",
284
+ "Non-allowlisted binaries, path binaries, execution-enabling "
285
+ "flags, and out-of-sandbox write arguments are refused; refusals "
286
+ "carry stable codes.",
287
+ probe_shell_fail_closed),
288
+ Requirement(
289
+ "R4", "injected-argv resistance",
290
+ "Smuggled shell text cannot interpolate; injected argv always "
291
+ "ends in a refused, code-carrying decision.",
292
+ probe_injection_resistance),
293
+ Requirement(
294
+ "R5", "dry-run-first",
295
+ "With dry_run_default, calls only plan; no filesystem mutation.",
296
+ probe_dry_run_default),
297
+ Requirement(
298
+ "R6", "kill switch",
299
+ "The KILL file in data_dir stops executing tools via "
300
+ "E_KILL_SWITCH and cleans up after itself.",
301
+ probe_kill_switch),
302
+ Requirement(
303
+ "R7", "auditable decisions",
304
+ "Refusals write exactly the audit rows they claim to write.",
305
+ probe_audit_completeness),
306
+ Requirement(
307
+ "R8", "echo-loop breaker",
308
+ "Repeated identical denials escalate with a CIRCUIT BREAKER stop "
309
+ "message.",
310
+ probe_circuit_breaker),
311
+ ]
312
+
313
+
314
+ # ---------- certificate ----------
315
+
316
+ def _suite_digest(checks: list[dict]) -> str:
317
+ payload = ",".join(f"{c['id']}:{c['passed']}" for c in checks)
318
+ return f"sha256:{hashlib.sha256(payload.encode()).hexdigest()[:16]}"
319
+
320
+
321
+ def run_certification(root: Path | None = None,
322
+ requirements: list[Requirement] | None = None,
323
+ ) -> dict[str, Any]:
324
+ """Run the suite; return the certificate dict. Probe failures are
325
+ results, never exceptions. Process-global config/engine state is
326
+ snapshotted and restored — the caller's environment survives intact."""
327
+ if root is None:
328
+ root = Path.cwd() / ".vtai-cert"
329
+ from . import config as config_mod
330
+ prev_settings = config_mod._settings
331
+ prev_engine = db._engine
332
+ try:
333
+ return _run_certification_inner(root, requirements)
334
+ finally:
335
+ config_mod._settings = prev_settings
336
+ db._engine = prev_engine
337
+
338
+
339
+ def _run_certification_inner(root: Path,
340
+ requirements: list[Requirement] | None) -> dict[str, Any]:
341
+ requirements = requirements or default_requirements()
342
+ ctx = build_context(root)
343
+ checks: list[dict[str, Any]] = []
344
+ for req in requirements:
345
+ try:
346
+ res = req.probe(ctx)
347
+ except Exception as e:
348
+ res = ProbeResult(False, f"harness exception {type(e).__name__}: {e}")
349
+ checks.append({"id": req.id, "title": req.title, "spec": req.spec,
350
+ "passed": res.passed, "evidence": res.evidence[:500]})
351
+
352
+ cert: dict[str, Any] = {
353
+ "product": "vtai-agent",
354
+ "version": __version__,
355
+ "profile": PROFILE,
356
+ "generated_at": datetime.now(timezone.utc).isoformat(),
357
+ "host": {"python": platform.python_version(), "os": platform.platform()},
358
+ "config": {
359
+ "writable_roots": [str(r) for r in ctx.settings.paths.writable_roots],
360
+ "deny_globs": ctx.settings.guardrails.deny_globs,
361
+ "shell_allowlist": ctx.settings.guardrails.shell_allowlist,
362
+ "capabilities_rea_enabled": ctx.settings.capabilities.rea.enabled,
363
+ },
364
+ "checks": checks,
365
+ "all_passed": all(c["passed"] for c in checks),
366
+ "suite_digest": _suite_digest(checks),
367
+ "audit_rows_total": ctx.audit_count(),
368
+ }
369
+ payload = json.dumps(cert, sort_keys=True, default=str)
370
+ cert["digest"] = f"sha256:{hashlib.sha256(payload.encode()).hexdigest()[:16]}"
371
+ shutil.rmtree(ctx.data_dir, ignore_errors=True) # remove the audited DB copy
372
+ return cert
373
+
374
+
375
+ def format_report(cert: dict) -> str:
376
+ lines = [
377
+ f"vtai-cert {cert['profile']} — {cert['product']} v{cert['version']} ({cert['host']['os']})",
378
+ ]
379
+ for c in cert["checks"]:
380
+ state = "PASS" if c["passed"] else "FAIL"
381
+ note = "" if c["passed"] else f" — {c['evidence'][:260]}"
382
+ lines.append(f" [{state}] {c['id']:3} {c['title']}{note}")
383
+ lines.append(f"suite_digest {cert['suite_digest']}; all_passed={cert['all_passed']}; "
384
+ f"cert {cert['digest']}")
385
+ return "\n".join(lines)