actaeon 0.2.0b0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. actaeon/__init__.py +3 -0
  2. actaeon/__main__.py +3 -0
  3. actaeon/access.py +137 -0
  4. actaeon/assistant.py +209 -0
  5. actaeon/auth.py +443 -0
  6. actaeon/bench.py +186 -0
  7. actaeon/bridges/__init__.py +16 -0
  8. actaeon/bridges/common.py +148 -0
  9. actaeon/bridges/demo.py +232 -0
  10. actaeon/bridges/local_intel.py +77 -0
  11. actaeon/bridges/local_tools.py +31 -0
  12. actaeon/bridges/openapi.py +169 -0
  13. actaeon/bridges/search_index.py +303 -0
  14. actaeon/bridges/server.py +109 -0
  15. actaeon/bridges/sql.py +227 -0
  16. actaeon/bridges/taxii.py +185 -0
  17. actaeon/bridges/web.py +109 -0
  18. actaeon/cli.py +910 -0
  19. actaeon/company.py +102 -0
  20. actaeon/config.example.yaml +236 -0
  21. actaeon/config.py +336 -0
  22. actaeon/connectors/__init__.py +6 -0
  23. actaeon/connectors/audit.py +131 -0
  24. actaeon/connectors/client.py +294 -0
  25. actaeon/connectors/contract.py +103 -0
  26. actaeon/connectors/hub.py +279 -0
  27. actaeon/connectors/oauth.py +237 -0
  28. actaeon/connectors/policy.py +79 -0
  29. actaeon/connectors/ratelimit.py +26 -0
  30. actaeon/connectors/spec.py +154 -0
  31. actaeon/connectors/untrusted.py +29 -0
  32. actaeon/db.py +341 -0
  33. actaeon/db_migrate.py +92 -0
  34. actaeon/decode.py +150 -0
  35. actaeon/demo/__init__.py +1 -0
  36. actaeon/demo/data.py +87 -0
  37. actaeon/demo/setup.py +62 -0
  38. actaeon/doctor.py +157 -0
  39. actaeon/hunt/__init__.py +6 -0
  40. actaeon/hunt/attack_data.json +1 -0
  41. actaeon/hunt/attack_names.py +79 -0
  42. actaeon/hunt/autopilot.py +153 -0
  43. actaeon/hunt/chat.py +132 -0
  44. actaeon/hunt/coverage.py +73 -0
  45. actaeon/hunt/detection.py +180 -0
  46. actaeon/hunt/drift.py +262 -0
  47. actaeon/hunt/engine.py +696 -0
  48. actaeon/hunt/entities.py +169 -0
  49. actaeon/hunt/graph.py +86 -0
  50. actaeon/hunt/injection.py +112 -0
  51. actaeon/hunt/jsonparse.py +70 -0
  52. actaeon/hunt/library.py +225 -0
  53. actaeon/hunt/memory.py +192 -0
  54. actaeon/hunt/packs.py +114 -0
  55. actaeon/hunt/prompts.py +101 -0
  56. actaeon/hunt/report.py +138 -0
  57. actaeon/hunt/scheduler.py +156 -0
  58. actaeon/hunt/service.py +492 -0
  59. actaeon/hunt/sigma_convert.py +159 -0
  60. actaeon/hunt/signals.py +56 -0
  61. actaeon/hunt/store.py +158 -0
  62. actaeon/hunt/sweep.py +231 -0
  63. actaeon/hunt/timeline.py +87 -0
  64. actaeon/hunt/toolloop.py +142 -0
  65. actaeon/hunt/triage.py +123 -0
  66. actaeon/hunt/validator.py +109 -0
  67. actaeon/hunt/verify.py +88 -0
  68. actaeon/hunts/collection/archive-created-before-transfer.yaml +6 -0
  69. actaeon/hunts/collection/data-staged-on-a-network-share.yaml +6 -0
  70. actaeon/hunts/collection/email-forwarding-rule-to-an-outside-address.yaml +6 -0
  71. actaeon/hunts/collection/storage-made-public.yaml +6 -0
  72. actaeon/hunts/command-and-control/beaconing.yaml +6 -0
  73. actaeon/hunts/command-and-control/first-seen-external-destination.yaml +6 -0
  74. actaeon/hunts/command-and-control/rare-user-agent.yaml +6 -0
  75. actaeon/hunts/command-and-control/tool-download-with-built-in-utilities.yaml +6 -0
  76. actaeon/hunts/command-and-control/unusual-dns.yaml +6 -0
  77. actaeon/hunts/credential-access/as-rep-roasting.yaml +6 -0
  78. actaeon/hunts/credential-access/browser-credential-store-access.yaml +6 -0
  79. actaeon/hunts/credential-access/brute-force-against-one-account.yaml +6 -0
  80. actaeon/hunts/credential-access/consent-to-an-unfamiliar-application.yaml +6 -0
  81. actaeon/hunts/credential-access/directory-replication-from-a-non-controller.yaml +6 -0
  82. actaeon/hunts/credential-access/kerberoasting.yaml +6 -0
  83. actaeon/hunts/credential-access/lsass-memory-access.yaml +6 -0
  84. actaeon/hunts/credential-access/mfa-fatigue.yaml +6 -0
  85. actaeon/hunts/credential-access/password-spraying.yaml +6 -0
  86. actaeon/hunts/credential-access/registry-hive-or-ntds-copy.yaml +6 -0
  87. actaeon/hunts/defense-impairment/cloud-logging-disabled.yaml +6 -0
  88. actaeon/hunts/defense-impairment/event-log-clearing.yaml +6 -0
  89. actaeon/hunts/defense-impairment/firewall-rule-added-or-disabled.yaml +6 -0
  90. actaeon/hunts/defense-impairment/linux-log-or-history-tampering.yaml +6 -0
  91. actaeon/hunts/defense-impairment/security-tool-tampering.yaml +6 -0
  92. actaeon/hunts/discovery/burst-of-discovery-commands.yaml +6 -0
  93. actaeon/hunts/discovery/internal-port-scan.yaml +6 -0
  94. actaeon/hunts/execution/encoded-powershell-from-office.yaml +6 -0
  95. actaeon/hunts/execution/first-seen-process-on-servers.yaml +6 -0
  96. actaeon/hunts/execution/new-cron-job-or-systemd-unit.yaml +6 -0
  97. actaeon/hunts/execution/new-scheduled-task-by-an-unusual-parent.yaml +6 -0
  98. actaeon/hunts/execution/office-macro-writing-executables.yaml +6 -0
  99. actaeon/hunts/execution/python-or-perl-one-liner-with-a-network-connection.yaml +6 -0
  100. actaeon/hunts/execution/rare-parent-child-process-pair.yaml +6 -0
  101. actaeon/hunts/execution/script-host-from-a-download-or-temp-folder.yaml +6 -0
  102. actaeon/hunts/execution/unix-shell-from-a-web-server.yaml +6 -0
  103. actaeon/hunts/execution/wmi-process-creation.yaml +6 -0
  104. actaeon/hunts/exfiltration/large-outbound-transfer-at-night.yaml +6 -0
  105. actaeon/hunts/exfiltration/upload-to-cloud-storage.yaml +6 -0
  106. actaeon/hunts/impact/mass-file-changes.yaml +6 -0
  107. actaeon/hunts/impact/service-stop-spree.yaml +6 -0
  108. actaeon/hunts/impact/shadow-copy-deletion.yaml +6 -0
  109. actaeon/hunts/initial-access/account-logging-in-from-a-first-seen-source.yaml +6 -0
  110. actaeon/hunts/initial-access/cloud-access-key-used-from-a-new-location.yaml +6 -0
  111. actaeon/hunts/initial-access/impossible-travel.yaml +6 -0
  112. actaeon/hunts/initial-access/phishing-attachment-to-process.yaml +6 -0
  113. actaeon/hunts/lateral-movement/pass-the-hash-pattern.yaml +6 -0
  114. actaeon/hunts/lateral-movement/rdp-from-an-unusual-source.yaml +6 -0
  115. actaeon/hunts/lateral-movement/remote-powershell-or-winrm-use.yaml +6 -0
  116. actaeon/hunts/lateral-movement/remote-service-execution.yaml +6 -0
  117. actaeon/hunts/lateral-movement/ssh-lateral-movement.yaml +6 -0
  118. actaeon/hunts/persistence/image-file-execution-options-hijack.yaml +6 -0
  119. actaeon/hunts/persistence/new-cloud-admin-or-role-assignment.yaml +6 -0
  120. actaeon/hunts/persistence/new-privileged-account.yaml +6 -0
  121. actaeon/hunts/persistence/new-service-installed.yaml +6 -0
  122. actaeon/hunts/persistence/run-key-persistence.yaml +6 -0
  123. actaeon/hunts/persistence/ssh-authorized-keys-change.yaml +6 -0
  124. actaeon/hunts/persistence/startup-folder-drop.yaml +6 -0
  125. actaeon/hunts/persistence/web-shell-on-a-web-server.yaml +6 -0
  126. actaeon/hunts/persistence/wmi-event-subscription.yaml +6 -0
  127. actaeon/hunts/privilege-escalation/process-injection-indicators.yaml +6 -0
  128. actaeon/hunts/privilege-escalation/sudo-or-su-abuse.yaml +6 -0
  129. actaeon/hunts/privilege-escalation/uac-bypass-pattern.yaml +6 -0
  130. actaeon/hunts/stealth/masquerading-binary-name.yaml +6 -0
  131. actaeon/hunts/stealth/signed-binary-proxy-execution.yaml +6 -0
  132. actaeon/hunts/stealth/timestomping-or-hidden-files.yaml +6 -0
  133. actaeon/intel/__init__.py +6 -0
  134. actaeon/intel/extract.py +124 -0
  135. actaeon/intel/feeds.py +131 -0
  136. actaeon/intel/fetch.py +175 -0
  137. actaeon/intel/relevance.py +103 -0
  138. actaeon/intel/stix.py +145 -0
  139. actaeon/intel/store.py +319 -0
  140. actaeon/intel/web.py +138 -0
  141. actaeon/jobs.py +358 -0
  142. actaeon/leads.py +225 -0
  143. actaeon/mcp_server.py +167 -0
  144. actaeon/outputs.py +247 -0
  145. actaeon/paths.py +26 -0
  146. actaeon/privacy.py +155 -0
  147. actaeon/providers/__init__.py +193 -0
  148. actaeon/providers/anthropic.py +65 -0
  149. actaeon/providers/base.py +156 -0
  150. actaeon/providers/cli.py +86 -0
  151. actaeon/providers/demo.py +121 -0
  152. actaeon/providers/gemini.py +230 -0
  153. actaeon/providers/ollama.py +69 -0
  154. actaeon/providers/openai_compat.py +87 -0
  155. actaeon/reporting.py +262 -0
  156. actaeon/retention.py +80 -0
  157. actaeon/server.py +81 -0
  158. actaeon/static/app.css +98 -0
  159. actaeon/static/app.js +858 -0
  160. actaeon/static/mdi/LICENSE +20 -0
  161. actaeon/static/mdi/README.md +5 -0
  162. actaeon/static/mdi/css/icons.css +103 -0
  163. actaeon/static/mdi/fonts/icons.woff2 +0 -0
  164. actaeon/tasks.py +79 -0
  165. actaeon/templates/index.html +31 -0
  166. actaeon/templates/login.html +18 -0
  167. actaeon/tickets.py +116 -0
  168. actaeon/web.py +812 -0
  169. actaeon/worker.py +117 -0
  170. actaeon-0.2.0b0.dist-info/METADATA +339 -0
  171. actaeon-0.2.0b0.dist-info/RECORD +176 -0
  172. actaeon-0.2.0b0.dist-info/WHEEL +5 -0
  173. actaeon-0.2.0b0.dist-info/entry_points.txt +2 -0
  174. actaeon-0.2.0b0.dist-info/licenses/LICENSE +202 -0
  175. actaeon-0.2.0b0.dist-info/licenses/NOTICE +16 -0
  176. actaeon-0.2.0b0.dist-info/top_level.txt +1 -0
actaeon/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Actaeon: an open-source AI threat hunter that works through MCP connectors."""
2
+
3
+ __version__ = "0.2.0-beta"
actaeon/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import cli_main
2
+
3
+ cli_main()
actaeon/access.py ADDED
@@ -0,0 +1,137 @@
1
+ """Who is asking, and which data sources they may use and see.
2
+
3
+ A connector can list who may use it::
4
+
5
+ connectors:
6
+ - name: mail
7
+ roles: [email]
8
+ access: [insider-threat, "role:admin", "user:jdoe"] # group names, role:<role>, user:<name>
9
+
10
+ No ``access`` (the default) means everyone. Admins can use every connector.
11
+
12
+ The caller is held in a context variable: the web guard sets it per request,
13
+ and jobs started from a request inherit it (the job runner copies the context),
14
+ so a hunt runs with the permissions of whoever started it. Scheduled and
15
+ command-line work has no caller and sees everything.
16
+
17
+ A hunt that called a connector with ``access`` is *restricted* to the people
18
+ who may use those connectors: other people cannot list or open it, its lead,
19
+ reports, chat or exports, and it is left out of other people's hunt memory.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import contextvars
25
+ from dataclasses import dataclass, field
26
+
27
+ ROLE_RANK = {"viewer": 0, "operator": 1, "admin": 2}
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Access:
32
+ user: str = ""
33
+ role: str = "viewer"
34
+ groups: tuple[str, ...] = ()
35
+ connectors: frozenset[str] | None = None # None: every connector
36
+ restricted_names: frozenset[str] = field(default_factory=frozenset)
37
+
38
+ def to_dict(self) -> dict:
39
+ return {"user": self.user, "role": self.role, "groups": list(self.groups),
40
+ "connectors": None if self.connectors is None else sorted(self.connectors),
41
+ "restricted_names": sorted(self.restricted_names)}
42
+
43
+ @classmethod
44
+ def from_dict(cls, d: dict) -> Access:
45
+ c = d.get("connectors")
46
+ return cls(user=str(d.get("user", "")), role=str(d.get("role", "viewer")), groups=tuple(d.get("groups") or ()),
47
+ connectors=None if c is None else frozenset(c),
48
+ restricted_names=frozenset(d.get("restricted_names") or ()))
49
+
50
+ def public(self) -> dict:
51
+ return {"user": self.user, "role": self.role, "groups": list(self.groups),
52
+ "connectors": None if self.connectors is None else sorted(self.connectors)}
53
+
54
+
55
+ _current: contextvars.ContextVar[Access | None] = contextvars.ContextVar("actaeon_access", default=None)
56
+
57
+
58
+ def current() -> Access | None:
59
+ return _current.get()
60
+
61
+
62
+ def set_current(a: Access | None):
63
+ return _current.set(a)
64
+
65
+
66
+ def reset(token) -> None:
67
+ _current.reset(token)
68
+
69
+
70
+ def matches(entry: str, user: str, role: str, groups) -> bool:
71
+ e = str(entry).strip()
72
+ if e.startswith("role:"):
73
+ want = e[5:].strip().lower()
74
+ return want in ROLE_RANK and ROLE_RANK.get(role, -1) >= ROLE_RANK[want]
75
+ if e.startswith("user:"):
76
+ return bool(user) and e[5:].strip().lower() == str(user).lower()
77
+ return e in set(groups or ())
78
+
79
+
80
+ def build(specs, *, user: str, role: str, groups=()) -> Access:
81
+ """The Access for this caller over these connector specs (an iterable of ConnectorSpec)."""
82
+ specs = list(specs)
83
+ restricted = frozenset(s.name for s in specs if getattr(s, "access", None))
84
+ groups = tuple(sorted({str(g) for g in groups or () if str(g).strip()}))
85
+ if role == "admin" or not restricted:
86
+ return Access(user=user, role=role, groups=groups, connectors=None, restricted_names=restricted)
87
+ allowed = frozenset(s.name for s in specs
88
+ if not getattr(s, "access", None) or any(matches(e, user, role, groups) for e in s.access))
89
+ return Access(user=user, role=role, groups=groups, connectors=allowed, restricted_names=restricted)
90
+
91
+
92
+ def can_use(connector: str) -> bool:
93
+ a = current()
94
+ return a is None or a.connectors is None or connector in a.connectors
95
+
96
+
97
+ def can_see(restricted) -> bool:
98
+ """May the current caller see a record that used these restricted connectors?"""
99
+ a = current()
100
+ if a is None or a.connectors is None or not restricted:
101
+ return True
102
+ return set(restricted) <= set(a.connectors)
103
+
104
+
105
+ def scheduler_access(cfg) -> Access | None:
106
+ """The identity the scheduler's own work (sweeps, autopilot, web intel) runs as.
107
+
108
+ scheduler:
109
+ groups: [soc-automation] # connector access lists are matched against these
110
+ role: operator
111
+
112
+ Without a ``scheduler`` section it runs with no person behind it and may use every connector
113
+ (what it reads from restricted sources is then restricted like any hunt). Saved-hunt replays always
114
+ run as the person who saved the hunt."""
115
+ s = ((getattr(cfg, "raw", None) or {}).get("scheduler") or {})
116
+ if not s:
117
+ return None
118
+ from .connectors.spec import parse_connectors
119
+ try:
120
+ specs = parse_connectors(list(getattr(cfg, "connectors", None) or []))
121
+ except Exception:
122
+ specs = []
123
+ role = str(s.get("role", "operator")).lower()
124
+ if role not in ROLE_RANK:
125
+ raise ValueError("scheduler.role must be viewer, operator or admin")
126
+ groups = s.get("groups") or []
127
+ if not isinstance(groups, list):
128
+ raise ValueError("scheduler.groups must be a list")
129
+ return build(specs, user=str(s.get("user", "scheduler")), role=role, groups=groups)
130
+
131
+
132
+ def run_as(a: Access | None, fn, *args, **kw):
133
+ tok = set_current(a)
134
+ try:
135
+ return fn(*args, **kw)
136
+ finally:
137
+ reset(tok)
actaeon/assistant.py ADDED
@@ -0,0 +1,209 @@
1
+ """The Ask assistant: questions and hunt requests in plain language.
2
+
3
+ Actions the model can take (one JSON object per turn):
4
+ search_records {query} past hunts, leads and analyst notes that match
5
+ get_hunt {id} one hunt's verdict and findings
6
+ list_leads {status} open or recent leads
7
+ lookup_intel {value} is a value in the intel store
8
+ start_hunt {hypothesis, context} run a full hunt now (stored like any hunt, origin ask:<user>)
9
+ ask_hunt {hunt_id, question} follow-up on a finished hunt (its chat; new searches become evidence)
10
+ answer {text, hunts, leads} final reply
11
+ Data is only searched inside hunts, so everything the assistant finds is stored
12
+ with its evidence. Conversations are stored with links to the hunts and leads used.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import re
19
+ import threading
20
+ from datetime import datetime, timezone
21
+
22
+ from . import db
23
+ from .connectors.untrusted import SYSTEM_RULE
24
+ from .hunt.jsonparse import first_object
25
+
26
+ MAX_ACTIONS = 6
27
+ MAX_HUNTS = 2
28
+ _lock = threading.Lock()
29
+
30
+ SYSTEM = f"""You are Actaeon's assistant for a security team. You answer questions and run threat hunts.
31
+ Reply with exactly ONE JSON object per turn, one of:
32
+ {{"action": "search_records", "query": "words"}}
33
+ {{"action": "get_hunt", "id": "hunt id"}}
34
+ {{"action": "list_leads", "status": "open|new|closed"}}
35
+ {{"action": "lookup_intel", "value": "IP, domain, hash ..."}}
36
+ {{"action": "start_hunt", "hypothesis": "a specific, testable hypothesis with a time window", "context": "why"}}
37
+ {{"action": "ask_hunt", "hunt_id": "id", "question": "follow-up question"}}
38
+ {{"action": "answer", "text": "plain answer", "hunts": ["ids you used"], "leads": [lead ids]}}
39
+ Rules: to find out whether something happened in the environment, start a hunt (you cannot search data
40
+ directly). Check records first so you do not repeat a recent hunt. Every factual claim must come from a tool
41
+ result; say plainly when you do not know. Name hunt ids and lead ids in the answer. At most {MAX_HUNTS} hunts.
42
+ {SYSTEM_RULE}"""
43
+
44
+
45
+ def _now() -> str:
46
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
47
+
48
+
49
+ class Conversations:
50
+ def __init__(self, path):
51
+ self.path = path
52
+ with self._conn() as c:
53
+ c.executescript("""
54
+ CREATE TABLE IF NOT EXISTS conversations (id INTEGER PRIMARY KEY AUTOINCREMENT, user TEXT NOT NULL,
55
+ created TEXT NOT NULL, updated TEXT NOT NULL, title TEXT NOT NULL);
56
+ CREATE TABLE IF NOT EXISTS conv_msgs (id INTEGER PRIMARY KEY AUTOINCREMENT, conv_id INTEGER NOT NULL,
57
+ ts TEXT NOT NULL, role TEXT NOT NULL, user TEXT, content TEXT NOT NULL, links TEXT);
58
+ """)
59
+
60
+ def _conn(self):
61
+ c = db.connect(self.path)
62
+ return c
63
+
64
+ def create(self, user: str, title: str) -> int:
65
+ with _lock, self._conn() as c:
66
+ return c.execute("INSERT INTO conversations (user, created, updated, title) VALUES (?,?,?,?)",
67
+ (user, _now(), _now(), title[:120])).lastrowid
68
+
69
+ def add(self, cid: int, role: str, content: str, user: str = "", links: dict | None = None) -> None:
70
+ with _lock, self._conn() as c:
71
+ c.execute("INSERT INTO conv_msgs (conv_id, ts, role, user, content, links) VALUES (?,?,?,?,?,?)",
72
+ (cid, _now(), role, user, content[:20000], json.dumps(links or {})))
73
+ c.execute("UPDATE conversations SET updated=? WHERE id=?", (_now(), cid))
74
+
75
+ @staticmethod
76
+ def _mine(owner: str) -> bool:
77
+ """With connector access limits in force, people only see their own conversations (answers can
78
+ quote restricted hunts). Admins and unrestricted callers see all."""
79
+ from .access import current
80
+ a = current()
81
+ return a is None or a.connectors is None or owner == a.user
82
+
83
+ def get(self, cid: int) -> dict | None:
84
+ with self._conn() as c:
85
+ r = c.execute("SELECT * FROM conversations WHERE id=?", (int(cid),)).fetchone()
86
+ if not r or not self._mine(r["user"]):
87
+ return None
88
+ msgs = [{**dict(m), "links": json.loads(m["links"] or "{}")}
89
+ for m in c.execute("SELECT * FROM conv_msgs WHERE conv_id=? ORDER BY id", (int(cid),))]
90
+ return {**dict(r), "messages": msgs}
91
+
92
+ def list(self, limit: int = 100) -> list[dict]:
93
+ with self._conn() as c:
94
+ return [dict(r) for r in c.execute("SELECT * FROM conversations ORDER BY updated DESC LIMIT ?",
95
+ (int(limit),)) if self._mine(r["user"])]
96
+
97
+
98
+ def _records(cfg, query: str) -> dict:
99
+ from .hunt import service as hs
100
+ mem = hs.memory_for(cfg).related(query, k=5)
101
+ words = [w for w in re.findall(r"[A-Za-z0-9._@:-]{3,}", query.lower())][:8]
102
+ hunts = []
103
+ for h in hs.store_for(cfg).list(limit=300):
104
+ text = (h["hypothesis"] + " " + json.dumps(h.get("verdict") or {})).lower()
105
+ if words and sum(w in text for w in words) >= max(1, len(words) // 2):
106
+ hunts.append({"id": h["id"], "created": h["created"], "outcome": h["outcome"],
107
+ "hypothesis": h["hypothesis"][:200], "summary": ((h.get("verdict") or {}).get("summary")
108
+ or "")[:300]})
109
+ if len(hunts) >= 8:
110
+ break
111
+ leads = [{"id": c["id"], "status": c["status"], "severity": c["severity"], "title": c["title"][:160]}
112
+ for c in hs.leads_for(cfg).list(limit=200)
113
+ if words and any(w in c["title"].lower() for w in words)][:8]
114
+ return {"hunts": hunts, "leads": leads, "notes": [n["text"][:300] for n in mem["notes"]],
115
+ "related_past_hunts": [{"id": h["hunt_id"], "outcome": h["outcome"], "hypothesis": h["hypothesis"][:160]}
116
+ for h in mem["hunts"]]}
117
+
118
+
119
+ def _hunt_brief(h: dict) -> dict:
120
+ v = h.get("verdict") or {}
121
+ return {"id": h["id"], "status": h["status"], "outcome": h.get("outcome"), "confidence": v.get("confidence"),
122
+ "summary": v.get("summary"), "findings": [f"{f['claim']} ({', '.join(f['evidence'])})"
123
+ for f in v.get("findings") or []],
124
+ "verification": (v.get("verification") or {}).get("result"), "needs_review": v.get("needs_review")}
125
+
126
+
127
+ def ask(cfg, message: str, *, user: str, conv_id: int | None = None, llm=None, hub=None) -> dict:
128
+ from .hunt import service as hs
129
+ message = (message or "").strip()[:4000]
130
+ if not message:
131
+ raise ValueError("message is required")
132
+ conv = Conversations(hs.store_for(cfg).path)
133
+ if conv_id is None or not conv.get(conv_id):
134
+ conv_id = conv.create(user, message)
135
+ conv.add(conv_id, "user", message, user)
136
+ if llm is None:
137
+ from .providers import get_llm
138
+ llm = get_llm()
139
+ plm = hs.privacy_llm(cfg, llm)
140
+ hist = (conv.get(conv_id) or {}).get("messages", [])[-10:-1]
141
+ history = "\n".join(f"{m['role'].upper()}: {m['content'][:1200]}" for m in hist)
142
+ msgs = [{"role": "system", "content": SYSTEM},
143
+ {"role": "user", "content": f"{hs.environment_context(cfg)}\n\nEarlier in this conversation:\n"
144
+ f"{history or '(none)'}\n\nNew message from {user}: {message}"}]
145
+ used_hunts: list[str] = []
146
+ used_leads: list[int] = []
147
+ started = 0
148
+ final = None
149
+ for step in range(MAX_ACTIONS + 1):
150
+ if step == MAX_ACTIONS:
151
+ msgs.append({"role": "user", "content": "No more actions. Give the answer now."})
152
+ res = plm.for_role("analyst").chat(msgs, max_tokens=1500)
153
+ if res.error:
154
+ final = {"text": f"(the model could not answer: {res.error[:200]})"}
155
+ break
156
+ act = first_object(res.text) or {}
157
+ kind = act.get("action")
158
+ if kind == "answer" or step == MAX_ACTIONS or not kind:
159
+ final = act if kind == "answer" else {"text": res.text[:4000]}
160
+ break
161
+ msgs.append({"role": "assistant", "content": json.dumps(act)[:3000]})
162
+ try:
163
+ if kind == "search_records":
164
+ out = _records(cfg, str(act.get("query", "")))
165
+ elif kind == "get_hunt":
166
+ h = hs.store_for(cfg).get(str(act.get("id", "")))
167
+ out = _hunt_brief(h) if h else {"error": "no such hunt"}
168
+ if h:
169
+ used_hunts.append(h["id"])
170
+ elif kind == "list_leads":
171
+ stt = str(act.get("status", "open"))
172
+ rows = hs.leads_for(cfg).list(open_only=stt == "open", status=stt if stt in ("new", "closed") else None,
173
+ limit=20)
174
+ out = [{"id": c["id"], "status": c["status"], "severity": c["severity"], "assignee": c["assignee"],
175
+ "title": c["title"][:160]} for c in rows]
176
+ elif kind == "lookup_intel":
177
+ out = hs.intel_for(cfg).lookup(str(act.get("value", "")))
178
+ elif kind == "start_hunt":
179
+ if started >= MAX_HUNTS:
180
+ out = {"error": f"at most {MAX_HUNTS} hunts per message"}
181
+ else:
182
+ started += 1
183
+ hyp = str(act.get("hypothesis", "")).strip()[:1000]
184
+ h = hs.run_hunt(cfg, hyp, origin=f"ask:{user}", actor=user,
185
+ context=f"Requested in the Ask assistant by {user}: {message[:500]}\n"
186
+ f"{str(act.get('context', ''))[:500]}", llm=llm, hub=hub)
187
+ used_hunts.append(h["id"])
188
+ lead = hs.leads_for(cfg).by_hunt(h["id"])
189
+ if lead:
190
+ used_leads.append(lead["id"])
191
+ out = {**_hunt_brief(h), "lead": lead["id"] if lead else None}
192
+ elif kind == "ask_hunt":
193
+ from .hunt.chat import ask as hunt_ask
194
+ hid = str(act.get("hunt_id", ""))
195
+ out = hunt_ask(cfg, hid, str(act.get("question", "")), author=user, llm=llm, hub=hub)
196
+ used_hunts.append(hid)
197
+ else:
198
+ out = {"error": f"unknown action {kind!r}"}
199
+ except (ValueError, LookupError) as exc:
200
+ out = {"error": str(exc)[:300]}
201
+ msgs.append({"role": "user", "content": f"Result of {kind}:\n<<UNTRUSTED-DATA ask>>\n"
202
+ f"{json.dumps(out, default=str)[:12000]}\n<<END-UNTRUSTED-DATA ask>>"})
203
+ text = str((final or {}).get("text") or "(no answer)")[:8000]
204
+ hunts = list(dict.fromkeys(used_hunts + [h for h in (final or {}).get("hunts") or [] if isinstance(h, str)
205
+ and hs.store_for(cfg).get(h, with_steps=False)]))
206
+ leads = list(dict.fromkeys(used_leads + [c for c in (final or {}).get("leads") or [] if isinstance(c, int)
207
+ and hs.leads_for(cfg).get(c)]))
208
+ conv.add(conv_id, "assistant", text, "actaeon", {"hunts": hunts, "leads": leads})
209
+ return {"conversation_id": conv_id, "answer": text, "hunts": hunts, "leads": leads}