firstwatch 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.
firstwatch/__init__.py ADDED
@@ -0,0 +1,51 @@
1
+ """firstwatch: first line of defence for AI agents.
2
+
3
+ from firstwatch import scan, watch
4
+
5
+ scan(page_html) # {"level": "clean"|"note"|"stop", "findings": [...]}
6
+ watch(page_html, url=url) # scans, saves a capture on a stop, returns the result + "saved"
7
+
8
+ Same rules and capture format as the Node package (https://github.com/dave8172/firstwatch).
9
+ """
10
+
11
+ from ._rules import RULES_VERSION
12
+ from .capture import SPEC, VERSION, build, capture_dir, to_otlp, validate, write_capture, write_note
13
+ from .redact import redact
14
+ from .scan import scan
15
+
16
+ __version__ = VERSION
17
+ __all__ = ["scan", "watch", "build", "validate", "write_capture", "write_note", "to_otlp", "redact", "capture_dir", "SPEC", "RULES_VERSION", "__version__"]
18
+
19
+
20
+ def watch(text, *, url=None, file=None, tool=None, kind=None, task=None, steps=None, session_id=None, agent_name=None, dir=None, raw=None):
21
+ """Scan one piece of outside content and record what it finds.
22
+
23
+ A stop is saved as a capture folder, a note as one line in notes.jsonl.
24
+ Returns the scan result plus "saved" (the folder or notes file, or None when clean).
25
+ Your code decides what to do next; on a stop, don't pass the content on as instructions.
26
+ """
27
+ result = scan(text)
28
+ result["saved"] = None
29
+ if result["level"] == "clean":
30
+ return result
31
+ from .capture import primary
32
+
33
+ top = primary(result["findings"])
34
+ repro = [f"Fetch {url} as raw HTML (no JavaScript)." if url else f"Open {file}." if file else "Feed the same content to the agent."]
35
+ repro.append(f'Find the text: "{top["excerpt"][:160]}"')
36
+ rec = build({
37
+ "level": result["level"],
38
+ "findings": result["findings"],
39
+ "source": {"kind": kind or ("web" if url else "file" if file else "tool" if tool else "other"), "url": url, "file": file, "tool": tool, "where": top["where"]},
40
+ "payload": {"text": top["excerpt"], "hidden": top["hidden"], "hidden_how": top["hidden_how"]},
41
+ "caught_by": "scanner",
42
+ "outcome": "blocked" if result["level"] == "stop" else "unknown",
43
+ "trace": {"task": task, "steps": steps or []},
44
+ "repro": repro,
45
+ "context": {"session_id": session_id, "agent_name": agent_name},
46
+ })
47
+ if result["level"] == "stop":
48
+ result["saved"] = write_capture(rec, dir=dir, raw=raw if raw is not None else text, raw_kind="html" if "<" in (raw or text) else "text")
49
+ else:
50
+ result["saved"] = write_note(rec, dir=dir)
51
+ return result
firstwatch/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
firstwatch/_rules.py ADDED
@@ -0,0 +1,37 @@
1
+ """Loads the shared detection rules (rules.json, the same file the Node package uses)."""
2
+
3
+ import json
4
+ import re
5
+ from importlib import resources
6
+
7
+ _data = json.loads(resources.files("firstwatch").joinpath("rules.json").read_text(encoding="utf-8"))
8
+
9
+
10
+ def _rx(p):
11
+ # re.ASCII keeps \w and \b the same as JavaScript regexes without the u flag.
12
+ flags = re.ASCII
13
+ if "i" in p["flags"]:
14
+ flags |= re.IGNORECASE
15
+ return re.compile(p["source"], flags)
16
+
17
+
18
+ RULES_VERSION = _data["version"]
19
+ ATLAS = _data["atlas"]
20
+ OWASP = _data["owasp"]
21
+ AGENT_RE = _rx(_data["agent"])
22
+ HIDDEN_STYLE = [(_rx(h), h["label"]) for h in _data["hidden_style"]]
23
+ DISCUSSION_RE = _rx(_data["discussion"])
24
+ DISCUSSION_MIN = _data["discussion"]["min"]
25
+ PRIORITY = _data["priority"]
26
+ BUILTIN = {b["id"]: b for b in _data["builtin"]}
27
+
28
+ RULES = []
29
+ for _r in _data["rules"]:
30
+ _rule = dict(_r)
31
+ if "pattern" in _r:
32
+ _rule["re"] = _rx(_r["pattern"])
33
+ else:
34
+ _rule["agent_re"] = _rx(_r["agent"])
35
+ _rule["imperative_re"] = _rx(_r["imperative"])
36
+ _rule["danger_re"] = _rx(_r["danger"])
37
+ RULES.append(_rule)
firstwatch/capture.py ADDED
@@ -0,0 +1,262 @@
1
+ """Build, check and write capture records (format: SPEC.md, firstwatch/0.1). Mirrors src/capture.js."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import secrets
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from urllib.parse import urlparse
10
+
11
+ from ._rules import ATLAS, OWASP, PRIORITY
12
+ from .redact import redact, redact_deep
13
+
14
+ SPEC = "firstwatch/0.1"
15
+ VERSION = "0.1.0"
16
+ LEVELS = ["stop", "note"]
17
+ OUTCOMES = ["blocked", "followed", "partial", "unknown"]
18
+ CAUGHT_BY = ["hook", "agent", "scanner", "owner"]
19
+ KINDS = ["web", "file", "command", "tool", "search", "message", "email", "other"]
20
+ MAX_EVIDENCE = 2_000_000
21
+ _TIME = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d(:\d\d(\.\d+)?)?(Z|[+-]\d\d:\d\d)$")
22
+
23
+
24
+ def primary(findings):
25
+ """Most serious first: what the attack does, then how it hides."""
26
+ def rank(f):
27
+ i = PRIORITY.index(f["rule"]) + 1 if f.get("rule") in PRIORITY else 50
28
+ return (0 if f.get("level") == "stop" else 100) + i
29
+ return sorted(findings, key=rank)[0] if findings else None
30
+
31
+
32
+ def capture_dir(cwd=None):
33
+ return Path(os.environ.get("FIRSTWATCH_DIR") or Path(cwd or os.getcwd()) / "security-captures").resolve()
34
+
35
+
36
+ def iso_local(d=None):
37
+ """ISO 8601 in local time with the UTC offset, e.g. 2026-10-29T14:05:00.123+05:30."""
38
+ return (d or datetime.now()).astimezone().isoformat(timespec="milliseconds")
39
+
40
+
41
+ def _slug(s):
42
+ s = re.sub(r"^www\.", "", str(s or "unknown").lower())
43
+ return re.sub(r"[^a-z0-9]+", "-", s).strip("-")[:40] or "unknown"
44
+
45
+
46
+ def _host(url):
47
+ try:
48
+ return urlparse(url).hostname if url else None
49
+ except ValueError:
50
+ return None
51
+
52
+
53
+ def validate(rec):
54
+ """Every problem at once, not the first. Returns a list of strings (empty = valid)."""
55
+ if not isinstance(rec, dict):
56
+ return ["record must be an object"]
57
+ p = []
58
+
59
+ def need(cond, msg):
60
+ if not cond:
61
+ p.append(msg)
62
+
63
+ src, pay = rec.get("source") or {}, rec.get("payload") or {}
64
+ need(rec.get("spec") == SPEC, f'spec must be "{SPEC}"')
65
+ need(isinstance(rec.get("id"), str) and rec["id"], "id is required")
66
+ need(isinstance(rec.get("time"), str) and _TIME.match(rec["time"]), "time must be ISO 8601 with a UTC offset")
67
+ need(rec.get("level") in LEVELS, f"level must be one of {', '.join(LEVELS)}")
68
+ need(isinstance(rec.get("summary"), str) and rec["summary"].strip(), "summary is required")
69
+ need(src.get("kind") in KINDS, f"source.kind must be one of {', '.join(KINDS)}")
70
+ need(isinstance(pay.get("text"), str) and pay["text"], "payload.text (the exact text) is required")
71
+ need(isinstance(pay.get("hidden"), bool), "payload.hidden must be true or false")
72
+ need(isinstance(rec.get("findings"), list), "findings must be a list")
73
+ need(rec.get("outcome") in OUTCOMES, f"outcome must be one of {', '.join(OUTCOMES)}")
74
+ need(rec.get("caught_by") in CAUGHT_BY, f"caught_by must be one of {', '.join(CAUGHT_BY)}")
75
+ need(isinstance(rec.get("repro"), list), "repro must be a list of steps")
76
+ return p
77
+
78
+
79
+ def build(data):
80
+ """Turn partial input (from a hook, an agent or your code) into a full record. Redacts as it goes."""
81
+ now = datetime.now(timezone.utc)
82
+ findings = data.get("findings") or []
83
+ top = primary(findings) or {}
84
+ source = data.get("source") or {}
85
+ payload_in = data.get("payload") or {}
86
+ url = source.get("url")
87
+ text, n1 = redact(payload_in.get("text") or top.get("excerpt") or "", keep_emails=True)
88
+ trace, n2 = redact_deep(data.get("trace") or {})
89
+ intent = data.get("intent") or top.get("what") or ""
90
+ hidden = payload_in.get("hidden", bool(top.get("hidden")))
91
+ where = f" on {_host(url)}" if url else (f" in {os.path.basename(source['file'])}" if source.get("file") else "")
92
+ rec = {
93
+ "spec": SPEC,
94
+ "id": f"fw_{now.strftime('%Y%m%dT%H%M%S')}_{secrets.token_hex(3)}",
95
+ "time": data.get("time") or iso_local(),
96
+ "level": data.get("level") or ("stop" if any(f.get("level") == "stop" for f in findings) else "note"),
97
+ "summary": data.get("summary") or f"{'Hidden text' if hidden else 'Text'}{where}: {intent or 'targets AI agents'}",
98
+ "source": {
99
+ "kind": source.get("kind") or ("web" if url else "other"),
100
+ "url": url,
101
+ "tool": source.get("tool"),
102
+ "where": source.get("where") or top.get("where"),
103
+ "file": source.get("file"),
104
+ },
105
+ "payload": {"text": text, "hidden": hidden, "hidden_how": payload_in.get("hidden_how", top.get("hidden_how", []))},
106
+ "findings": findings,
107
+ "intent": intent,
108
+ "outcome": data.get("outcome") or "unknown",
109
+ "caught_by": data.get("caught_by") or "agent",
110
+ "trace": trace,
111
+ "repro": data.get("repro") or [],
112
+ "evidence": [],
113
+ "redactions": n1 + n2,
114
+ }
115
+ if data.get("notes"):
116
+ rec["notes"] = redact(data["notes"])[0]
117
+ rec["attributes"] = _attributes(rec, data.get("context") or {})
118
+ return rec
119
+
120
+
121
+ def _attributes(rec, ctx):
122
+ """Flat attributes: OpenTelemetry names where a convention exists, firstwatch.* where none does."""
123
+ def uniq(xs):
124
+ return list(dict.fromkeys(xs))
125
+
126
+ a = {
127
+ "event.name": "firstwatch.capture",
128
+ "firstwatch.spec": SPEC,
129
+ "firstwatch.capture.id": rec["id"],
130
+ "firstwatch.level": rec["level"],
131
+ "firstwatch.outcome": rec["outcome"],
132
+ "firstwatch.caught_by": rec["caught_by"],
133
+ "firstwatch.hidden": rec["payload"]["hidden"],
134
+ "firstwatch.rules": uniq(f["rule"] for f in rec["findings"]),
135
+ "firstwatch.atlas.techniques": uniq(t for f in rec["findings"] for t in f.get("atlas", [])),
136
+ "firstwatch.owasp.ids": uniq(t for f in rec["findings"] for t in f.get("owasp", [])),
137
+ }
138
+ if rec["source"]["url"]:
139
+ a["url.full"] = rec["source"]["url"]
140
+ if _host(rec["source"]["url"]):
141
+ a["server.address"] = _host(rec["source"]["url"])
142
+ if ctx.get("session_id"):
143
+ a["gen_ai.conversation.id"] = ctx["session_id"]
144
+ if rec["source"]["tool"]:
145
+ a["gen_ai.operation.name"] = "execute_tool"
146
+ a["gen_ai.tool.name"] = rec["source"]["tool"]
147
+ if ctx.get("tool_use_id"):
148
+ a["gen_ai.tool.call.id"] = ctx["tool_use_id"]
149
+ if ctx.get("agent_name"):
150
+ a["gen_ai.agent.name"] = ctx["agent_name"]
151
+ return a
152
+
153
+
154
+ def _readme(rec):
155
+ s, p = rec["source"], rec["payload"]
156
+ via = f" (via {s['tool']})" if s.get("tool") else ""
157
+ lines = [
158
+ f"# {rec['level'].upper()}: {rec['summary']}",
159
+ "",
160
+ f"- **Time:** {rec['time']}",
161
+ f"- **Source:** {s.get('url') or s.get('file') or s['kind']}{via}",
162
+ f"- **Where:** {s.get('where') or 'not recorded'}",
163
+ f"- **Hidden from people:** {('yes (' + (', '.join(p['hidden_how']) or 'how not recorded') + ')') if p['hidden'] else 'no'}",
164
+ f"- **What it tried:** {rec['intent'] or 'not recorded'}",
165
+ f"- **Outcome:** {rec['outcome']} · **caught by:** {rec['caught_by']}",
166
+ "",
167
+ "## Exact text",
168
+ "",
169
+ "```text",
170
+ p["text"].replace("```", "'''"),
171
+ "```",
172
+ "",
173
+ ]
174
+ if rec["findings"]:
175
+ lines += ["## Findings", "", "| Rule | Level | Where | ATLAS | OWASP |", "|---|---|---|---|---|"]
176
+ for f in rec["findings"]:
177
+ atlas = "; ".join(f"{t} {ATLAS.get(t, '')}".strip() for t in f.get("atlas", []))
178
+ owasp = "; ".join(f"{t} {OWASP.get(t, '')}".strip() for t in f.get("owasp", []))
179
+ lines.append(f"| {f['rule']} | {f['level']} | {f.get('where') or ''} | {atlas} | {owasp} |")
180
+ lines.append("")
181
+ if rec["repro"]:
182
+ lines += ["## Reproduce", ""] + [f"{i + 1}. {x}" for i, x in enumerate(rec["repro"])] + [""]
183
+ steps = (rec.get("trace") or {}).get("steps") or []
184
+ if steps:
185
+ lines += ["## Trace", ""]
186
+ if rec["trace"].get("task"):
187
+ lines += [f"Task: {rec['trace']['task']}", ""]
188
+ lines += [f"{i + 1}. `{x.get('tool')}` {x.get('summary') or ''}".strip() for i, x in enumerate(steps)] + [""]
189
+ if rec["evidence"]:
190
+ lines += ["## Evidence", ""] + [f"- {e}" for e in rec["evidence"]] + [""]
191
+ if rec.get("notes"):
192
+ lines += ["## Notes", "", rec["notes"], ""]
193
+ lines += ["---", "", f"Recorded by firstwatch {VERSION}. {rec['redactions']} value(s) masked before saving. Raw page source is stored as .txt so it never opens in a browser by mistake. Keep this folder private unless you choose to share it.", ""]
194
+ return "\n".join(lines)
195
+
196
+
197
+ def _append(path, obj):
198
+ with open(path, "a", encoding="utf-8") as f:
199
+ f.write(json.dumps(obj, ensure_ascii=False) + "\n")
200
+
201
+
202
+ def write_capture(rec, dir=None, raw=None, raw_kind="html"):
203
+ """Write a stop capture as a folder. Returns the folder path."""
204
+ problems = validate(rec)
205
+ if problems:
206
+ raise ValueError("invalid capture:\n- " + "\n- ".join(problems))
207
+ d = Path(dir) if dir else capture_dir()
208
+ where = _slug(_host(rec["source"].get("url")) or (rec["source"].get("file") and os.path.basename(rec["source"]["file"])) or rec["source"].get("tool") or rec["source"]["kind"])
209
+ name = _slug((primary(rec["findings"]) or {}).get("rule") or "capture")
210
+ folder = d / f"{rec['time'][:10]}-{where}-{name}"
211
+ n = 2
212
+ while folder.exists():
213
+ folder = d / f"{rec['time'][:10]}-{where}-{name}-{n}"
214
+ n += 1
215
+ folder.mkdir(parents=True)
216
+ if raw:
217
+ file = "raw.html.txt" if raw_kind == "html" else "raw.txt"
218
+ masked, count = redact(raw[:MAX_EVIDENCE], keep_emails=True)
219
+ (folder / file).write_text(masked, encoding="utf-8")
220
+ rec["evidence"].append(file)
221
+ rec["redactions"] += count
222
+ (folder / "capture.json").write_text(json.dumps(rec, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
223
+ (folder / "README.md").write_text(_readme(rec), encoding="utf-8")
224
+ _append(d / "index.jsonl", {"time": rec["time"], "level": rec["level"], "id": rec["id"], "folder": str(folder), "summary": rec["summary"]})
225
+ return str(folder)
226
+
227
+
228
+ def write_note(rec, dir=None):
229
+ """A note is one line in notes.jsonl, not a folder."""
230
+ d = Path(dir) if dir else capture_dir()
231
+ d.mkdir(parents=True, exist_ok=True)
232
+ _append(d / "notes.jsonl", {"time": rec["time"], "level": "note", "id": rec["id"], "source": rec["source"], "rules": [f["rule"] for f in rec["findings"]], "excerpt": rec["payload"]["text"][:300], "hidden_how": rec["payload"]["hidden_how"]})
233
+ return str(d / "notes.jsonl")
234
+
235
+
236
+ def to_otlp(rec):
237
+ """One OTLP/JSON log record, ready to POST to a collector's /v1/logs. No network here."""
238
+ def value(v):
239
+ if isinstance(v, list):
240
+ return {"arrayValue": {"values": [value(x) for x in v]}}
241
+ if isinstance(v, bool):
242
+ return {"boolValue": v}
243
+ if isinstance(v, (int, float)):
244
+ return {"doubleValue": v}
245
+ return {"stringValue": str(v)}
246
+
247
+ t = datetime.fromisoformat(rec["time"].replace("Z", "+00:00"))
248
+ stop = rec["level"] == "stop"
249
+ return {"resourceLogs": [{
250
+ "resource": {"attributes": [{"key": "service.name", "value": {"stringValue": "firstwatch"}}]},
251
+ "scopeLogs": [{
252
+ "scope": {"name": "firstwatch", "version": VERSION},
253
+ "logRecords": [{
254
+ "timeUnixNano": str(int(t.timestamp() * 1000) * 1_000_000),
255
+ "severityNumber": 13 if stop else 9,
256
+ "severityText": "WARN" if stop else "INFO",
257
+ "eventName": "firstwatch.capture",
258
+ "body": {"stringValue": rec["summary"]},
259
+ "attributes": [{"key": k, "value": value(v)} for k, v in (rec.get("attributes") or {}).items()],
260
+ }],
261
+ }],
262
+ }]}
firstwatch/cli.py ADDED
@@ -0,0 +1,116 @@
1
+ """firstwatch command line (Python). Same commands as the Node CLI, minus the Claude Code hook."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from . import VERSION, build, capture_dir, scan, to_otlp, validate, write_capture, write_note
10
+
11
+ HELP = f"""firstwatch {VERSION}: watch, record and stop attacks aimed at AI agents
12
+
13
+ firstwatch scan [file] scan a file (or stdin) and print findings as JSON
14
+ firstwatch capture read a capture as JSON on stdin, check it, save it
15
+ firstwatch list list saved captures
16
+ firstwatch validate <capture.json> check a capture against the format
17
+ firstwatch export otlp <capture.json> print it as an OpenTelemetry (OTLP/JSON) log record
18
+ firstwatch clear [--session <id>] lift the approval gate for one session, or all
19
+
20
+ Captures go to ./security-captures, or FIRSTWATCH_DIR if set.
21
+ The Claude Code hook ships in the Node package: npx firstwatch hook claude-code"""
22
+
23
+
24
+ def _fail(msg):
25
+ sys.stderr.write(f"firstwatch: {msg}\n")
26
+ sys.exit(1)
27
+
28
+
29
+ def _print(v):
30
+ sys.stdout.write((v if isinstance(v, str) else json.dumps(v, indent=2, ensure_ascii=False)) + "\n")
31
+
32
+
33
+ def _read(path):
34
+ return Path(path).read_text(encoding="utf-8")
35
+
36
+
37
+ def main(argv=None):
38
+ args = list(sys.argv[1:] if argv is None else argv)
39
+ cmd = args.pop(0) if args else None
40
+ if cmd in (None, "-h", "--help", "help"):
41
+ return _print(HELP)
42
+ if cmd in ("-v", "--version"):
43
+ return _print(VERSION)
44
+
45
+ if cmd == "scan":
46
+ text = _read(args[0]) if args and args[0] != "-" else sys.stdin.read()
47
+ return _print(scan(text))
48
+
49
+ if cmd == "capture":
50
+ try:
51
+ data = json.loads(sys.stdin.read())
52
+ except ValueError as e:
53
+ _fail(f"stdin must be JSON: {e}")
54
+ text = (data.get("payload") or {}).get("text") or data.get("text")
55
+ if not text:
56
+ _fail("payload.text (the exact text of the attack) is required")
57
+ data["payload"] = {"hidden": False, "hidden_how": [], **(data.get("payload") or {}), "text": text}
58
+ found = scan(text)["findings"]
59
+ data["findings"] = (data.get("findings") or []) + found
60
+ data["level"] = data.get("level") or ("stop" if any(f["level"] == "stop" for f in found) else "note")
61
+ rec = build(data)
62
+ problems = validate(rec)
63
+ if problems:
64
+ _fail("capture is incomplete:\n- " + "\n- ".join(problems))
65
+ raw_file = data.get("raw_file")
66
+ raw = _read(raw_file) if raw_file and os.path.exists(raw_file) else None
67
+ if rec["level"] == "stop":
68
+ where = write_capture(rec, raw=raw, raw_kind="html" if re.search(r"\.html?$", raw_file or "", re.I) else "text")
69
+ else:
70
+ where = write_note(rec)
71
+ return _print({"level": rec["level"], "saved": where, "findings": len(rec["findings"])})
72
+
73
+ if cmd == "list":
74
+ f = capture_dir() / "index.jsonl"
75
+ if not f.exists():
76
+ return _print("No captures yet.")
77
+ for line in f.read_text(encoding="utf-8").strip().splitlines():
78
+ try:
79
+ r = json.loads(line)
80
+ except ValueError:
81
+ continue
82
+ _print(f"{r['time']} {r['level'].upper()} {r['summary']}\n {r['folder']}")
83
+ return None
84
+
85
+ if cmd == "validate":
86
+ if not args:
87
+ _fail("usage: firstwatch validate <capture.json>")
88
+ problems = validate(json.loads(_read(args[0])))
89
+ if problems:
90
+ _fail("not valid:\n- " + "\n- ".join(problems))
91
+ return _print("valid")
92
+
93
+ if cmd == "export":
94
+ if len(args) < 2 or args[0] != "otlp":
95
+ _fail("usage: firstwatch export otlp <capture.json>")
96
+ return _print(to_otlp(json.loads(_read(args[1]))))
97
+
98
+ if cmd == "clear":
99
+ d = capture_dir() / ".sessions"
100
+ if not d.exists():
101
+ return _print("No gated sessions.")
102
+ if "--session" in args:
103
+ sid = args[args.index("--session") + 1]
104
+ files = [d / (re.sub(r"[^a-z0-9]+", "-", sid.lower()) + ".json")]
105
+ else:
106
+ files = list(d.iterdir())
107
+ for f in files:
108
+ f.unlink(missing_ok=True)
109
+ return _print("Lifted the gate for " + (f"session {sid}" if "--session" in args else "all sessions") + ".")
110
+
111
+ _fail(f'unknown command "{cmd}"\n\n{HELP}')
112
+ return None
113
+
114
+
115
+ if __name__ == "__main__":
116
+ main()
firstwatch/redact.py ADDED
@@ -0,0 +1,69 @@
1
+ """Mask secrets before anything is written to disk. Emails are masked too unless the
2
+ caller keeps them (an attacker's address in the payload is evidence). Mirrors src/redact.js."""
3
+
4
+ import re
5
+
6
+ _A = re.ASCII
7
+ SECRETS = [
8
+ re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----"),
9
+ re.compile(r"\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}", _A),
10
+ re.compile(r"\b(?:ghp|gho|ghs|ghu|ghr)_[A-Za-z0-9]{30,}\b", _A),
11
+ re.compile(r"\bgithub_pat_[A-Za-z0-9_]{40,}\b", _A),
12
+ re.compile(r"\bAKIA[0-9A-Z]{16}\b", _A),
13
+ re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}", _A),
14
+ re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b", _A),
15
+ re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}", _A),
16
+ re.compile(r"\b(?:hf|npm|glpat|pypi)[-_][A-Za-z0-9_-]{20,}", _A),
17
+ ]
18
+ BEARER = re.compile(r"\b(Bearer|Basic)\s+[A-Za-z0-9._~+/-]{16,}=*", _A)
19
+ ASSIGNED = re.compile(r"\b([\w-]*(?:api[_-]?key|token|secret|passw(?:or)?d|pwd|auth)[\w-]*)([\"']?\s*[:=]\s*[\"']?)([^\s\"',;]{8,})", re.I | _A)
20
+ EMAIL = re.compile(r"\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b", _A)
21
+
22
+
23
+ def redact(text, keep_emails=False):
24
+ """Return (masked_text, count)."""
25
+ if not isinstance(text, str):
26
+ return text, 0
27
+ count = 0
28
+
29
+ def sub(rx, repl, s):
30
+ nonlocal count
31
+ out, n = rx.subn(repl, s)
32
+ count += n
33
+ return out
34
+
35
+ out = text
36
+ for rx in SECRETS:
37
+ out = sub(rx, "[REDACTED:secret]", out)
38
+ out = sub(BEARER, lambda m: f"{m.group(1)} [REDACTED:secret]", out)
39
+
40
+ def assigned(m):
41
+ nonlocal count
42
+ if m.group(3).startswith("[REDACTED"):
43
+ return m.group(0)
44
+ count += 1
45
+ return f"{m.group(1)}{m.group(2)}[REDACTED:secret]"
46
+
47
+ out = ASSIGNED.sub(assigned, out)
48
+ if not keep_emails:
49
+ out = sub(EMAIL, "[REDACTED:email]", out)
50
+ return out, count
51
+
52
+
53
+ def redact_deep(value, keep_emails=False):
54
+ """Redact every string inside a JSON-like value. Returns (value, count)."""
55
+ total = 0
56
+
57
+ def walk(v):
58
+ nonlocal total
59
+ if isinstance(v, str):
60
+ out, n = redact(v, keep_emails)
61
+ total += n
62
+ return out
63
+ if isinstance(v, list):
64
+ return [walk(x) for x in v]
65
+ if isinstance(v, dict):
66
+ return {k: walk(x) for k, x in v.items()}
67
+ return v
68
+
69
+ return walk(value), total
firstwatch/rules.json ADDED
@@ -0,0 +1,289 @@
1
+ {
2
+ "version": "0.1.0",
3
+ "_about": "firstwatch detection rules. Shared by the Node and Python packages; edit here only. Patterns use the common subset of JavaScript and Python regex syntax. Levels: visible = when people can see the text (null = only when hidden or addressed to an AI), hidden = when the text is hidden from people.",
4
+ "atlas": {
5
+ "AML.T0051.001": "LLM Prompt Injection: Indirect",
6
+ "AML.T0054": "LLM Jailbreak",
7
+ "AML.T0057": "LLM Data Leakage",
8
+ "AML.T0068": "LLM Prompt Obfuscation",
9
+ "AML.T0081": "Modify AI Agent Configuration",
10
+ "AML.T0086": "Exfiltration via AI Agent Tool Invocation",
11
+ "AML.T0100": "AI Agent Clickbait"
12
+ },
13
+ "owasp": {
14
+ "LLM01:2025": "Prompt Injection",
15
+ "LLM02:2025": "Sensitive Information Disclosure",
16
+ "LLM06:2025": "Excessive Agency",
17
+ "LLM07:2025": "System Prompt Leakage"
18
+ },
19
+ "agent": {
20
+ "source": "(?:\\b(?:ai|llms?|gpt|claude|gemini|chatgpt|copilot)\\b|\\blanguage models?\\b|\\b(?:assistants?|chatbots?)\\b)",
21
+ "flags": "i"
22
+ },
23
+ "rules": [
24
+ {
25
+ "id": "override-instructions",
26
+ "what": "tells the agent to ignore or replace its instructions",
27
+ "visible": "stop",
28
+ "hidden": "stop",
29
+ "atlas": [
30
+ "AML.T0051.001"
31
+ ],
32
+ "owasp": [
33
+ "LLM01:2025"
34
+ ],
35
+ "pattern": {
36
+ "source": "\\b(?:ignore|disregard|forget|override|bypass)\\b[^.\\n]{0,40}?\\b(?:previous|prior|above|earlier|all|any|your|system|original|preceding)\\b[^.\\n]{0,20}?\\b(?:instructions?|prompts?|rules|directions|guidelines|directives|context|constraints)\\b",
37
+ "flags": "i"
38
+ }
39
+ },
40
+ {
41
+ "id": "role-hijack",
42
+ "what": "tries to switch the agent into an unrestricted role",
43
+ "visible": "stop",
44
+ "hidden": "stop",
45
+ "atlas": [
46
+ "AML.T0054"
47
+ ],
48
+ "owasp": [
49
+ "LLM01:2025"
50
+ ],
51
+ "pattern": {
52
+ "source": "\\byou are now (?:in )?(?:dan\\b|an? (?:unrestricted|unfiltered|jailbroken)|unrestricted|jailbroken|in developer mode)|\\b(?:enter|enable|activate) (?:dan|god|jailbreak) mode\\b|\\bact as dan\\b",
53
+ "flags": "i"
54
+ }
55
+ },
56
+ {
57
+ "id": "new-instructions",
58
+ "what": "announces new instructions for the reader",
59
+ "visible": "note",
60
+ "hidden": "stop",
61
+ "atlas": [
62
+ "AML.T0051.001"
63
+ ],
64
+ "owasp": [
65
+ "LLM01:2025"
66
+ ],
67
+ "pattern": {
68
+ "source": "\\b(?:new|updated|real|actual|true|secret|hidden|additional) (?:system )?(?:instructions?|directives?|orders|task)\\s*[:\\-]",
69
+ "flags": "i"
70
+ }
71
+ },
72
+ {
73
+ "id": "conceal-from-user",
74
+ "what": "tells the agent to hide something from its user",
75
+ "visible": "stop",
76
+ "hidden": "stop",
77
+ "atlas": [
78
+ "AML.T0051.001"
79
+ ],
80
+ "owasp": [
81
+ "LLM01:2025"
82
+ ],
83
+ "pattern": {
84
+ "source": "\\b(?:do not|don'?t|never)\\s+(?:tell|inform|mention|reveal|show|alert|notify|let)\\b[^.\\n]{0,30}?\\b(?:the )?(?:user|human|owner|operator|anyone)\\b|\\bwithout (?:telling|informing|alerting|notifying|asking)\\b[^.\\n]{0,20}?\\b(?:the )?(?:user|human|owner|operator)\\b",
85
+ "flags": "i"
86
+ }
87
+ },
88
+ {
89
+ "id": "exfiltrate-secrets",
90
+ "what": "asks for secrets to be sent to an outside address",
91
+ "visible": "stop",
92
+ "hidden": "stop",
93
+ "atlas": [
94
+ "AML.T0086",
95
+ "AML.T0057"
96
+ ],
97
+ "owasp": [
98
+ "LLM02:2025",
99
+ "LLM06:2025"
100
+ ],
101
+ "pattern": {
102
+ "source": "\\b(?:send|e-?mail|post|upload|share|forward|transmit|exfiltrate|leak|paste|include|append|attach|submit)\\b[^\\n]{0,80}?(?:\\.env\\b|api[ _-]?keys?|access[ _-]?tokens?|auth(?:entication)?[ _-]?tokens?|secrets?|passwords?|credentials?|private[ _-]?keys?|ssh[ _-]?keys?|id_rsa|aws_[a-z_]+|session[ _-]?(?:tokens?|cookies?)|cookies|seed[ _-]?phrase|wallet)[^\\n]{0,80}?(?:[\\w.+-]+@[\\w-]+\\.[\\w.]+|https?:\\/\\/\\S+|\\bto (?:this|the following|our|my) (?:address|url|endpoint|webhook|server|email)|\\bin (?:your|the) (?:reply|response|answer|output))|(?:\\.env\\b|api[ _-]?keys?|access[ _-]?tokens?|auth(?:entication)?[ _-]?tokens?|secrets?|passwords?|credentials?|private[ _-]?keys?|ssh[ _-]?keys?|id_rsa|aws_[a-z_]+|session[ _-]?(?:tokens?|cookies?)|cookies|seed[ _-]?phrase|wallet)[^\\n]{0,40}?\\b(?:send|e-?mail|post|upload|share|forward|transmit|exfiltrate|leak|paste|include|append|attach|submit)\\b[^\\n]{0,60}?(?:[\\w.+-]+@[\\w-]+\\.[\\w.]+|https?:\\/\\/\\S+|\\bto (?:this|the following|our|my) (?:address|url|endpoint|webhook|server|email)|\\bin (?:your|the) (?:reply|response|answer|output))",
103
+ "flags": "i"
104
+ }
105
+ },
106
+ {
107
+ "id": "reveal-instructions",
108
+ "what": "asks the agent to reveal its instructions",
109
+ "visible": "stop",
110
+ "hidden": "stop",
111
+ "atlas": [
112
+ "AML.T0057"
113
+ ],
114
+ "owasp": [
115
+ "LLM07:2025"
116
+ ],
117
+ "pattern": {
118
+ "source": "\\b(?:reveal|print|output|repeat|show|disclose|leak|share|tell me)\\b[^.\\n]{0,30}?\\b(?:(?:your|the) system prompt|your (?:instructions|initial prompt|hidden prompt|rules|configuration)|the (?:initial|hidden) prompt)\\b",
119
+ "flags": "i"
120
+ }
121
+ },
122
+ {
123
+ "id": "remote-code",
124
+ "what": "pipes a download straight into a shell",
125
+ "visible": null,
126
+ "hidden": "stop",
127
+ "atlas": [
128
+ "AML.T0051.001"
129
+ ],
130
+ "owasp": [
131
+ "LLM06:2025"
132
+ ],
133
+ "pattern": {
134
+ "source": "\\b(?:curl|wget)\\b[^\\n|]{0,200}\\|\\s*(?:sudo\\s+)?(?:ba|z)?sh\\b|\\b(?:iex|invoke-expression)\\b[^\\n]{0,40}\\b(?:iwr|invoke-webrequest|downloadstring)\\b",
135
+ "flags": "i"
136
+ }
137
+ },
138
+ {
139
+ "id": "image-exfiltration",
140
+ "what": "markdown image whose URL carries a data placeholder",
141
+ "visible": "stop",
142
+ "hidden": "stop",
143
+ "atlas": [
144
+ "AML.T0086"
145
+ ],
146
+ "owasp": [
147
+ "LLM02:2025"
148
+ ],
149
+ "pattern": {
150
+ "source": "!\\[[^\\]]*\\]\\(\\s*https?:\\/\\/[^)\\s]*[?&][\\w-]+=\\s*(?:\\{|\\[|<|%7b|\\$\\{)",
151
+ "flags": "i"
152
+ }
153
+ },
154
+ {
155
+ "id": "modify-agent-config",
156
+ "what": "asks for changes to the agent's own setup",
157
+ "visible": "note",
158
+ "hidden": "stop",
159
+ "atlas": [
160
+ "AML.T0081"
161
+ ],
162
+ "owasp": [
163
+ "LLM06:2025"
164
+ ],
165
+ "pattern": {
166
+ "source": "\\b(?:edit|modify|update|append|write|add|overwrite|replace)\\b[^\\n]{0,100}?(?:\\bCLAUDE\\.md\\b|\\bAGENTS\\.md\\b|\\.cursorrules\\b|\\bsettings\\.json\\b|\\bMEMORY\\.md\\b|\\byour (?:memory|instructions|system prompt|hooks?|rules)\\b|\\.bashrc\\b|\\.zshrc\\b|\\bcrontab\\b|\\bauthorized_keys\\b)",
167
+ "flags": "i"
168
+ }
169
+ },
170
+ {
171
+ "id": "agent-imperative",
172
+ "what": "gives orders to AI agents",
173
+ "visible": "note",
174
+ "hidden": "stop",
175
+ "atlas": [
176
+ "AML.T0051.001"
177
+ ],
178
+ "owasp": [
179
+ "LLM01:2025"
180
+ ],
181
+ "agent": {
182
+ "source": "(?:\\b(?:ai|llms?|gpt|claude|gemini|chatgpt|copilot)\\b|\\blanguage models?\\b|\\b(?:assistants?|chatbots?)\\b)",
183
+ "flags": "i"
184
+ },
185
+ "imperative": {
186
+ "source": "(?:\\byou (?:must|should|need to|are required to|will|have to)\\b|\\bdo not\\b|\\bdon'?t\\b|\\bnever\\b|\\balways\\b|\\bimmediately\\b|\\bbefore (?:you )?(?:answer|respond|reply|continue|proceed)\\b|\\bmake sure\\b)",
187
+ "flags": "i"
188
+ },
189
+ "danger": {
190
+ "source": "\\b(?:send|e-?mail|forward|upload|transfer|wire|pay|buy|purchase|delete|wipe|reveal|disclose|grant|authori[sz]e|approve|sign ?up|subscribe|share (?:your|the|all))\\b",
191
+ "flags": "i"
192
+ }
193
+ }
194
+ ],
195
+ "hidden_style": [
196
+ {
197
+ "label": "css:display-none",
198
+ "source": "display\\s*:\\s*none",
199
+ "flags": "i"
200
+ },
201
+ {
202
+ "label": "css:visibility-hidden",
203
+ "source": "visibility\\s*:\\s*hidden",
204
+ "flags": "i"
205
+ },
206
+ {
207
+ "label": "css:tiny-text",
208
+ "source": "font-size\\s*:\\s*(?:0|0?\\.\\d+|1)(?:px|pt|em|rem)?\\s*(?:;|$|!)",
209
+ "flags": "i"
210
+ },
211
+ {
212
+ "label": "css:opacity-0",
213
+ "source": "opacity\\s*:\\s*0(?:\\.0+)?\\s*(?:;|$|!)",
214
+ "flags": "i"
215
+ },
216
+ {
217
+ "label": "css:off-screen",
218
+ "source": "(?:left|top|right|text-indent|margin-left)\\s*:\\s*-\\d{3,}",
219
+ "flags": "i"
220
+ },
221
+ {
222
+ "label": "css:white-or-transparent-text",
223
+ "source": "(?:^|[;\\s])color\\s*:\\s*(?:#fff\\b|#ffffff\\b|white\\b|transparent\\b|rgba\\([^)]*,\\s*0\\s*\\))",
224
+ "flags": "i"
225
+ },
226
+ {
227
+ "label": "css:zero-size",
228
+ "source": "(?:height|width)\\s*:\\s*0(?:px)?\\s*;[^\\\"']*overflow\\s*:\\s*hidden",
229
+ "flags": "i"
230
+ }
231
+ ],
232
+ "discussion": {
233
+ "_about": "A page that mentions these words this many times in visible text discusses attacks: its visible stops become notes. Hidden text still stops.",
234
+ "source": "prompt[ -]?injections?|jailbreak",
235
+ "flags": "gi",
236
+ "min": 3
237
+ },
238
+ "priority": [
239
+ "exfiltrate-secrets",
240
+ "image-exfiltration",
241
+ "remote-code",
242
+ "modify-agent-config",
243
+ "override-instructions",
244
+ "role-hijack",
245
+ "reveal-instructions",
246
+ "unicode-tag-smuggling",
247
+ "conceal-from-user",
248
+ "agent-imperative",
249
+ "new-instructions",
250
+ "hidden-agent-text",
251
+ "bidi-control"
252
+ ],
253
+ "builtin": [
254
+ {
255
+ "id": "unicode-tag-smuggling",
256
+ "what": "hides text in invisible Unicode tag characters",
257
+ "level": "stop",
258
+ "atlas": [
259
+ "AML.T0068",
260
+ "AML.T0051.001"
261
+ ],
262
+ "owasp": [
263
+ "LLM01:2025"
264
+ ]
265
+ },
266
+ {
267
+ "id": "bidi-control",
268
+ "what": "contains characters that reorder text on screen",
269
+ "level": "note",
270
+ "atlas": [
271
+ "AML.T0068"
272
+ ],
273
+ "owasp": [
274
+ "LLM01:2025"
275
+ ]
276
+ },
277
+ {
278
+ "id": "hidden-agent-text",
279
+ "what": "hidden text that mentions AI agents",
280
+ "level": "note",
281
+ "atlas": [
282
+ "AML.T0051.001"
283
+ ],
284
+ "owasp": [
285
+ "LLM01:2025"
286
+ ]
287
+ }
288
+ ]
289
+ }
firstwatch/scan.py ADDED
@@ -0,0 +1,240 @@
1
+ """Scan text or HTML for attacks aimed at AI agents. Pure: text in, findings out.
2
+
3
+ No network, no model. It splits the input into what people see and what they
4
+ don't, then runs the rules on each part. Mirrors src/scan.js in the Node package.
5
+ """
6
+
7
+ import base64
8
+ import html
9
+ import re
10
+
11
+ from ._rules import AGENT_RE, BUILTIN, DISCUSSION_MIN, DISCUSSION_RE, HIDDEN_STYLE, RULES
12
+
13
+ ZERO_WIDTH = re.compile("[\u200B-\u200D\u2060-\u2064\uFEFF\u00AD]")
14
+ BIDI = re.compile("[\u202A-\u202E\u2066-\u2069]")
15
+ TAG_CHARS = re.compile("[\U000e0000-\U000e007f]+")
16
+ LEVELS = {"clean": 0, "note": 1, "stop": 2}
17
+ _SPACE = re.compile(r"\s+")
18
+
19
+
20
+ def decode_entities(s):
21
+ def num(m, base):
22
+ try:
23
+ return chr(int(m.group(1), base))
24
+ except (ValueError, OverflowError):
25
+ return ""
26
+
27
+ s = re.sub(r"&#x([0-9a-f]+);", lambda m: num(m, 16), s, flags=re.I)
28
+ s = re.sub(r"&#(\d+);", lambda m: num(m, 10), s)
29
+ for a, b in (("&nbsp;", " "), ("&quot;", '"'), ("&#39;", "'"), ("&apos;", "'"), ("&lt;", "<"), ("&gt;", ">"), ("&amp;", "&")):
30
+ s = s.replace(a, b)
31
+ return s
32
+
33
+
34
+ def _strip_tags(s):
35
+ return _SPACE.sub(" ", decode_entities(re.sub(r"<[^>]*>", " ", s))).strip()
36
+
37
+
38
+ def _inside(ranges, i):
39
+ return any(a <= i < b for a, b in ranges)
40
+
41
+
42
+ def segments(text):
43
+ """Split input into segments: dicts with text, hidden, how, where, offset, quoted."""
44
+ out = []
45
+ for m in TAG_CHARS.finditer(text):
46
+ decoded = "".join(chr(ord(c) - 0xE0000) for c in m.group(0))
47
+ out.append({"text": decoded, "hidden": True, "how": ["unicode-tag-characters"], "where": "invisible characters", "offset": m.start()})
48
+ s = TAG_CHARS.sub("", text)
49
+ if not re.search(r"</?[a-z][\w-]*[^>]*>|<!--", s, re.I):
50
+ out.append({"text": s, "hidden": False, "how": [], "where": "text", "offset": 0})
51
+ return _with_base64(out, s)
52
+
53
+ lower = s.lower()
54
+ removed = []
55
+ for m in re.finditer(r"<!--([\s\S]*?)-->", s):
56
+ out.append({"text": m.group(1).strip(), "hidden": True, "how": ["html-comment"], "where": "HTML comment", "offset": m.start()})
57
+ removed.append((m.start(), m.end()))
58
+ for m in re.finditer(r"<(script|style|template)\b[^>]*>[\s\S]*?</\1>", s, re.I):
59
+ removed.append((m.start(), m.end()))
60
+
61
+ for m in re.finditer(r"<([a-z][\w-]*)\b([^>]*)>", s, re.I | re.ASCII):
62
+ tag, attrs = m.group(1).lower(), m.group(2)
63
+ how = []
64
+ style = re.search(r"\bstyle\s*=\s*(\"([^\"]*)\"|'([^']*)')", attrs, re.I)
65
+ if style:
66
+ value = style.group(2) if style.group(2) is not None else style.group(3)
67
+ how += [label for rx, label in HIDDEN_STYLE if rx.search(value)]
68
+ if re.search(r"(?:^|\s)hidden(?:\s|=|$)", attrs, re.I):
69
+ how.append("attr:hidden")
70
+ if re.search(r"aria-hidden\s*=\s*[\"']?true", attrs, re.I):
71
+ how.append("attr:aria-hidden")
72
+ if re.search(r"\bclass\s*=\s*[\"'][^\"']*\b(?:hidden|d-none|invisible)\b", attrs, re.I):
73
+ how.append("class:hidden")
74
+ for a in re.finditer(r"\b(alt|title|aria-label|content|data-[\w-]+)\s*=\s*(\"([^\"]*)\"|'([^']*)')", attrs, re.I | re.ASCII):
75
+ val = decode_entities(a.group(3) if a.group(3) is not None else a.group(4)).strip()
76
+ if len(val) > 15:
77
+ name = a.group(1).lower()
78
+ out.append({"text": val, "hidden": True, "how": [f"attribute:{name}"], "where": f"<{tag}> {name} attribute", "offset": m.start()})
79
+ if not how or re.fullmatch(r"br|img|input|meta|link|hr", tag):
80
+ continue
81
+ start = m.end()
82
+ close = lower.find(f"</{tag}", start)
83
+ end = min(len(s), start + 4000) if close == -1 else close
84
+ inner = _strip_tags(s[start:end])
85
+ if inner:
86
+ out.append({"text": inner, "hidden": True, "how": how, "where": f"hidden <{tag}>", "offset": m.start()})
87
+ removed.append((m.start(), end))
88
+
89
+ # Code blocks are quoted examples: a match there is a note, not a stop.
90
+ for m in re.finditer(r"<(pre|code)\b[^>]*>([\s\S]*?)</\1>", s, re.I):
91
+ if _inside(removed, m.start()):
92
+ continue
93
+ inner = _strip_tags(m.group(2))
94
+ if inner:
95
+ out.append({"text": inner, "hidden": False, "how": [], "where": f"<{m.group(1).lower()}> block", "offset": m.start(), "quoted": True})
96
+ removed.append((m.start(), m.end()))
97
+
98
+ visible, pos = [], 0
99
+ for a, b in sorted(removed):
100
+ if a > pos:
101
+ visible.append(s[pos:a] + " ")
102
+ pos = max(pos, b)
103
+ visible.append(s[pos:])
104
+ vis = _strip_tags("".join(visible))
105
+ if vis:
106
+ out.append({"text": vis, "hidden": False, "how": [], "where": "visible text", "offset": 0})
107
+ return _with_base64(out, s)
108
+
109
+
110
+ def _with_base64(out, s):
111
+ for m in re.finditer(r"(?<![A-Za-z0-9+/=])[A-Za-z0-9+/]{40,}={0,2}(?![A-Za-z0-9+/=])", s):
112
+ blob = m.group(0).rstrip("=")
113
+ if len(blob) % 4 == 1:
114
+ blob = blob[:-1]
115
+ try:
116
+ decoded = base64.b64decode(blob + "=" * (-len(blob) % 4)).decode("utf-8", errors="replace")
117
+ except ValueError:
118
+ continue
119
+ printable = len(re.sub(r"[^\x20-\x7e\n\t]", "", decoded)) / max(len(decoded), 1)
120
+ if printable > 0.95 and re.search(r"[a-z]{3,} [a-z]{3,}", decoded, re.I):
121
+ out.append({"text": decoded, "hidden": True, "how": ["base64"], "where": "base64-encoded text", "offset": m.start()})
122
+ return out
123
+
124
+
125
+ def _excerpt(text, index, length):
126
+ a = max(0, index - 80)
127
+ b = min(len(text), index + length + 80)
128
+ return ("…" if a > 0 else "") + text[a:b] + ("…" if b < len(text) else "")
129
+
130
+
131
+ def _in_quotes(text, i):
132
+ """Is position i inside a quotation? Counts open quotes in the 300 characters before it."""
133
+ before = text[max(0, i - 300):i]
134
+ return before.count('"') % 2 == 1 or (before.count("“") + before.count("«")) > (before.count("”") + before.count("»"))
135
+
136
+
137
+ def _level_for(rule, seg, text, index, near):
138
+ level = rule["hidden"] if seg["hidden"] else rule["visible"]
139
+ if level is None:
140
+ level = "stop" if AGENT_RE.search(near) else None
141
+ if not level:
142
+ return None
143
+ # A quoted example in visible text is someone talking about an attack, not making one.
144
+ if level == "stop" and not seg["hidden"] and (seg.get("quoted") or _in_quotes(text, index)):
145
+ level = "note"
146
+ return level
147
+
148
+
149
+ def _finding(rule, level, seg, ex):
150
+ atlas = list(rule["atlas"])
151
+ if any(h in ("base64", "zero-width-characters") for h in seg["how"]) and "AML.T0068" not in atlas:
152
+ atlas.append("AML.T0068")
153
+ return {
154
+ "rule": rule["id"],
155
+ "what": rule["what"],
156
+ "level": level,
157
+ "hidden": bool(seg["hidden"]),
158
+ "hidden_how": seg["how"],
159
+ "where": seg["where"],
160
+ "offset": seg["offset"],
161
+ "excerpt": ex,
162
+ "atlas": atlas,
163
+ "owasp": list(rule["owasp"]),
164
+ }
165
+
166
+
167
+ def scan(text):
168
+ """Scan a string. Returns {"level": "clean"|"note"|"stop", "findings": [...]}."""
169
+ if not isinstance(text, str) or not text:
170
+ return {"level": "clean", "findings": []}
171
+ findings, seen = [], set()
172
+ segs = segments(text)
173
+ if BIDI.search(text):
174
+ findings.append(_finding(BUILTIN["bidi-control"], "note", {"hidden": True, "how": ["bidi-control-characters"], "where": "text", "offset": 0}, "(bidirectional control characters present)"))
175
+
176
+ ai = next(r for r in RULES if r["id"] == "agent-imperative")
177
+ for seg in segs:
178
+ raw = seg["text"]
179
+ t = ZERO_WIDTH.sub("", raw)
180
+ how = list(seg["how"])
181
+ if t != raw:
182
+ how.append("zero-width-characters")
183
+ sf = dict(seg, how=how)
184
+ hit = False
185
+
186
+ if "unicode-tag-characters" in seg["how"]:
187
+ findings.append(_finding(BUILTIN["unicode-tag-smuggling"], "stop", sf, t[:240]))
188
+ hit = True
189
+
190
+ for rule in RULES:
191
+ if rule["id"] == "agent-imperative":
192
+ continue
193
+ m = rule["re"].search(t)
194
+ if not m:
195
+ continue
196
+ near = t[max(0, m.start() - 200):m.end() + 200]
197
+ level = _level_for(rule, sf, t, m.start(), near)
198
+ if not level:
199
+ continue
200
+ ex = _excerpt(t, m.start(), len(m.group(0)))
201
+ if (rule["id"] + ex) in seen:
202
+ continue
203
+ seen.add(rule["id"] + ex)
204
+ findings.append(_finding(rule, level, sf, ex))
205
+ hit = True
206
+
207
+ at = 0
208
+ for sentence in re.split(r"(?<=[.!?])\s+|\n+", t):
209
+ index = t.find(sentence, at)
210
+ at = index + len(sentence)
211
+ if not ai["agent_re"].search(sentence) or not ai["imperative_re"].search(sentence):
212
+ continue
213
+ level = "stop" if sf["hidden"] or ai["danger_re"].search(sentence) else "note"
214
+ if level == "stop" and not sf["hidden"] and (sf.get("quoted") or _in_quotes(t, index + 1)):
215
+ level = "note"
216
+ ex = sentence.strip()[:300]
217
+ if (ai["id"] + ex) in seen:
218
+ continue
219
+ seen.add(ai["id"] + ex)
220
+ findings.append(_finding(ai, level, sf, ex))
221
+ hit = True
222
+
223
+ # Hidden menus often name AI products; only a comment or invisible characters aimed at AI is worth a note.
224
+ if not hit and seg["hidden"] and AGENT_RE.search(t) and (seg["how"][:1] in (["html-comment"], ["unicode-tag-characters"])):
225
+ findings.append(_finding(BUILTIN["hidden-agent-text"], "note", sf, t[:240]))
226
+
227
+ # A page that talks about these attacks at length quotes them in visible text.
228
+ # Its visible examples become notes; anything hidden still stops.
229
+ visible = " ".join(x["text"] for x in segs if not x["hidden"])
230
+ if len(DISCUSSION_RE.findall(visible)) >= DISCUSSION_MIN:
231
+ for f in findings:
232
+ if f["level"] == "stop" and not f["hidden"]:
233
+ f["level"] = "note"
234
+ f["context"] = "page discusses attacks"
235
+
236
+ level = "clean"
237
+ for f in findings:
238
+ if LEVELS[f["level"]] > LEVELS[level]:
239
+ level = f["level"]
240
+ return {"level": level, "findings": findings}
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.5
2
+ Name: firstwatch
3
+ Version: 0.1.0
4
+ Summary: First line of defence for AI agents: spots attacks hidden in pages, files and tool results, and records each one as evidence in an open, OpenTelemetry-ready format with MITRE ATLAS and OWASP IDs. No dependencies.
5
+ Project-URL: Homepage, https://github.com/dave8172/firstwatch
6
+ Project-URL: Documentation, https://github.com/dave8172/firstwatch/tree/main/docs
7
+ Project-URL: Source, https://github.com/dave8172/firstwatch
8
+ Project-URL: Issues, https://github.com/dave8172/firstwatch/issues
9
+ Project-URL: Changelog, https://github.com/dave8172/firstwatch/blob/main/CHANGELOG.md
10
+ Author: dave8172
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: agent-security,ai-agents,guardrails,llm-security,mitre-atlas,opentelemetry,owasp,prompt-injection,security
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
25
+ Classifier: Topic :: Security
26
+ Requires-Python: >=3.9
27
+ Description-Content-Type: text/markdown
28
+
29
+ # firstwatch
30
+
31
+ **First line of defence for AI agents.** Scans pages, files and tool results for attacks aimed at agents (in the text people see and the text they don't), and records each one as evidence in an open, OpenTelemetry-ready format with MITRE ATLAS and OWASP IDs.
32
+
33
+ No dependencies. Python 3.9+.
34
+
35
+ ```bash
36
+ pip install firstwatch
37
+ ```
38
+
39
+ ```python
40
+ from firstwatch import scan, watch
41
+
42
+ scan(page_html)
43
+ # {"level": "stop", "findings": [{"rule": "exfiltrate-secrets", "hidden": True,
44
+ # "hidden_how": ["css:tiny-text"], "atlas": ["AML.T0086", ...], ...}]}
45
+
46
+ result = watch(page_html, url=url, tool="fetch") # scan + save a capture on a stop
47
+ if result["level"] == "stop":
48
+ ... # don't hand the page to the model as instructions; tell the user
49
+ ```
50
+
51
+ Command line:
52
+
53
+ ```bash
54
+ firstwatch scan page.html
55
+ firstwatch list
56
+ firstwatch export otlp security-captures/<folder>/capture.json
57
+ ```
58
+
59
+ It catches instruction overrides, role hijacks, orders to hide things from the user, requests to send secrets out, requests to reveal the system prompt, `curl | sh` in hidden text, markdown image data leaks, edits to the agent's own setup, and orders aimed at AI assistants. It looks in HTML comments, CSS-hidden elements, attributes, invisible Unicode tag characters, zero-width characters and base64.
60
+
61
+ The Node package (`npm install firstwatch`) uses the same rules and format, and adds Claude Code hooks that block actions after an attack.
62
+
63
+ **Docs, examples and the capture format:** https://github.com/dave8172/firstwatch
64
+
65
+ MIT licensed. Made by [@dave8172](https://x.com/dave8172).
@@ -0,0 +1,13 @@
1
+ firstwatch/__init__.py,sha256=4aA7kX_RUGucGeKGqpH2vvk-96AqKXWKQbK3LBWTKvg,2488
2
+ firstwatch/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
3
+ firstwatch/_rules.py,sha256=qT3msZJf49VcW17Tf9I9ild8lvOQww9EdFlg6pUM1Ww,1112
4
+ firstwatch/capture.py,sha256=d57H0_qwvK_grd4g3NzhMZ-7aY75qsxwlt1x8azZV-g,11711
5
+ firstwatch/cli.py,sha256=cRNozfLPoxq4olaFGIm3xNOCzdvrrZ8_nbRTBHcB88I,4441
6
+ firstwatch/redact.py,sha256=PmqQqN9UiHYx4-mcOoHVOIaXTERY5BqRZMrmzIzv1Mc,2363
7
+ firstwatch/rules.json,sha256=Bb6AKqiX_sph0zlWXb9vjpPBl1KOhVmF-tJ-ZSXWA18,9953
8
+ firstwatch/scan.py,sha256=V7lB6p0s_3ZswemM710kfLUNk66nLvldRX5XK6_M6so,10144
9
+ firstwatch-0.1.0.dist-info/METADATA,sha256=2qjvTBT1cWFSo2_OIWM6tEclgUeI7F6xlZsVALUzrVQ,3042
10
+ firstwatch-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
11
+ firstwatch-0.1.0.dist-info/entry_points.txt,sha256=1da0Y_MWNUPkkD450ZlM-cIGrEtjo4RC3iIM0CqR5tc,51
12
+ firstwatch-0.1.0.dist-info/licenses/LICENSE,sha256=SaJIEtWVZ-SL5sXsd4HaAwLAUZrnZsMGZOiSK0kZt3I,1065
13
+ firstwatch-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ firstwatch = firstwatch.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dave8172
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.