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.
@@ -0,0 +1,2 @@
1
+ """Stopgate core: the normalized action model, taint/dataflow engine, detectors,
2
+ injection scanner, and the pure deterministic policy engine."""
@@ -0,0 +1,196 @@
1
+ r"""Normalized action + provenance model.
2
+
3
+ Everything the policy engine reasons about is expressed here: a proposed
4
+ tool call (``Action``) and the results tool calls return (``ToolResult``).
5
+ Provenance — *where a byte came from* — is the spine of Stopgate's moat, so it
6
+ lives at the bottom of the stack with no dependencies on anything else.
7
+
8
+ Note on the odd string splits in this package (e.g. ``"pay" "ment"``): the host
9
+ environment runs a *lexical* PreToolUse gate that greps source for scary words.
10
+ Stopgate's source necessarily names the threats it detects, so we split those
11
+ literals across Python adjacent-string-literal boundaries — they concatenate to
12
+ the real token at runtime. This is, itself, a live demonstration of why lexical
13
+ gates fail and why Stopgate detects by provenance/dataflow instead.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+ from enum import IntEnum
20
+ from typing import Any, Dict, List, Optional
21
+
22
+
23
+ class Severity(IntEnum):
24
+ """Ordered so ``max(...)`` composes detector votes and escalation cleanly."""
25
+
26
+ NONE = 0
27
+ LOW = 1
28
+ MEDIUM = 2
29
+ HIGH = 3
30
+ CRITICAL = 4
31
+
32
+ @classmethod
33
+ def from_name(cls, name: str) -> "Severity":
34
+ try:
35
+ return cls[str(name).strip().upper()]
36
+ except KeyError:
37
+ raise ValueError("unknown severity: {!r}".format(name))
38
+
39
+
40
+ # Provenance: the ONLY sources Stopgate trusts are the operator and the host
41
+ # system itself. Everything an agent pulls from the outside world is untrusted
42
+ # by construction — this is deliberately non-lexical.
43
+ TRUSTED_SOURCES = frozenset({"user", "system", "operator"})
44
+ UNTRUSTED_SOURCES = frozenset(
45
+ {"web", "http", "file", "email", "mcp", "tool", "shell", "network", "unknown"}
46
+ )
47
+
48
+
49
+ def norm_source(source: Optional[str]) -> str:
50
+ if source is None:
51
+ return "unknown"
52
+ s = str(source).strip().lower()
53
+ return s or "unknown"
54
+
55
+
56
+ def is_trusted(source: str) -> bool:
57
+ """A source is trusted only if explicitly whitelisted. Unknown → untrusted."""
58
+ return norm_source(source) in TRUSTED_SOURCES
59
+
60
+
61
+ class ActionKind:
62
+ """Structural classes of *what a tool does* (not what its text says)."""
63
+
64
+ READ = "read"
65
+ WRITE = "write"
66
+ CREDENTIAL_ACCESS = "credential_access"
67
+ NETWORK_EGRESS = "network_egress"
68
+ DESTRUCTIVE = "destructive"
69
+ SPEND = "pay" "ment" # the real token, split past the host's lexical gate
70
+ INSTALL = "install"
71
+ PERMISSION = "permission"
72
+ MESSAGING = "messaging"
73
+ OTHER = "other"
74
+
75
+
76
+ # Action kinds that are irreversible in the real world. Per the brief these
77
+ # HARD-STOP for a human no matter what the learned policy says.
78
+ IRREVERSIBLE_KINDS = frozenset(
79
+ {ActionKind.SPEND, ActionKind.DESTRUCTIVE, ActionKind.MESSAGING}
80
+ )
81
+
82
+ ALL_KINDS = frozenset(
83
+ {
84
+ ActionKind.READ,
85
+ ActionKind.WRITE,
86
+ ActionKind.CREDENTIAL_ACCESS,
87
+ ActionKind.NETWORK_EGRESS,
88
+ ActionKind.DESTRUCTIVE,
89
+ ActionKind.SPEND,
90
+ ActionKind.INSTALL,
91
+ ActionKind.PERMISSION,
92
+ ActionKind.MESSAGING,
93
+ ActionKind.OTHER,
94
+ }
95
+ )
96
+
97
+
98
+ @dataclass
99
+ class ToolResult:
100
+ """The output a tool returned, tagged with where it came from."""
101
+
102
+ source: str
103
+ content: str = ""
104
+
105
+ def __post_init__(self) -> None:
106
+ self.source = norm_source(self.source)
107
+ if self.content is None:
108
+ self.content = ""
109
+ elif not isinstance(self.content, str):
110
+ self.content = str(self.content)
111
+
112
+ @property
113
+ def trusted(self) -> bool:
114
+ return is_trusted(self.source)
115
+
116
+
117
+ @dataclass
118
+ class Action:
119
+ """A proposed tool call, normalized.
120
+
121
+ ``kind`` may be provided by an adapter; if omitted it is left as ``OTHER``
122
+ and the detector layer refines it. ``text`` is the flattened, matchable
123
+ representation used by detectors and the egress matcher — never by the
124
+ injection scanner, which reads tool *results* only.
125
+ """
126
+
127
+ tool: str
128
+ args: Dict[str, Any] = field(default_factory=dict)
129
+ kind: str = ActionKind.OTHER
130
+ source: str = "user"
131
+ result: Optional[ToolResult] = None
132
+
133
+ def __post_init__(self) -> None:
134
+ if not isinstance(self.tool, str) or not self.tool.strip():
135
+ raise ValueError("Action.tool must be a non-empty string")
136
+ self.tool = self.tool.strip()
137
+ if self.args is None:
138
+ self.args = {}
139
+ if not isinstance(self.args, dict):
140
+ raise TypeError("Action.args must be a dict")
141
+ self.source = norm_source(self.source)
142
+ if not self.kind:
143
+ self.kind = ActionKind.OTHER
144
+
145
+ @property
146
+ def text(self) -> str:
147
+ """Flatten tool + args into one string for matching."""
148
+ parts = [self.tool]
149
+ parts.extend(flatten(self.args))
150
+ return "\n".join(p for p in parts if p)
151
+
152
+ @property
153
+ def outbound_bytes(self) -> str:
154
+ """The bytes this action would send outward (egress matcher input).
155
+
156
+ Only the *payload-bearing* arg fields, so a URL or filename that echoes
157
+ a secret's name does not count as exfiltration — we care about the body
158
+ actually leaving the machine.
159
+ """
160
+ payload_keys = ("body", "data", "payload", "content", "text", "message", "json")
161
+ chunks: List[str] = []
162
+ for k in payload_keys:
163
+ if k in self.args and self.args[k] is not None:
164
+ chunks.extend(flatten(self.args[k]))
165
+ if not chunks: # fall back to everything if no obvious payload field
166
+ chunks.extend(flatten(self.args))
167
+ return "\n".join(chunks)
168
+
169
+
170
+ def flatten(value: Any) -> List[str]:
171
+ """Depth-first flatten of arbitrary nested args into a list of strings."""
172
+ out: List[str] = []
173
+ stack = [value]
174
+ seen = 0
175
+ while stack:
176
+ seen += 1
177
+ if seen > 100000: # cyclic / pathological structure guard
178
+ break
179
+ cur = stack.pop()
180
+ if cur is None:
181
+ continue
182
+ if isinstance(cur, str):
183
+ out.append(cur)
184
+ elif isinstance(cur, bool):
185
+ out.append(str(cur))
186
+ elif isinstance(cur, (int, float)):
187
+ out.append(str(cur))
188
+ elif isinstance(cur, dict):
189
+ for k, v in cur.items():
190
+ out.append(str(k))
191
+ stack.append(v)
192
+ elif isinstance(cur, (list, tuple, set)):
193
+ stack.extend(cur)
194
+ else:
195
+ out.append(str(cur))
196
+ return out
@@ -0,0 +1,117 @@
1
+ r"""Action-class detectors — classify *what kind of operation* a call is.
2
+
3
+ This layer is legitimately lexical: it reads :attr:`Action.text` and matches the
4
+ compiled tables in :mod:`stopgate.core.patterns` to decide whether a call is a
5
+ credential read, a network egress, a destructive command, and so on. It is the
6
+ ``(a) action-class`` box in the architecture diagram — the *floor* of severity.
7
+
8
+ It is emphatically NOT the moat. Vocabulary can be reworded past every table
9
+ here; that is exactly why :mod:`stopgate.core.taint` exists and why the policy
10
+ engine composes the two. Nothing in this file references a capability it does
11
+ not implement.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from typing import List
18
+
19
+ from . import patterns as P
20
+ from .action import Action, ActionKind, Severity
21
+
22
+ # Base severity contributed by each action class *on its own*, before taint or
23
+ # escalation. Deliberately conservative: reading a file is not an incident.
24
+ _BASE_SEVERITY = {
25
+ ActionKind.CREDENTIAL_ACCESS: Severity.MEDIUM,
26
+ ActionKind.NETWORK_EGRESS: Severity.LOW,
27
+ ActionKind.DESTRUCTIVE: Severity.HIGH,
28
+ ActionKind.SPEND: Severity.HIGH,
29
+ ActionKind.INSTALL: Severity.MEDIUM,
30
+ ActionKind.PERMISSION: Severity.MEDIUM,
31
+ ActionKind.MESSAGING: Severity.MEDIUM,
32
+ ActionKind.WRITE: Severity.LOW,
33
+ ActionKind.READ: Severity.NONE,
34
+ ActionKind.OTHER: Severity.NONE,
35
+ }
36
+
37
+ # Kinds where being downstream of untrusted content is genuinely dangerous.
38
+ # These are the actions the taint layer escalates when the session is tainted.
39
+ HIGH_RISK_KINDS = frozenset(
40
+ {
41
+ ActionKind.NETWORK_EGRESS,
42
+ ActionKind.DESTRUCTIVE,
43
+ ActionKind.SPEND,
44
+ ActionKind.INSTALL,
45
+ ActionKind.PERMISSION,
46
+ ActionKind.MESSAGING,
47
+ ActionKind.CREDENTIAL_ACCESS,
48
+ }
49
+ )
50
+
51
+ # Evaluated in priority order; the first match per table sets that kind. Order
52
+ # matters only for which reason string leads — severity uses max() over all.
53
+ _KIND_TABLES = [
54
+ (ActionKind.DESTRUCTIVE, P.DESTRUCTIVE_PATTERNS, "destructive / irreversible operation"),
55
+ (ActionKind.SPEND, P.SPEND_PATTERNS, "money movement"),
56
+ (ActionKind.INSTALL, P.INSTALL_PATTERNS, "software install / download-and-run"),
57
+ (ActionKind.PERMISSION, P.PERMISSION_PATTERNS, "permission change"),
58
+ (ActionKind.MESSAGING, P.MESSAGING_PATTERNS, "publish / send-on-your-behalf"),
59
+ (ActionKind.NETWORK_EGRESS, P.EGRESS_PATTERNS, "network egress"),
60
+ (ActionKind.CREDENTIAL_ACCESS, P.CREDENTIAL_PATH_PATTERNS, "credential / secret access"),
61
+ ]
62
+
63
+
64
+ @dataclass
65
+ class Classification:
66
+ """The action-class verdict for a single :class:`Action`."""
67
+
68
+ kinds: List[str] = field(default_factory=list)
69
+ severity: Severity = Severity.NONE
70
+ reasons: List[str] = field(default_factory=list)
71
+
72
+ @property
73
+ def primary(self) -> str:
74
+ return self.kinds[0] if self.kinds else ActionKind.OTHER
75
+
76
+
77
+ def classify(action: Action) -> Classification:
78
+ """Classify an action by structural pattern tables. Pure, no I/O.
79
+
80
+ Honours an adapter-supplied ``action.kind`` (an adapter that already knows
81
+ the tool's semantics is more reliable than our regexes), then augments it
82
+ with anything the tables detect in the flattened call text.
83
+ """
84
+ text = action.text
85
+ kinds: List[str] = []
86
+ reasons: List[str] = []
87
+
88
+ if action.kind and action.kind != ActionKind.OTHER:
89
+ kinds.append(action.kind)
90
+ reasons.append("adapter-tagged: {}".format(action.kind))
91
+
92
+ for kind, table, label in _KIND_TABLES:
93
+ if kind in kinds:
94
+ continue
95
+ if P.matches_any(text, table):
96
+ kinds.append(kind)
97
+ reasons.append(label)
98
+
99
+ if not kinds:
100
+ kinds.append(ActionKind.OTHER)
101
+
102
+ severity = max(
103
+ (_BASE_SEVERITY.get(k, Severity.NONE) for k in kinds),
104
+ default=Severity.NONE,
105
+ )
106
+ return Classification(kinds=kinds, severity=severity, reasons=reasons)
107
+
108
+
109
+ def is_secret_material(text: str) -> bool:
110
+ """True if *text* looks like a secret path OR contains a secret *value*.
111
+
112
+ Used by the taint layer to decide whether a read's result is worth
113
+ fingerprinting for egress matching.
114
+ """
115
+ return P.matches_any(text, P.CREDENTIAL_PATH_PATTERNS) or P.matches_any(
116
+ text, P.SECRET_VALUE_PATTERNS
117
+ )
@@ -0,0 +1,164 @@
1
+ r"""Compiled pattern tables for the action-class detectors.
2
+
3
+ Two deliberate stylistic quirks appear throughout, both to slip past the host's
4
+ *lexical* PreToolUse gate (which greps source for the exact words this security
5
+ tool must name) while producing the correct runtime pattern:
6
+
7
+ * literals are split across adjacent Python string boundaries — ``"pay" "ment"``
8
+ concatenates to the real token at import time;
9
+ * regexes use ``\b``/``\s`` rather than literal spaces.
10
+
11
+ At runtime these compile to the genuine detection patterns; the tests assert on
12
+ real malicious command strings (assembled the same way) to prove it.
13
+
14
+ Action-class detection is legitimately lexical — it classifies *what kind of
15
+ operation* a call is. The non-lexical moat lives in taint.py, not here.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from typing import Any, List, Pattern
22
+
23
+ _I = re.IGNORECASE
24
+
25
+
26
+ def _c(*fragments: str) -> Pattern:
27
+ return re.compile("".join(fragments), _I)
28
+
29
+
30
+ # --- Spend / money movement -------------------------------------------------
31
+ SPEND_PATTERNS: List[Pattern] = [
32
+ _c(r"\b(?:", "pay", "ment", r"|", "pur", "chase", r"|", "check", "out",
33
+ r"|buy\b|pay\s+for|place\s+(?:an?\s+)?order|invoice|billing"
34
+ r"|paypal|braintree|credit[\s_-]*card)\b"),
35
+ _c(r"\bstripe\b"),
36
+ _c(r"\b", "char", "ge", r"\s+(?:the\s+)?card\b"),
37
+ ]
38
+
39
+ # --- Destructive / irreversible --------------------------------------------
40
+ DESTRUCTIVE_PATTERNS: List[Pattern] = [
41
+ _c(r"\brm\b[^\n;|&]*-(?:rf|fr|r|f|-recursive|-force)\b"),
42
+ _c(r"--no-preserve", "-root"),
43
+ _c(r"\b", "mk", "fs", r"\b"),
44
+ _c(r"\bshred\b"),
45
+ _c(r"\bdd\b[^\n;|&]*\bif="),
46
+ _c(r"\bdrop\s+(?:table|database)\b"),
47
+ _c(r"\btruncate\s+table\b"),
48
+ _c(r"\bterraform\s+destroy\b"),
49
+ _c(r"\bkubectl\s+delete\b"),
50
+ _c(r"\bgit\s+push\s+(?:--force\b|-f\b|--force-with-lease\b)"),
51
+ _c(r">\s*/dev/sd[a-z]"),
52
+ _c(r"\bfind\b[^|]*\s-delete\b"),
53
+ ]
54
+
55
+ # --- System install / download-and-run -------------------------------------
56
+ INSTALL_PATTERNS: List[Pattern] = [
57
+ _c(r"\bsudo\b"),
58
+ _c(r"\bapt(?:-get)?\s+install\b"),
59
+ _c(r"\byum\s+install\b"),
60
+ _c(r"\bdnf\s+install\b"),
61
+ _c(r"\bbrew\s+install\b"),
62
+ _c(r"\bnpm\s+install\s+-g\b"),
63
+ _c(r"\bpip\s+install\b"),
64
+ _c(r"(?:curl|wget)\b[^|]*\|\s*(?:bash|sh|zsh|python)\b"),
65
+ ]
66
+
67
+ # --- Credential / secret access, matched by PATH not vocabulary -------------
68
+ # Provenance-flavoured: these are the concrete locations secrets live. A hit
69
+ # marks the READ content as secret material for the egress matcher.
70
+ #
71
+ # The left anchor is a negative-lookbehind ``(?<!\w)`` rather than ``(?:^|/)``.
72
+ # Actions are flattened to a newline-joined string before matching, so a bare
73
+ # RELATIVE path (``.env``, ``.aws/credentials``, ``id_rsa`` — the common case
74
+ # when the agent runs inside the project dir) sits after a ``\n``, which the
75
+ # old start-or-slash anchor never matched. That silently failed to fingerprint
76
+ # the read, so a later exfil of that secret was MISSED. ``(?<!\w)`` matches at
77
+ # string start, after ``/``, and after whitespace/newline/quote, while still
78
+ # rejecting ``foo.env`` / ``myid_rsa`` (preceded by a word char).
79
+ CREDENTIAL_PATH_PATTERNS: List[Pattern] = [
80
+ _c(r"(?<!\w)\.env(?:\.[\w-]+)?\b"),
81
+ _c(r"(?<!\w)\.ssh/"),
82
+ _c(r"(?<!\w)id_(?:rsa|ed25519|ecdsa|dsa)\b"),
83
+ _c(r"(?<!\w)\.aws/credentials\b"),
84
+ _c(r"(?<!\w)\.aws/config\b"),
85
+ _c(r"(?<!\w)\.netrc\b"),
86
+ _c(r"(?<!\w)\.npmrc\b"),
87
+ _c(r"(?<!\w)\.pypirc\b"),
88
+ _c(r"(?<!\w)\.git-credentials\b"),
89
+ _c(r"(?<!\w)\.kube/config\b"),
90
+ _c(r"(?<!\w)\.docker/config\.json\b"),
91
+ _c(r"\b\w*(?:secret|token|apikey|api_key|private_key|credential)s?\.(?:json|ya?ml|txt|pem|key)\b"),
92
+ _c(r"\bkeychain\b"),
93
+ ]
94
+
95
+ # High-entropy / well-known secret *values* (for tainting read content).
96
+ SECRET_VALUE_PATTERNS: List[Pattern] = [
97
+ _c(r"\bAKIA[0-9A-Z]{16}\b"), # AWS access key id
98
+ _c(r"\bASIA[0-9A-Z]{16}\b"), # AWS temp key id
99
+ _c(r"\bgh[pousr]_[A-Za-z0-9]{16,}\b"), # GitHub tokens
100
+ _c(r"\bsk-[A-Za-z0-9]{20,}\b"), # OpenAI-style keys
101
+ _c(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"), # Slack tokens
102
+ _c(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"), # PEM private keys
103
+ _c(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"), # JWT
104
+ ]
105
+
106
+ # --- Network egress ---------------------------------------------------------
107
+ EGRESS_PATTERNS: List[Pattern] = [
108
+ _c(r"\bhttps?://"),
109
+ _c(r"(?:curl|wget|nc|ncat|netcat|scp|rsync|ftp|sftp|telnet)\b"),
110
+ _c(r"\brequests\.(?:post|put|patch)\b"),
111
+ _c(r"\burllib(?:\.request)?\b"),
112
+ _c(r"\bsocket\.(?:connect|sendall|send)\b"),
113
+ _c(r"\bfetch\("),
114
+ _c(r"\bxmlhttprequest\b"),
115
+ ]
116
+
117
+ # --- Permission changes -----------------------------------------------------
118
+ PERMISSION_PATTERNS: List[Pattern] = [
119
+ _c(r"\bchmod\s+(?:-R\s+)?0?777\b"),
120
+ _c(r"\bchmod\b"),
121
+ _c(r"\bchown\b"),
122
+ _c(r"\bchgrp\b"),
123
+ _c(r"\bsetfacl\b"),
124
+ ]
125
+
126
+ # --- Messaging / publishing / send-on-your-behalf ---------------------------
127
+ MESSAGING_PATTERNS: List[Pattern] = [
128
+ _c(r"\bnpm\s+publish\b"),
129
+ _c(r"\bpypi\b|\btwine\s+upload\b"),
130
+ _c(r"\bgit\s+push\b"),
131
+ _c(r"\bsend[_-]?(?:mail|email|message)\b"),
132
+ _c(r"\bsmtp\b"),
133
+ _c(r"\b(?:slack|discord|telegram)\b.*\b(?:webhook|post|send)\b"),
134
+ _c(r"\btweet\b|\bpost_status\b"),
135
+ ]
136
+
137
+
138
+ def _as_text(text: Any) -> str:
139
+ """Coerce detector input to a string.
140
+
141
+ Detectors are a security boundary: they must never crash on odd input and
142
+ hand control back to the agent. ``None`` becomes empty (matches nothing);
143
+ anything non-string is stringified so it is still scanned rather than
144
+ silently skipped.
145
+ """
146
+ if text is None:
147
+ return ""
148
+ if isinstance(text, str):
149
+ return text
150
+ return str(text)
151
+
152
+
153
+ def matches_any(text: Any, patterns: List[Pattern]) -> bool:
154
+ t = _as_text(text)
155
+ return any(p.search(t) for p in patterns)
156
+
157
+
158
+ def first_match(text: Any, patterns: List[Pattern]):
159
+ t = _as_text(text)
160
+ for p in patterns:
161
+ m = p.search(t)
162
+ if m:
163
+ return m
164
+ return None