stopgate 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.
- stopgate/__init__.py +12 -0
- stopgate/cli.py +711 -0
- stopgate/core/__init__.py +2 -0
- stopgate/core/action.py +196 -0
- stopgate/core/detectors.py +117 -0
- stopgate/core/patterns.py +164 -0
- stopgate/core/taint.py +513 -0
- stopgate/data/sample_session.jsonl +8 -0
- stopgate/digest.py +210 -0
- stopgate/engine.py +118 -0
- stopgate/evalsuite.py +443 -0
- stopgate/logparse.py +286 -0
- stopgate/policy.py +482 -0
- stopgate/session.py +260 -0
- stopgate/supervisor.py +113 -0
- stopgate-0.1.0.dist-info/METADATA +181 -0
- stopgate-0.1.0.dist-info/RECORD +21 -0
- stopgate-0.1.0.dist-info/WHEEL +5 -0
- stopgate-0.1.0.dist-info/entry_points.txt +2 -0
- stopgate-0.1.0.dist-info/licenses/LICENSE +21 -0
- stopgate-0.1.0.dist-info/top_level.txt +1 -0
stopgate/cli.py
ADDED
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
r"""``stopgate`` command-line interface.
|
|
2
|
+
|
|
3
|
+
Commands (all READ-ONLY, ZERO CONFIG to start, no network of their own):
|
|
4
|
+
|
|
5
|
+
* ``stopgate report`` — the first-60-seconds summary of what the agent has been
|
|
6
|
+
doing, replayed from its existing logs (Phase 1).
|
|
7
|
+
* ``stopgate digest`` — a short session receipt that leads with what Stopgate
|
|
8
|
+
*let through and why that was safe*, not only what it flagged (Phase 2).
|
|
9
|
+
* ``stopgate learn`` — observe the user's own past sessions and write a
|
|
10
|
+
human-editable allow-policy that suppresses known-normal activity (Phase 2).
|
|
11
|
+
|
|
12
|
+
``run``/``eval`` are intentionally NOT here yet; this CLI never advertises a
|
|
13
|
+
command it does not implement.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
from collections import OrderedDict
|
|
23
|
+
from typing import Dict, List, Optional
|
|
24
|
+
|
|
25
|
+
from . import __version__
|
|
26
|
+
from . import policy as policymod
|
|
27
|
+
from .core.action import Action, ActionKind
|
|
28
|
+
from .digest import analyze, render_digest
|
|
29
|
+
from .engine import Decision, PolicyEngine, Verdict
|
|
30
|
+
from .logparse import parse_log
|
|
31
|
+
from .supervisor import supervise, render_run_summary
|
|
32
|
+
from .policy import Policy
|
|
33
|
+
|
|
34
|
+
def _default_fixture() -> str:
|
|
35
|
+
packaged = os.path.join(os.path.dirname(__file__), "data", "sample_session.jsonl")
|
|
36
|
+
if os.path.exists(packaged):
|
|
37
|
+
return packaged
|
|
38
|
+
return os.path.normpath(
|
|
39
|
+
os.path.join(os.path.dirname(__file__), "..", "..", "tests",
|
|
40
|
+
"fixtures", "sample_session.jsonl")
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_DEFAULT_FIXTURE = _default_fixture()
|
|
45
|
+
_DOMAIN_RE = re.compile(r"https?://([^/\s:]+)", re.IGNORECASE)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _extract_domains(action: Action) -> List[str]:
|
|
49
|
+
return [m.group(1).lower() for m in _DOMAIN_RE.finditer(action.text)]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _credential_paths(action: Action) -> List[str]:
|
|
53
|
+
"""Pull the concrete secret-looking paths out of an action's args."""
|
|
54
|
+
from .core import patterns as P
|
|
55
|
+
|
|
56
|
+
out = []
|
|
57
|
+
for chunk in action.text.split("\n"):
|
|
58
|
+
if P.matches_any(chunk, P.CREDENTIAL_PATH_PATTERNS):
|
|
59
|
+
out.append(chunk.strip())
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _fmt_paths(paths: List[str], limit: int = 3) -> str:
|
|
64
|
+
uniq = list(OrderedDict.fromkeys(paths))
|
|
65
|
+
shown = uniq[:limit]
|
|
66
|
+
s = ", ".join(shown)
|
|
67
|
+
if len(uniq) > limit:
|
|
68
|
+
s += ", …"
|
|
69
|
+
return s
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_report(actions: List[Action], policy: Optional[Policy] = None) -> Dict[str, object]:
|
|
73
|
+
"""Run the engine over the actions and tally the report. Pure aggregation.
|
|
74
|
+
|
|
75
|
+
When a learned ``policy`` is supplied, actions it explains as known-normal
|
|
76
|
+
are pulled OUT of the tallies (and counted under ``suppressed``) so the
|
|
77
|
+
report shows the anomalies, not the routine. With ``policy=None`` — the
|
|
78
|
+
zero-config default — nothing is suppressed and the output is unchanged.
|
|
79
|
+
A policy can never suppress a taint hit, an escalation, or an irreversible
|
|
80
|
+
action; that invariant lives in :func:`stopgate.policy.suppression`.
|
|
81
|
+
"""
|
|
82
|
+
engine = PolicyEngine()
|
|
83
|
+
cred_reads: List[str] = []
|
|
84
|
+
domains: "OrderedDict[str, int]" = OrderedDict()
|
|
85
|
+
destructive = 0
|
|
86
|
+
taint_hits: List[Decision] = []
|
|
87
|
+
escalations: List[Decision] = []
|
|
88
|
+
notify = 0
|
|
89
|
+
blocked = 0
|
|
90
|
+
suppressed = 0
|
|
91
|
+
decisions: List[Decision] = []
|
|
92
|
+
|
|
93
|
+
for act in actions:
|
|
94
|
+
d = engine.evaluate(act)
|
|
95
|
+
decisions.append(d)
|
|
96
|
+
|
|
97
|
+
if policymod.suppression(act, d, policy) is not None:
|
|
98
|
+
suppressed += 1
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
if ActionKind.CREDENTIAL_ACCESS in d.kinds:
|
|
102
|
+
cred_reads.extend(_credential_paths(act))
|
|
103
|
+
if ActionKind.NETWORK_EGRESS in d.kinds:
|
|
104
|
+
for dom in _extract_domains(act):
|
|
105
|
+
domains[dom] = domains.get(dom, 0) + 1
|
|
106
|
+
if ActionKind.DESTRUCTIVE in d.kinds:
|
|
107
|
+
destructive += 1
|
|
108
|
+
if d.taint_hit:
|
|
109
|
+
taint_hits.append(d)
|
|
110
|
+
elif d.escalated:
|
|
111
|
+
escalations.append(d)
|
|
112
|
+
if d.verdict == Verdict.NOTIFY:
|
|
113
|
+
notify += 1
|
|
114
|
+
elif d.verdict == Verdict.BLOCK:
|
|
115
|
+
blocked += 1
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
"n_actions": len(actions),
|
|
119
|
+
"cred_reads": cred_reads,
|
|
120
|
+
"domains": domains,
|
|
121
|
+
"destructive": destructive,
|
|
122
|
+
"taint_hits": taint_hits,
|
|
123
|
+
"escalations": escalations,
|
|
124
|
+
"notify": notify,
|
|
125
|
+
"blocked": blocked,
|
|
126
|
+
"suppressed": suppressed,
|
|
127
|
+
"engine": engine,
|
|
128
|
+
"decisions": decisions,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def render_report(rep: Dict[str, object], source_label: str) -> str:
|
|
133
|
+
n = rep["n_actions"]
|
|
134
|
+
cred_reads: List[str] = rep["cred_reads"]
|
|
135
|
+
domains: "OrderedDict[str, int]" = rep["domains"]
|
|
136
|
+
destructive: int = rep["destructive"]
|
|
137
|
+
taint_hits: List[Decision] = rep["taint_hits"]
|
|
138
|
+
escalations: List[Decision] = rep["escalations"]
|
|
139
|
+
|
|
140
|
+
lines: List[str] = []
|
|
141
|
+
lines.append("")
|
|
142
|
+
lines.append(" Scanning {} ... (no config needed, nothing sent anywhere)".format(source_label))
|
|
143
|
+
lines.append("")
|
|
144
|
+
lines.append(" This session — {:,} agent actions:".format(n))
|
|
145
|
+
lines.append("")
|
|
146
|
+
|
|
147
|
+
# credential-touching reads
|
|
148
|
+
cred_n = len(cred_reads)
|
|
149
|
+
cred_detail = " ({})".format(_fmt_paths(cred_reads)) if cred_reads else ""
|
|
150
|
+
lines.append(" {:>4} file reads touched credentials{}".format(cred_n, cred_detail))
|
|
151
|
+
|
|
152
|
+
# network calls
|
|
153
|
+
net_calls = sum(domains.values())
|
|
154
|
+
n_domains = len(domains)
|
|
155
|
+
dom_detail = " ({})".format(_fmt_paths(list(domains.keys()))) if domains else ""
|
|
156
|
+
lines.append(
|
|
157
|
+
" {:>4} network calls to {} domain{}{}".format(
|
|
158
|
+
net_calls, n_domains, "" if n_domains == 1 else "s", dom_detail
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# destructive
|
|
163
|
+
lines.append(" {:>4} destructive commands (no confirmation asked)".format(destructive))
|
|
164
|
+
|
|
165
|
+
# the taint line — the headline
|
|
166
|
+
n_hits = len(taint_hits)
|
|
167
|
+
n_esc = len(escalations)
|
|
168
|
+
if n_hits:
|
|
169
|
+
lines.append(
|
|
170
|
+
" {:>4} outbound payload carried a secret read earlier <- this is the one".format(n_hits)
|
|
171
|
+
)
|
|
172
|
+
if n_esc:
|
|
173
|
+
# find the distance from the reason text if present
|
|
174
|
+
lines.append(
|
|
175
|
+
" {:>4} action(s) taken shortly after reading untrusted content".format(n_esc)
|
|
176
|
+
)
|
|
177
|
+
if not n_hits and not n_esc:
|
|
178
|
+
lines.append(" {:>4} taint hits".format(0))
|
|
179
|
+
|
|
180
|
+
lines.append("")
|
|
181
|
+
|
|
182
|
+
# Show the taint graph for the sharpest finding, per the brief
|
|
183
|
+
# (tainted + high -> block AND show the taint graph).
|
|
184
|
+
if taint_hits or escalations:
|
|
185
|
+
engine = rep["engine"]
|
|
186
|
+
lines.append(" --- taint graph ------------------------------------------------")
|
|
187
|
+
graph = engine.tracker.graph()
|
|
188
|
+
for node in graph["nodes"]:
|
|
189
|
+
lines.append(
|
|
190
|
+
" source step {:>3} {:<10} {}".format(
|
|
191
|
+
node["step"], node["source"], node["summary"]
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
for edge in graph["edges"]:
|
|
195
|
+
arrow = "==>" if edge["kind"] == "egress" else "-->"
|
|
196
|
+
lines.append(
|
|
197
|
+
" {} step {} {} step {} {}".format(
|
|
198
|
+
arrow, edge["origin_step"], "", edge["action_step"], edge["detail"]
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
lines.append(" ----------------------------------------------------------------")
|
|
202
|
+
lines.append("")
|
|
203
|
+
|
|
204
|
+
# honest summary of what the engine would have done
|
|
205
|
+
lines.append(
|
|
206
|
+
" Stopgate verdicts: {} would notify, {} would block until approved.".format(
|
|
207
|
+
rep["notify"], rep["blocked"]
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
if rep.get("suppressed"):
|
|
211
|
+
lines.append(
|
|
212
|
+
" {} known-normal action(s) suppressed by your policy (observe-only).".format(
|
|
213
|
+
rep["suppressed"]
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
lines.append("")
|
|
217
|
+
lines.append(' Want Stopgate to watch the next one? stopgate run -- claude "..."')
|
|
218
|
+
lines.append(" (unattended mode ships in a later phase; report is read-only today.)")
|
|
219
|
+
lines.append("")
|
|
220
|
+
return "\n".join(lines)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _resolve_log(path: Optional[str]) -> str:
|
|
224
|
+
return path or os.path.normpath(_DEFAULT_FIXTURE)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _load_log_or_die(log_path: str):
|
|
228
|
+
"""Shared front-door for report/digest: validate the path and parse it,
|
|
229
|
+
returning ``(actions, stats)`` or raising ``_CliError`` with an exit code."""
|
|
230
|
+
if not os.path.exists(log_path):
|
|
231
|
+
raise _CliError("log not found: {}".format(log_path))
|
|
232
|
+
if os.path.isdir(log_path):
|
|
233
|
+
raise _CliError("log path is a directory, not a file: {}".format(log_path))
|
|
234
|
+
# A security tool that crashes on bad input fails open. parse_log never
|
|
235
|
+
# raises on malformed *lines*, but the file itself may be unreadable
|
|
236
|
+
# (permissions, a special file, a race that removed it) — a clean error,
|
|
237
|
+
# never a traceback.
|
|
238
|
+
try:
|
|
239
|
+
return parse_log(log_path)
|
|
240
|
+
except OSError as exc:
|
|
241
|
+
raise _CliError("could not read log {}: {}".format(log_path, exc))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class _CliError(Exception):
|
|
245
|
+
"""A user-facing error with a clean message (never a traceback)."""
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _resolve_policy(args: argparse.Namespace) -> "tuple[Optional[Policy], Optional[str]]":
|
|
249
|
+
"""Return ``(policy, label)``. ``--no-policy`` forces zero-config; an
|
|
250
|
+
explicit ``--policy`` must exist; otherwise auto-discover (returns None if
|
|
251
|
+
nothing is found, i.e. plain zero-config)."""
|
|
252
|
+
if getattr(args, "no_policy", False):
|
|
253
|
+
return None, None
|
|
254
|
+
path = getattr(args, "policy", None)
|
|
255
|
+
if not path:
|
|
256
|
+
path = policymod.discover_policy_path()
|
|
257
|
+
if not path:
|
|
258
|
+
return None, None
|
|
259
|
+
if not os.path.exists(path):
|
|
260
|
+
raise _CliError("policy file not found: {}".format(path))
|
|
261
|
+
pol = policymod.load_policy(path)
|
|
262
|
+
return pol, os.path.basename(path)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _warn_footer() -> None:
|
|
266
|
+
sys.stderr.write(
|
|
267
|
+
"\n ⚠ Stopgate is best-effort and can miss attacks or flag safe actions "
|
|
268
|
+
"— not a guarantee; see DISCLAIMER.md.\n")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def cmd_report(args: argparse.Namespace) -> int:
|
|
272
|
+
log_path = _resolve_log(args.log)
|
|
273
|
+
try:
|
|
274
|
+
actions, stats = _load_log_or_die(log_path)
|
|
275
|
+
policy, _plabel = _resolve_policy(args)
|
|
276
|
+
except _CliError as exc:
|
|
277
|
+
sys.stderr.write("stopgate: {}\n".format(exc))
|
|
278
|
+
return 2
|
|
279
|
+
label = "log {}".format(os.path.basename(log_path))
|
|
280
|
+
if not actions:
|
|
281
|
+
sys.stdout.write(
|
|
282
|
+
"\n No agent actions found in {} "
|
|
283
|
+
"({} line(s), {} unparseable).\n\n".format(log_path, stats.lines, stats.skipped)
|
|
284
|
+
)
|
|
285
|
+
return 0
|
|
286
|
+
|
|
287
|
+
rep = build_report(actions, policy=policy)
|
|
288
|
+
sys.stdout.write(render_report(rep, label))
|
|
289
|
+
if stats.skipped:
|
|
290
|
+
sys.stdout.write(
|
|
291
|
+
" ({} of {} log line(s) were not tool calls and were skipped.)\n\n".format(
|
|
292
|
+
stats.skipped, stats.lines
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
_warn_footer()
|
|
296
|
+
return 0
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def cmd_digest(args: argparse.Namespace) -> int:
|
|
300
|
+
log_path = _resolve_log(args.log)
|
|
301
|
+
try:
|
|
302
|
+
actions, stats = _load_log_or_die(log_path)
|
|
303
|
+
policy, plabel = _resolve_policy(args)
|
|
304
|
+
except _CliError as exc:
|
|
305
|
+
sys.stderr.write("stopgate: {}\n".format(exc))
|
|
306
|
+
return 2
|
|
307
|
+
label = "session log {}".format(os.path.basename(log_path))
|
|
308
|
+
if not actions:
|
|
309
|
+
sys.stdout.write(
|
|
310
|
+
"\n Nothing to digest — no agent actions in {} "
|
|
311
|
+
"({} line(s), {} unparseable).\n\n".format(log_path, stats.lines, stats.skipped)
|
|
312
|
+
)
|
|
313
|
+
return 0
|
|
314
|
+
dg = analyze(actions, policy=policy)
|
|
315
|
+
sys.stdout.write(render_digest(dg, label, plabel))
|
|
316
|
+
_warn_footer()
|
|
317
|
+
return 0
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def cmd_learn(args: argparse.Namespace) -> int:
|
|
321
|
+
logs = args.log or [os.path.normpath(_DEFAULT_FIXTURE)]
|
|
322
|
+
base: Optional[Policy] = None
|
|
323
|
+
total = policymod.LearnStats()
|
|
324
|
+
n_sessions = 0
|
|
325
|
+
for log_path in logs:
|
|
326
|
+
try:
|
|
327
|
+
actions, _stats = _load_log_or_die(log_path)
|
|
328
|
+
except _CliError as exc:
|
|
329
|
+
sys.stderr.write("stopgate: {}\n".format(exc))
|
|
330
|
+
return 2
|
|
331
|
+
if not actions:
|
|
332
|
+
continue
|
|
333
|
+
n_sessions += 1
|
|
334
|
+
engine = PolicyEngine()
|
|
335
|
+
observations = [(a, engine.evaluate(a)) for a in actions]
|
|
336
|
+
base, stats = policymod.learn_policy(
|
|
337
|
+
observations, sessions=n_sessions, base=base
|
|
338
|
+
)
|
|
339
|
+
total.actions += stats.actions
|
|
340
|
+
total.harvested += stats.harvested
|
|
341
|
+
total.excluded += stats.excluded
|
|
342
|
+
|
|
343
|
+
if base is None:
|
|
344
|
+
sys.stderr.write("stopgate: no agent actions found in the given log(s) — nothing to learn.\n")
|
|
345
|
+
return 2
|
|
346
|
+
|
|
347
|
+
total.sessions = n_sessions
|
|
348
|
+
base.meta = {
|
|
349
|
+
"generated": args.now or "(local run)",
|
|
350
|
+
"sessions": str(total.sessions),
|
|
351
|
+
"actions": str(total.actions),
|
|
352
|
+
"harvested": str(total.harvested),
|
|
353
|
+
"excluded": str(total.excluded),
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
out_path = args.out or policymod.default_policy_path()
|
|
357
|
+
try:
|
|
358
|
+
policymod.save_policy(base, out_path)
|
|
359
|
+
except OSError as exc:
|
|
360
|
+
sys.stderr.write("stopgate: could not write policy {}: {}\n".format(out_path, exc))
|
|
361
|
+
return 2
|
|
362
|
+
|
|
363
|
+
sys.stdout.write(
|
|
364
|
+
"\n Learned your normal from {} session(s), {} action(s) "
|
|
365
|
+
"({} routine harvested, {} flagged/risky refused).\n".format(
|
|
366
|
+
total.sessions, total.actions, total.harvested, total.excluded
|
|
367
|
+
)
|
|
368
|
+
)
|
|
369
|
+
sys.stdout.write(
|
|
370
|
+
" Wrote allow-policy: {}\n".format(out_path)
|
|
371
|
+
)
|
|
372
|
+
sys.stdout.write(
|
|
373
|
+
" {} dir(s), {} file(s), {} domain(s), {} tool(s).\n".format(
|
|
374
|
+
len(base.dirs), len(base.files), len(base.domains), len(base.tools)
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
sys.stdout.write(
|
|
378
|
+
" Observe-only: it suppresses known-normal noise in report/digest, "
|
|
379
|
+
"never unblocks anything.\n\n"
|
|
380
|
+
)
|
|
381
|
+
return 0
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
385
|
+
p = argparse.ArgumentParser(
|
|
386
|
+
prog="stopgate",
|
|
387
|
+
description="Stop watching your agent. Local-first, zero-config safety for coding agents.",
|
|
388
|
+
)
|
|
389
|
+
p.add_argument("--version", action="version", version="stopgate {}".format(__version__))
|
|
390
|
+
sub = p.add_subparsers(dest="command")
|
|
391
|
+
|
|
392
|
+
rep = sub.add_parser(
|
|
393
|
+
"report",
|
|
394
|
+
help="read-only summary of what your agent has been doing (zero config)",
|
|
395
|
+
)
|
|
396
|
+
rep.add_argument(
|
|
397
|
+
"--log",
|
|
398
|
+
default=None,
|
|
399
|
+
help="path to a Claude Code session/hook JSONL log "
|
|
400
|
+
"(default: bundled sample session)",
|
|
401
|
+
)
|
|
402
|
+
_add_policy_flags(rep)
|
|
403
|
+
rep.set_defaults(func=cmd_report)
|
|
404
|
+
|
|
405
|
+
dig = sub.add_parser(
|
|
406
|
+
"digest",
|
|
407
|
+
help="short session receipt: what was let through and why it was safe",
|
|
408
|
+
)
|
|
409
|
+
dig.add_argument(
|
|
410
|
+
"--log",
|
|
411
|
+
default=None,
|
|
412
|
+
help="path to a session/hook JSONL log (default: bundled sample session)",
|
|
413
|
+
)
|
|
414
|
+
_add_policy_flags(dig)
|
|
415
|
+
dig.set_defaults(func=cmd_digest)
|
|
416
|
+
|
|
417
|
+
lrn = sub.add_parser(
|
|
418
|
+
"learn",
|
|
419
|
+
help="observe your own past sessions and write an allow-policy (opt-in tuning)",
|
|
420
|
+
)
|
|
421
|
+
lrn.add_argument(
|
|
422
|
+
"--log",
|
|
423
|
+
action="append",
|
|
424
|
+
default=None,
|
|
425
|
+
metavar="PATH",
|
|
426
|
+
help="a past session log to learn from; repeat to learn from several "
|
|
427
|
+
"(default: bundled sample session)",
|
|
428
|
+
)
|
|
429
|
+
lrn.add_argument(
|
|
430
|
+
"--out",
|
|
431
|
+
default=None,
|
|
432
|
+
help="where to write the policy (default: ./.stopgate.toml if present, "
|
|
433
|
+
"else ~/.stopgate/policy.toml)",
|
|
434
|
+
)
|
|
435
|
+
lrn.add_argument(
|
|
436
|
+
"--now",
|
|
437
|
+
default=None,
|
|
438
|
+
help=argparse.SUPPRESS, # test hook: stamp a deterministic 'generated' date
|
|
439
|
+
)
|
|
440
|
+
lrn.set_defaults(func=cmd_learn)
|
|
441
|
+
|
|
442
|
+
runp = sub.add_parser(
|
|
443
|
+
"run",
|
|
444
|
+
help="supervise an agent run: auto-approve in-policy, hard-stop the irreversible",
|
|
445
|
+
)
|
|
446
|
+
runp.add_argument("--log", default=None,
|
|
447
|
+
help="preview supervision on a recorded session (default: bundled sample)")
|
|
448
|
+
runp.add_argument("--approve-all", action="store_true", help=argparse.SUPPRESS)
|
|
449
|
+
runp.add_argument("--deny-all", action="store_true", help=argparse.SUPPRESS)
|
|
450
|
+
runp.add_argument("cmd", nargs=argparse.REMAINDER,
|
|
451
|
+
help="-- <agent command> to gate live via the PreToolUse hook")
|
|
452
|
+
_add_policy_flags(runp)
|
|
453
|
+
runp.set_defaults(func=cmd_run)
|
|
454
|
+
|
|
455
|
+
hookp = sub.add_parser(
|
|
456
|
+
"hook",
|
|
457
|
+
help="Claude Code PreToolUse gate: read a tool event on stdin, emit allow/ask",
|
|
458
|
+
)
|
|
459
|
+
hookp.add_argument(
|
|
460
|
+
"--mode", choices=["observe", "enforce"], default=None,
|
|
461
|
+
help="observe = log only, never block (default: $STOPGATE_MODE, "
|
|
462
|
+
"~/.stopgate-mode, else enforce)",
|
|
463
|
+
)
|
|
464
|
+
hookp.add_argument(
|
|
465
|
+
"--headless", action="store_true",
|
|
466
|
+
help="nobody is at the keyboard: a hard-stop denies instead of asking",
|
|
467
|
+
)
|
|
468
|
+
hookp.set_defaults(func=cmd_hook)
|
|
469
|
+
|
|
470
|
+
watchp = sub.add_parser(
|
|
471
|
+
"watch",
|
|
472
|
+
help="PostToolUse recorder: feed tool RESULTS into the session's dataflow "
|
|
473
|
+
"state (this is what makes live cross-call exfil detection work)",
|
|
474
|
+
)
|
|
475
|
+
watchp.set_defaults(func=cmd_watch)
|
|
476
|
+
|
|
477
|
+
evp = sub.add_parser(
|
|
478
|
+
"eval",
|
|
479
|
+
help="strict precision benchmark: false alarms vs a keyword baseline",
|
|
480
|
+
)
|
|
481
|
+
evp.add_argument("--json", action="store_true",
|
|
482
|
+
help="also write full per-example results to JSON")
|
|
483
|
+
evp.add_argument("--out", default=None, help="JSON output path")
|
|
484
|
+
evp.set_defaults(func=cmd_eval)
|
|
485
|
+
|
|
486
|
+
return p
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
490
|
+
"""Supervise an agent run: auto-approve in-policy, hard-stop the irreversible."""
|
|
491
|
+
cmd = [c for c in (getattr(args, "cmd", None) or []) if c != "--"]
|
|
492
|
+
if cmd:
|
|
493
|
+
# Live mode. True interception of a child agent happens through Claude
|
|
494
|
+
# Code's PreToolUse hook (see `stopgate hook`); we do not silently run an
|
|
495
|
+
# ungated agent and pretend it was watched.
|
|
496
|
+
sys.stdout.write(
|
|
497
|
+
"\n Live gating runs through Claude Code's PreToolUse hook.\n"
|
|
498
|
+
" Add this to your Claude Code settings (once):\n\n"
|
|
499
|
+
' "hooks": {"PreToolUse": [{"hooks": [{"type": "command",\n'
|
|
500
|
+
' "command": "stopgate hook"}]}]}\n\n'
|
|
501
|
+
" Then every tool call in `%s ...` is gated live: in-policy actions\n"
|
|
502
|
+
" pass, and money/prod/destructive/egress stop for you.\n"
|
|
503
|
+
" To preview supervision on a recorded session: stopgate run --log <file>\n\n"
|
|
504
|
+
% cmd[0]
|
|
505
|
+
)
|
|
506
|
+
return 0
|
|
507
|
+
log_path = _resolve_log(args.log)
|
|
508
|
+
try:
|
|
509
|
+
actions, stats = _load_log_or_die(log_path)
|
|
510
|
+
policy, plabel = _resolve_policy(args)
|
|
511
|
+
except _CliError as exc:
|
|
512
|
+
sys.stderr.write("stopgate: {}\n".format(exc))
|
|
513
|
+
return 2
|
|
514
|
+
if args.approve_all:
|
|
515
|
+
def approver(a, d):
|
|
516
|
+
return True
|
|
517
|
+
elif args.deny_all or not sys.stdin.isatty():
|
|
518
|
+
def approver(a, d):
|
|
519
|
+
return False # fail-safe: never auto-proceed a hard-stop
|
|
520
|
+
else:
|
|
521
|
+
def approver(a, d):
|
|
522
|
+
try:
|
|
523
|
+
ans = input(" HARD-STOP: {} — {}\n approve? [y/N] ".format(a.tool, d.reason))
|
|
524
|
+
except EOFError:
|
|
525
|
+
return False
|
|
526
|
+
return ans.strip().lower() in ("y", "yes")
|
|
527
|
+
def notifier(m):
|
|
528
|
+
sys.stderr.write(" [stopgate] " + m + "\n")
|
|
529
|
+
result = supervise(actions, approver=approver, notifier=notifier)
|
|
530
|
+
sys.stdout.write(render_run_summary(result))
|
|
531
|
+
dg = analyze(actions, policy=policy)
|
|
532
|
+
sys.stdout.write(render_digest(
|
|
533
|
+
dg, "supervised run of {}".format(os.path.basename(log_path)), plabel))
|
|
534
|
+
_warn_footer()
|
|
535
|
+
return 1 if result.blocked else 0
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _hook_emit(word: str, reason: str) -> int:
|
|
539
|
+
import json
|
|
540
|
+
out = {"hookSpecificOutput": {
|
|
541
|
+
"hookEventName": "PreToolUse",
|
|
542
|
+
"permissionDecision": word,
|
|
543
|
+
"permissionDecisionReason": "stopgate: " + reason}}
|
|
544
|
+
sys.stdout.write(json.dumps(out))
|
|
545
|
+
return 0
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _resolve_mode(flag: Optional[str]) -> str:
|
|
549
|
+
"""--mode, else $STOPGATE_MODE, else ~/.stopgate-mode, else enforce."""
|
|
550
|
+
import os as _os
|
|
551
|
+
m = (flag or _os.environ.get("STOPGATE_MODE") or "").strip().lower()
|
|
552
|
+
if not m:
|
|
553
|
+
try:
|
|
554
|
+
with open(_os.path.join(_os.path.expanduser("~"), ".stopgate-mode")) as fh:
|
|
555
|
+
m = fh.read().strip().lower()
|
|
556
|
+
except OSError:
|
|
557
|
+
m = ""
|
|
558
|
+
return m if m in ("observe", "enforce") else "enforce"
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def cmd_hook(args: argparse.Namespace) -> int:
|
|
562
|
+
"""Claude Code PreToolUse gate — the live seam.
|
|
563
|
+
|
|
564
|
+
Judges the PENDING tool call against everything Stopgate has already seen in
|
|
565
|
+
this session (fed by ``stopgate watch``; see :mod:`stopgate.session`). The
|
|
566
|
+
ruling is computed on a COPY of the session state, so judging an action can
|
|
567
|
+
never rewrite history.
|
|
568
|
+
|
|
569
|
+
Modes — ``--mode``, else ``$STOPGATE_MODE``, else ``~/.stopgate-mode``, else enforce:
|
|
570
|
+
|
|
571
|
+
observe log only, ALWAYS allow. Cannot stall an agent. Use it to build trust.
|
|
572
|
+
enforce a hard-stop surfaces to a human as "ask".
|
|
573
|
+
--headless (or ``$STOPGATE_HEADLESS=1``) nobody is at the keyboard, so a
|
|
574
|
+
hard-stop becomes "deny" rather than hanging forever on a prompt
|
|
575
|
+
no one will ever answer.
|
|
576
|
+
|
|
577
|
+
Fails OPEN on internal error, deliberately: a broken guardrail must never
|
|
578
|
+
brick the agent it is watching. Stopgate is a safety net, not a guarantee.
|
|
579
|
+
"""
|
|
580
|
+
import json
|
|
581
|
+
import os as _os
|
|
582
|
+
from .logparse import action_from_record
|
|
583
|
+
from .engine import PolicyEngine, Verdict
|
|
584
|
+
from . import session as _sess
|
|
585
|
+
|
|
586
|
+
raw = sys.stdin.read()
|
|
587
|
+
try:
|
|
588
|
+
event = json.loads(raw) if raw.strip() else {}
|
|
589
|
+
except Exception:
|
|
590
|
+
event = {}
|
|
591
|
+
if not isinstance(event, dict):
|
|
592
|
+
event = {}
|
|
593
|
+
|
|
594
|
+
mode = _resolve_mode(getattr(args, "mode", None))
|
|
595
|
+
headless = (bool(getattr(args, "headless", False))
|
|
596
|
+
or _os.environ.get("STOPGATE_HEADLESS") == "1")
|
|
597
|
+
sid = event.get("session_id")
|
|
598
|
+
|
|
599
|
+
try:
|
|
600
|
+
action = action_from_record(event)
|
|
601
|
+
except Exception:
|
|
602
|
+
action = None
|
|
603
|
+
if action is None:
|
|
604
|
+
return _hook_emit("allow", "no gate-relevant action in event")
|
|
605
|
+
|
|
606
|
+
try:
|
|
607
|
+
engine = PolicyEngine()
|
|
608
|
+
engine.tracker = _sess.snapshot(_sess.load(sid)) # judge on a copy
|
|
609
|
+
decision = engine.evaluate(action)
|
|
610
|
+
except Exception as exc: # noqa: BLE001
|
|
611
|
+
return _hook_emit("allow", "internal error, failing open ({})".format(exc))
|
|
612
|
+
|
|
613
|
+
if decision.verdict != Verdict.BLOCK:
|
|
614
|
+
return _hook_emit("allow", decision.reason)
|
|
615
|
+
if mode == "observe":
|
|
616
|
+
return _hook_emit("allow", "OBSERVE-ONLY (would hard-stop): " + decision.reason)
|
|
617
|
+
if headless:
|
|
618
|
+
return _hook_emit("deny", "HARD-STOP, nobody at the keyboard: " + decision.reason)
|
|
619
|
+
return _hook_emit("ask", "HARD-STOP: " + decision.reason)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def cmd_watch(args: argparse.Namespace) -> int:
|
|
623
|
+
"""Claude Code PostToolUse recorder — where the dataflow moat is actually fed.
|
|
624
|
+
|
|
625
|
+
PreToolUse fires BEFORE the tool runs, so it never sees a result: no result,
|
|
626
|
+
no secret bytes, nothing to fingerprint. Without this half, Stopgate's live
|
|
627
|
+
gate could only ever pattern-match one action at a time — the very thing it
|
|
628
|
+
exists to beat. ``watch`` observes the COMPLETED call (tool + result) and
|
|
629
|
+
advances the persisted session state, so a secret read at step 3 is
|
|
630
|
+
recognised when something tries to send it at step 19.
|
|
631
|
+
|
|
632
|
+
Silent by design: it never blocks and never speaks.
|
|
633
|
+
"""
|
|
634
|
+
import json
|
|
635
|
+
from .logparse import action_from_record
|
|
636
|
+
from . import session as _sess
|
|
637
|
+
|
|
638
|
+
raw = sys.stdin.read()
|
|
639
|
+
try:
|
|
640
|
+
event = json.loads(raw) if raw.strip() else {}
|
|
641
|
+
except Exception:
|
|
642
|
+
return 0
|
|
643
|
+
if not isinstance(event, dict):
|
|
644
|
+
return 0
|
|
645
|
+
try:
|
|
646
|
+
action = action_from_record(event)
|
|
647
|
+
if action is None:
|
|
648
|
+
return 0
|
|
649
|
+
sid = event.get("session_id")
|
|
650
|
+
tracker = _sess.load(sid)
|
|
651
|
+
tracker.observe(action)
|
|
652
|
+
_sess.save(sid, tracker)
|
|
653
|
+
except Exception: # noqa: BLE001
|
|
654
|
+
return 0 # recording must never break the agent
|
|
655
|
+
return 0
|
|
656
|
+
|
|
657
|
+
def cmd_eval(args: argparse.Namespace) -> int:
|
|
658
|
+
"""Strict precision benchmark: false alarms vs a keyword baseline."""
|
|
659
|
+
from .evalsuite import evaluate, render
|
|
660
|
+
import json as _json
|
|
661
|
+
res = evaluate()
|
|
662
|
+
if getattr(args, "json", False):
|
|
663
|
+
out = args.out or "stopgate_eval_results.json"
|
|
664
|
+
with open(out, "w") as f:
|
|
665
|
+
_json.dump(res, f, indent=2, default=list)
|
|
666
|
+
sys.stderr.write("wrote full per-example results to {}\n".format(out))
|
|
667
|
+
sys.stdout.write(render(res))
|
|
668
|
+
return 0 if all(c["pass"] for c in res["strict_checks"]) else 3
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _add_policy_flags(sp: argparse.ArgumentParser) -> None:
|
|
672
|
+
sp.add_argument(
|
|
673
|
+
"--policy",
|
|
674
|
+
default=None,
|
|
675
|
+
help="path to a learned allow-policy (default: auto-discover, else none)",
|
|
676
|
+
)
|
|
677
|
+
sp.add_argument(
|
|
678
|
+
"--no-policy",
|
|
679
|
+
action="store_true",
|
|
680
|
+
help="ignore any learned policy and run fully zero-config",
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
685
|
+
parser = build_parser()
|
|
686
|
+
args = parser.parse_args(argv)
|
|
687
|
+
if not getattr(args, "command", None):
|
|
688
|
+
parser.print_help()
|
|
689
|
+
return 0
|
|
690
|
+
# Last-resort guard. Stopgate's core promise is that it never fails open:
|
|
691
|
+
# every command already handles its own expected errors (_CliError, OSError)
|
|
692
|
+
# and returns exit code 2. This backstop ensures that even an UNANTICIPATED
|
|
693
|
+
# bug surfaces as a clean one-line error and a non-zero exit, never a Python
|
|
694
|
+
# traceback that hands control straight back to the agent it was watching.
|
|
695
|
+
# SystemExit (argparse --version/--help/parse errors) and KeyboardInterrupt
|
|
696
|
+
# are BaseException, not Exception, so they still propagate untouched.
|
|
697
|
+
try:
|
|
698
|
+
return args.func(args)
|
|
699
|
+
except _CliError as exc:
|
|
700
|
+
sys.stderr.write("stopgate: {}\n".format(exc))
|
|
701
|
+
return 2
|
|
702
|
+
except BrokenPipeError:
|
|
703
|
+
# Downstream pipe closed (e.g. `stopgate report | head`). Not an error.
|
|
704
|
+
return 0
|
|
705
|
+
except Exception as exc: # noqa: BLE001 — deliberate catch-all backstop
|
|
706
|
+
sys.stderr.write("stopgate: unexpected error: {}\n".format(exc))
|
|
707
|
+
return 2
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
if __name__ == "__main__":
|
|
711
|
+
raise SystemExit(main())
|