haris 0.7.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.
Files changed (370) hide show
  1. haris/__init__.py +6 -0
  2. haris/__main__.py +5 -0
  3. haris/agent/__init__.py +21 -0
  4. haris/agent/audit.py +67 -0
  5. haris/agent/budget.py +52 -0
  6. haris/agent/coordinator.py +253 -0
  7. haris/agent/docker_runtime.py +403 -0
  8. haris/agent/guardrails.py +176 -0
  9. haris/agent/models.py +43 -0
  10. haris/agent/policy.py +127 -0
  11. haris/agent/prompts.py +17 -0
  12. haris/agent/proxy.py +309 -0
  13. haris/agent/proxy_auth.py +26 -0
  14. haris/agent/runtime.py +133 -0
  15. haris/agent/settings.py +45 -0
  16. haris/agent/stages.py +5 -0
  17. haris/agent/turn.py +118 -0
  18. haris/agent/validator.py +121 -0
  19. haris/ai_security/__init__.py +3 -0
  20. haris/ai_security/active.py +294 -0
  21. haris/ai_security/adapters.py +151 -0
  22. haris/ai_security/body_validators.py +174 -0
  23. haris/ai_security/bundles.py +47 -0
  24. haris/ai_security/discovery.py +94 -0
  25. haris/ai_security/findings.py +81 -0
  26. haris/ai_security/mcp_probe.py +133 -0
  27. haris/ai_security/mcp_static.py +169 -0
  28. haris/ai_security/options.py +37 -0
  29. haris/ai_security/owasp_ai.py +86 -0
  30. haris/ai_security/passive.py +275 -0
  31. haris/ai_security/probes.py +82 -0
  32. haris/ai_security/rpc.py +64 -0
  33. haris/ai_security/scanner.py +70 -0
  34. haris/ai_security/signatures.py +67 -0
  35. haris/auth/__init__.py +0 -0
  36. haris/auth/api_tokens.py +63 -0
  37. haris/auth/audit.py +54 -0
  38. haris/auth/bearer.py +57 -0
  39. haris/auth/bootstrap.py +37 -0
  40. haris/auth/config.py +87 -0
  41. haris/auth/cookies.py +47 -0
  42. haris/auth/csrf.py +65 -0
  43. haris/auth/database.py +49 -0
  44. haris/auth/email.py +100 -0
  45. haris/auth/middleware.py +75 -0
  46. haris/auth/models.py +104 -0
  47. haris/auth/oidc.py +115 -0
  48. haris/auth/public.py +16 -0
  49. haris/auth/redirects.py +38 -0
  50. haris/auth/security_headers.py +72 -0
  51. haris/auth/service.py +276 -0
  52. haris/auth/tokens.py +5 -0
  53. haris/auth/users.py +167 -0
  54. haris/auth/verification.py +94 -0
  55. haris/bench/__init__.py +0 -0
  56. haris/bench/__main__.py +49 -0
  57. haris/bench/render.py +34 -0
  58. haris/bench/score.py +205 -0
  59. haris/bench/truth.py +87 -0
  60. haris/checks/__init__.py +35 -0
  61. haris/checks/baseline.py +243 -0
  62. haris/checks/cache_checks.py +67 -0
  63. haris/checks/cookie_checks.py +259 -0
  64. haris/checks/cookie_policy.py +79 -0
  65. haris/checks/dns_checks.py +164 -0
  66. haris/checks/dns_wire.py +123 -0
  67. haris/checks/findings.py +30 -0
  68. haris/checks/graphql_checks.py +82 -0
  69. haris/checks/header_checks.py +231 -0
  70. haris/checks/header_policy.py +175 -0
  71. haris/checks/info_disclosure.py +229 -0
  72. haris/checks/info_patterns.py +142 -0
  73. haris/checks/listing_checks.py +75 -0
  74. haris/checks/method_checks.py +76 -0
  75. haris/checks/misc_checks.py +239 -0
  76. haris/checks/openapi_checks.py +67 -0
  77. haris/checks/path_probe.py +50 -0
  78. haris/checks/probe_scanner.py +26 -0
  79. haris/checks/redirect_checks.py +80 -0
  80. haris/checks/requesting.py +66 -0
  81. haris/checks/sri_checks.py +87 -0
  82. haris/checks/tls_checks.py +218 -0
  83. haris/checks/tls_probe.py +150 -0
  84. haris/checks/validators.py +173 -0
  85. haris/cli.py +648 -0
  86. haris/config/__init__.py +6 -0
  87. haris/config/loader.py +201 -0
  88. haris/config/paths.py +14 -0
  89. haris/core/__init__.py +71 -0
  90. haris/core/confidence.py +76 -0
  91. haris/core/context_extract.py +89 -0
  92. haris/core/correlator.py +64 -0
  93. haris/core/decorators.py +105 -0
  94. haris/core/engine.py +378 -0
  95. haris/core/exit_codes.py +24 -0
  96. haris/core/identity.py +93 -0
  97. haris/core/llm_enrichment.py +83 -0
  98. haris/core/owasp.py +222 -0
  99. haris/core/profiles.py +193 -0
  100. haris/core/remediation.py +119 -0
  101. haris/core/risk.py +221 -0
  102. haris/core/runner.py +218 -0
  103. haris/core/scanner.py +141 -0
  104. haris/core/tool_vet.py +35 -0
  105. haris/core/vuln_class.py +66 -0
  106. haris/data/__init__.py +1 -0
  107. haris/data/scanner_config.py +305 -0
  108. haris/db/__init__.py +5 -0
  109. haris/db/api_tokens.py +86 -0
  110. haris/db/baseline.py +236 -0
  111. haris/db/connection.py +22 -0
  112. haris/db/findings.py +48 -0
  113. haris/db/jobs.py +166 -0
  114. haris/db/llm_data.py +119 -0
  115. haris/db/migrate.py +70 -0
  116. haris/db/migrations/0001_baseline.py +5 -0
  117. haris/db/migrations/0002_hash_tokens.py +21 -0
  118. haris/db/migrations/0003_finding_identity.py +77 -0
  119. haris/db/migrations/0004_scan_owner_state.py +33 -0
  120. haris/db/migrations/0005_api_tokens.py +30 -0
  121. haris/db/migrations/0006_authorized_targets.py +26 -0
  122. haris/db/migrations/0007_job_queue.py +29 -0
  123. haris/db/migrations/0008_agent_fields.py +20 -0
  124. haris/db/scans.py +162 -0
  125. haris/db/session_read.py +180 -0
  126. haris/db/session_write.py +163 -0
  127. haris/db/store.py +149 -0
  128. haris/db/targets.py +73 -0
  129. haris/db/template_defaults.py +144 -0
  130. haris/db/templates.py +157 -0
  131. haris/db/websites.py +194 -0
  132. haris/diff.py +154 -0
  133. haris/ignore.py +111 -0
  134. haris/llm/__init__.py +28 -0
  135. haris/llm/backend.py +239 -0
  136. haris/llm/base.py +88 -0
  137. haris/llm/budget.py +106 -0
  138. haris/llm/catalog.py +86 -0
  139. haris/llm/correlation.py +77 -0
  140. haris/llm/enrichment.py +117 -0
  141. haris/llm/enrichment_prompts.py +86 -0
  142. haris/llm/parsing.py +84 -0
  143. haris/llm/prompts.py +253 -0
  144. haris/llm/providers.py +221 -0
  145. haris/llm/qa.py +249 -0
  146. haris/llm/redaction.py +75 -0
  147. haris/llm/retriever.py +113 -0
  148. haris/llm/retry.py +92 -0
  149. haris/llm/router.py +70 -0
  150. haris/llm/schemas.py +91 -0
  151. haris/llm/settings.py +98 -0
  152. haris/llm/structured.py +117 -0
  153. haris/llm/template_loader.py +47 -0
  154. haris/llm/templates/enrichment/assess_false_positives.j2 +22 -0
  155. haris/llm/templates/enrichment/enrich_finding.j2 +33 -0
  156. haris/llm/templates/enrichment/identify_attack_chains.j2 +23 -0
  157. haris/llm/templates/enrichment/suggest_variants.j2 +26 -0
  158. haris/llm/templates/enrichment/system.j2 +7 -0
  159. haris/llm/templates/enrichment/triage_findings.j2 +24 -0
  160. haris/llm/templates/qa/explain_finding.j2 +12 -0
  161. haris/llm/templates/qa/filter_and_explain.j2 +12 -0
  162. haris/llm/templates/qa/freeform_question.j2 +6 -0
  163. haris/llm/templates/qa/generate_test_cases.j2 +12 -0
  164. haris/llm/templates/qa/remediation_plan.j2 +4 -0
  165. haris/llm/templates/qa/suggest_mitigations.j2 +12 -0
  166. haris/llm/templates/qa/summarize_report.j2 +4 -0
  167. haris/llm/templates/qa/system.j2 +8 -0
  168. haris/llm/triage.py +97 -0
  169. haris/llm/variant_analysis.py +76 -0
  170. haris/models/__init__.py +89 -0
  171. haris/models/chat.py +41 -0
  172. haris/models/config.py +62 -0
  173. haris/models/correlator.py +25 -0
  174. haris/models/enrichment.py +69 -0
  175. haris/models/enums.py +131 -0
  176. haris/models/finding.py +131 -0
  177. haris/models/llm.py +18 -0
  178. haris/models/owasp.py +67 -0
  179. haris/models/profiles.py +18 -0
  180. haris/models/remediation.py +36 -0
  181. haris/models/risk.py +12 -0
  182. haris/models/scan_config_template.py +60 -0
  183. haris/models/scan_context.py +63 -0
  184. haris/models/scanner.py +21 -0
  185. haris/models/session.py +121 -0
  186. haris/models/target.py +230 -0
  187. haris/models/templates.py +82 -0
  188. haris/models/verification.py +69 -0
  189. haris/net/__init__.py +26 -0
  190. haris/net/errors.py +18 -0
  191. haris/net/policy.py +22 -0
  192. haris/net/ratelimit.py +53 -0
  193. haris/net/redact.py +16 -0
  194. haris/net/requestlog.py +76 -0
  195. haris/net/retry.py +20 -0
  196. haris/net/scoped_client.py +348 -0
  197. haris/net/ssrf.py +86 -0
  198. haris/net/transport.py +108 -0
  199. haris/reporting/__init__.py +65 -0
  200. haris/reporting/base.py +28 -0
  201. haris/reporting/csv_report.py +59 -0
  202. haris/reporting/escape.py +43 -0
  203. haris/reporting/findings.py +33 -0
  204. haris/reporting/html_report.py +126 -0
  205. haris/reporting/json_report.py +48 -0
  206. haris/reporting/junit_report.py +62 -0
  207. haris/reporting/markdown_report.py +236 -0
  208. haris/reporting/report_schema.py +92 -0
  209. haris/reporting/sarif_report.py +192 -0
  210. haris/resources/__init__.py +0 -0
  211. haris/resources/config/agent.yaml +24 -0
  212. haris/resources/config/ai_mappings.yaml +221 -0
  213. haris/resources/config/ai_probes.yaml +47 -0
  214. haris/resources/config/ai_signatures.yaml +168 -0
  215. haris/resources/config/confidence.yaml +73 -0
  216. haris/resources/config/default_config.yaml +159 -0
  217. haris/resources/config/llm_models.yaml +68 -0
  218. haris/resources/config/scanner_data.yaml +43 -0
  219. haris/resources/config/scanners/garak.yaml +14 -0
  220. haris/resources/config/scanners/nikto.yaml +116 -0
  221. haris/resources/config/scanners/nmap.yaml +48 -0
  222. haris/resources/config/scanners/nuclei.yaml +361 -0
  223. haris/resources/config/scanners/promptfoo.yaml +23 -0
  224. haris/resources/config/scanners/sslyze.yaml +64 -0
  225. haris/resources/config/scanners/wapiti.yaml +83 -0
  226. haris/resources/config/scanners/zap.yaml +22 -0
  227. haris/resources/config/verify.yaml +56 -0
  228. haris/resources/config/vuln_classes.yaml +356 -0
  229. haris/resources/schema/report.schema.json +670 -0
  230. haris/scan_files.py +30 -0
  231. haris/scanners/__init__.py +44 -0
  232. haris/scanners/argv_policy.py +47 -0
  233. haris/scanners/base.py +76 -0
  234. haris/scanners/garak_scanner.py +183 -0
  235. haris/scanners/nikto_scanner.py +258 -0
  236. haris/scanners/nmap_scanner.py +261 -0
  237. haris/scanners/nuclei_results.py +239 -0
  238. haris/scanners/nuclei_scanner.py +470 -0
  239. haris/scanners/nuclei_templates.py +26 -0
  240. haris/scanners/nuclei_urls.py +55 -0
  241. haris/scanners/option_policy.py +242 -0
  242. haris/scanners/option_values.py +74 -0
  243. haris/scanners/process.py +60 -0
  244. haris/scanners/promptfoo_scanner.py +247 -0
  245. haris/scanners/redaction.py +57 -0
  246. haris/scanners/sslyze_scanner.py +244 -0
  247. haris/scanners/wapiti_exclude.py +27 -0
  248. haris/scanners/wapiti_scanner.py +264 -0
  249. haris/scanners/wapiti_session.py +104 -0
  250. haris/scanners/zap_plan.py +155 -0
  251. haris/scanners/zap_scanner.py +244 -0
  252. haris/templates/__init__.py +9 -0
  253. haris/templates/adapters.py +336 -0
  254. haris/templates/manager.py +174 -0
  255. haris/templates/report.py +49 -0
  256. haris/templates/updater.py +201 -0
  257. haris/verify/__init__.py +15 -0
  258. haris/verify/evidence.py +41 -0
  259. haris/verify/llm_verdict.py +148 -0
  260. haris/verify/oracles.py +150 -0
  261. haris/verify/oracles_baseline.py +54 -0
  262. haris/verify/pipeline.py +66 -0
  263. haris/verify/probe.py +88 -0
  264. haris/verify/replay.py +142 -0
  265. haris/verify/settings.py +71 -0
  266. haris/verify/timing.py +63 -0
  267. haris/verify/verdict_prompt.py +72 -0
  268. haris/web/__init__.py +1 -0
  269. haris/web/access.py +64 -0
  270. haris/web/agent_gate.py +158 -0
  271. haris/web/app.py +108 -0
  272. haris/web/conversations.py +51 -0
  273. haris/web/job_queue.py +148 -0
  274. haris/web/llm_support.py +43 -0
  275. haris/web/logging_setup.py +23 -0
  276. haris/web/page_data.py +19 -0
  277. haris/web/routers/__init__.py +43 -0
  278. haris/web/routers/account.py +88 -0
  279. haris/web/routers/admin.py +153 -0
  280. haris/web/routers/admin_api.py +15 -0
  281. haris/web/routers/auth_login.py +309 -0
  282. haris/web/routers/health.py +8 -0
  283. haris/web/routers/llm_chat.py +159 -0
  284. haris/web/routers/llm_scan.py +96 -0
  285. haris/web/routers/oidc.py +76 -0
  286. haris/web/routers/pages.py +146 -0
  287. haris/web/routers/scan_data.py +92 -0
  288. haris/web/routers/scan_templates.py +106 -0
  289. haris/web/routers/scans.py +93 -0
  290. haris/web/routers/scans_api.py +334 -0
  291. haris/web/routers/targets.py +85 -0
  292. haris/web/routers/templates_api.py +226 -0
  293. haris/web/routers/tokens.py +78 -0
  294. haris/web/routers/websites.py +58 -0
  295. haris/web/routers/websites_api.py +50 -0
  296. haris/web/routing.py +80 -0
  297. haris/web/scan_runtime.py +169 -0
  298. haris/web/scanner_info.py +32 -0
  299. haris/web/schemas/__init__.py +0 -0
  300. haris/web/schemas/access.py +46 -0
  301. haris/web/schemas/llm.py +99 -0
  302. haris/web/schemas/scans.py +58 -0
  303. haris/web/schemas/sites.py +77 -0
  304. haris/web/settings.py +46 -0
  305. haris/web/state.py +40 -0
  306. haris/web/static/css/findings.css +248 -0
  307. haris/web/static/css/fonts.css +28 -0
  308. haris/web/static/css/layout.css +241 -0
  309. haris/web/static/css/pages.css +220 -0
  310. haris/web/static/css/template_form.css +54 -0
  311. haris/web/static/favicon.svg +4 -0
  312. haris/web/static/haris_logo.png +0 -0
  313. haris/web/static/js/dashboard.js +12 -0
  314. haris/web/static/js/login.js +4 -0
  315. haris/web/static/js/password_form.js +25 -0
  316. haris/web/static/js/profile.js +16 -0
  317. haris/web/static/js/reload.js +1 -0
  318. haris/web/static/js/scan_detail.js +161 -0
  319. haris/web/static/js/scan_new.js +97 -0
  320. haris/web/static/js/scans.js +8 -0
  321. haris/web/static/js/template_form.js +303 -0
  322. haris/web/static/js/templates_page.js +102 -0
  323. haris/web/static/js/users.js +44 -0
  324. haris/web/static/js/website_detail.js +98 -0
  325. haris/web/static/main.js +135 -0
  326. haris/web/static/style.css +335 -0
  327. haris/web/static/theme-init.js +4 -0
  328. haris/web/static/vendor/LICENSE.chartjs +9 -0
  329. haris/web/static/vendor/LICENSE.dompurify +202 -0
  330. haris/web/static/vendor/LICENSE.htmx +13 -0
  331. haris/web/static/vendor/LICENSE.inter +93 -0
  332. haris/web/static/vendor/LICENSE.marked +44 -0
  333. haris/web/static/vendor/README +32 -0
  334. haris/web/static/vendor/chart.umd.min.js +14 -0
  335. haris/web/static/vendor/fonts/inter-latin-400-normal.woff2 +0 -0
  336. haris/web/static/vendor/fonts/inter-latin-500-normal.woff2 +0 -0
  337. haris/web/static/vendor/fonts/inter-latin-600-normal.woff2 +0 -0
  338. haris/web/static/vendor/fonts/inter-latin-700-normal.woff2 +0 -0
  339. haris/web/static/vendor/htmx.min.js +1 -0
  340. haris/web/static/vendor/marked.umd.js +86 -0
  341. haris/web/static/vendor/purify.min.js +7 -0
  342. haris/web/targets.py +190 -0
  343. haris/web/templates/_scan_rows.html +43 -0
  344. haris/web/templates/auth/audit_log.html +107 -0
  345. haris/web/templates/auth/login.html +101 -0
  346. haris/web/templates/auth/profile.html +165 -0
  347. haris/web/templates/auth/register.html +85 -0
  348. haris/web/templates/auth/setup.html +88 -0
  349. haris/web/templates/auth/users.html +155 -0
  350. haris/web/templates/auth/verify_email.html +54 -0
  351. haris/web/templates/base.html +121 -0
  352. haris/web/templates/dashboard.html +113 -0
  353. haris/web/templates/licenses.html +82 -0
  354. haris/web/templates/partials/scan_ai_insights.html +82 -0
  355. haris/web/templates/partials/template_form_scanners_b.html +198 -0
  356. haris/web/templates/scan_detail.html +332 -0
  357. haris/web/templates/scan_new.html +147 -0
  358. haris/web/templates/scans.html +89 -0
  359. haris/web/templates/settings.html +54 -0
  360. haris/web/templates/template_form.html +321 -0
  361. haris/web/templates/templates.html +190 -0
  362. haris/web/templates/website_detail.html +111 -0
  363. haris/web/templates/websites.html +42 -0
  364. haris/web/templating.py +38 -0
  365. haris-0.7.0.dist-info/METADATA +253 -0
  366. haris-0.7.0.dist-info/RECORD +370 -0
  367. haris-0.7.0.dist-info/WHEEL +5 -0
  368. haris-0.7.0.dist-info/entry_points.txt +2 -0
  369. haris-0.7.0.dist-info/licenses/LICENSE +21 -0
  370. haris-0.7.0.dist-info/top_level.txt +1 -0
haris/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("haris")
5
+ except PackageNotFoundError:
6
+ __version__ = "0+unknown"
haris/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,21 @@
1
+ from .policy import (
2
+ AgentPolicy,
3
+ activation_result,
4
+ bind_cli,
5
+ bind_policy,
6
+ current_policy,
7
+ policy_for_web,
8
+ reset_policy,
9
+ )
10
+ from .stages import note_stage
11
+
12
+ __all__ = [
13
+ "AgentPolicy",
14
+ "activation_result",
15
+ "bind_cli",
16
+ "bind_policy",
17
+ "current_policy",
18
+ "note_stage",
19
+ "policy_for_web",
20
+ "reset_policy",
21
+ ]
haris/agent/audit.py ADDED
@@ -0,0 +1,67 @@
1
+ import hashlib
2
+ import json
3
+ import os
4
+ import threading
5
+ from datetime import UTC, datetime
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from ..llm.redaction import redact
10
+
11
+ LOG_CAP = 500
12
+ DIGEST_FIELDS = frozenset({"digest"})
13
+
14
+
15
+ def digest(text: str) -> str:
16
+ return hashlib.sha256(redact(text).encode()).hexdigest()
17
+
18
+
19
+ def _clean(value: str, *, cap: bool, secrets: list[str]) -> str:
20
+ cleaned = "".join(ch for ch in redact(value) if ch >= " " or ch in "\t")
21
+ for secret in secrets:
22
+ cleaned = cleaned.replace(secret, "[REDACTED]")
23
+ return cleaned[:LOG_CAP] if cap else cleaned
24
+
25
+
26
+ def _redact(value: object, key: str, secrets: list[str]) -> object:
27
+ if isinstance(value, str):
28
+ return _clean(value, cap=key not in DIGEST_FIELDS, secrets=secrets)
29
+ if isinstance(value, dict):
30
+ return {str(k): _redact(v, str(k), secrets) for k, v in value.items()}
31
+ if isinstance(value, list):
32
+ return [_redact(item, "", secrets) for item in value]
33
+ return value
34
+
35
+
36
+ class AuditLog:
37
+ """Append-only JSONL. Each record is redacted before it is written."""
38
+
39
+ def __init__(self, path: Path) -> None:
40
+ self.path = path
41
+ path.parent.mkdir(parents=True, exist_ok=True)
42
+ self._fh = path.open("a", encoding="utf-8")
43
+ self._lock = threading.Lock()
44
+ self._secrets: list[str] = []
45
+
46
+ def redact_secret(self, secret: str) -> None:
47
+ """Drop this exact string from every later record."""
48
+ if secret and secret not in self._secrets:
49
+ self._secrets.append(secret)
50
+
51
+ def write(self, kind: str, **fields: object) -> None:
52
+ record: dict[str, Any] = {
53
+ "kind": kind,
54
+ "ts": datetime.now(UTC).isoformat(),
55
+ **{
56
+ key: _redact(value, key, self._secrets) for key, value in fields.items()
57
+ },
58
+ }
59
+ line = json.dumps(record, sort_keys=True, default=str)
60
+ with self._lock:
61
+ self._fh.write(line + "\n")
62
+ self._fh.flush()
63
+ os.fsync(self._fh.fileno())
64
+
65
+ def close(self) -> None:
66
+ with self._lock:
67
+ self._fh.close()
haris/agent/budget.py ADDED
@@ -0,0 +1,52 @@
1
+ import time
2
+ from collections.abc import Callable
3
+
4
+ from ..llm.budget import LLMBudget
5
+ from ..llm.settings import BudgetSettings
6
+ from .settings import AgentSettings
7
+
8
+
9
+ class JobBudget:
10
+ """Per-job tool-call, token and wall-clock caps. Cost sits on ``llm``."""
11
+
12
+ def __init__(
13
+ self,
14
+ settings: AgentSettings,
15
+ clock: Callable[[], float] = time.monotonic,
16
+ *,
17
+ llm: LLMBudget | None = None,
18
+ ) -> None:
19
+ self.settings = settings
20
+ self.clock = clock
21
+ self.llm = llm
22
+ self.started = clock()
23
+ self.tool_calls = 0
24
+ self.tokens = 0
25
+
26
+ def check_clock(self) -> str:
27
+ if self.clock() - self.started > self.settings.wall_clock_s:
28
+ return "wall_clock"
29
+ return ""
30
+
31
+ def charge_tokens(self, usage: dict[str, int]) -> str:
32
+ self.tokens += int(usage.get("total_tokens", 0))
33
+ if self.tokens > self.settings.max_tokens:
34
+ return "tokens"
35
+ return ""
36
+
37
+ def request_tool(self) -> str:
38
+ """Count one requested tool call. The call past the cap is not run."""
39
+ self.tool_calls += 1
40
+ if self.tool_calls > self.settings.max_tool_calls:
41
+ return "tool_calls"
42
+ return ""
43
+
44
+
45
+ def llm_budget(settings: AgentSettings, *, remaining_cost: float) -> LLMBudget:
46
+ """Token cap for one job; cost cap is what the scan has left."""
47
+ return LLMBudget(
48
+ BudgetSettings(
49
+ max_tokens=settings.max_tokens,
50
+ max_cost_usd=max(remaining_cost, 0.01),
51
+ )
52
+ )
@@ -0,0 +1,253 @@
1
+ import logging
2
+ import secrets
3
+ from collections.abc import Callable
4
+ from pathlib import Path
5
+ from urllib.parse import urljoin, urlsplit
6
+
7
+ from ..llm.backend import BaseLLMBackend
8
+ from ..llm.budget import BudgetExceededError
9
+ from ..llm.parsing import parse_object
10
+ from ..models import CorrelatedFinding, ScanSession
11
+ from ..net import ScopedClient
12
+ from .audit import AuditLog, digest
13
+ from .budget import JobBudget
14
+ from .guardrails import screen
15
+ from .models import JobResult, ToolCall
16
+ from .policy import AgentPolicy, select_candidates
17
+ from .prompts import SYSTEM
18
+ from .proxy import ScopeProxy
19
+ from .runtime import RuntimeSpec, SandboxRuntime
20
+ from .settings import AgentSettings
21
+ from .turn import (
22
+ attach_budget,
23
+ cancelled,
24
+ fence_text,
25
+ poc_from,
26
+ prompt_for,
27
+ proxy_target,
28
+ remember,
29
+ review_poc,
30
+ )
31
+ from .validator import apply_validation
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ REVIEW_TOKENS = 200
36
+ STEP_TOKENS = 800
37
+
38
+
39
+ def run_agent_stage(
40
+ session: ScanSession,
41
+ client: ScopedClient,
42
+ backend: BaseLLMBackend | None,
43
+ policy: AgentPolicy,
44
+ *,
45
+ runtime_factory: Callable[[], SandboxRuntime] | None = None,
46
+ cancel: object | None = None,
47
+ clock: Callable[[], float] | None = None,
48
+ ) -> None:
49
+ """One sandboxed job per candidate. Tiers move only in ``apply_validation``."""
50
+ if backend is None:
51
+ session.errors.append("[agent] skipped: no LLM backend")
52
+ return
53
+ settings = policy.settings
54
+ factory = runtime_factory or (lambda: _docker(settings))
55
+ spent = 0.0
56
+ for case in select_candidates(session.correlated, settings):
57
+ if cancelled(cancel):
58
+ break
59
+ runtime = factory()
60
+ try:
61
+ result = run_job(
62
+ session,
63
+ case,
64
+ client,
65
+ backend,
66
+ policy,
67
+ runtime,
68
+ spent=spent,
69
+ cancel=cancel,
70
+ clock=clock,
71
+ )
72
+ except Exception as exc:
73
+ logger.warning("Agent job failed: %s", exc)
74
+ session.errors.append(f"[agent] {type(exc).__name__}: {exc}")
75
+ continue
76
+ finally:
77
+ runtime.stop()
78
+ remember(session, case, result.annotation)
79
+ if result.status != "completed":
80
+ continue
81
+ note = apply_validation(
82
+ case,
83
+ result.poc,
84
+ client,
85
+ model=backend.model,
86
+ allow_unsafe=policy.allow_unsafe_methods,
87
+ )
88
+ if note:
89
+ claim = result.annotation
90
+ remember(session, case, f"{claim}; {note}" if claim else note)
91
+ if isinstance(runtime, SandboxRuntime) and getattr(backend, "budget", None):
92
+ spent = float(getattr(backend.budget, "used_cost", spent))
93
+
94
+
95
+ def run_job(
96
+ session: ScanSession,
97
+ case: CorrelatedFinding,
98
+ client: ScopedClient,
99
+ backend: BaseLLMBackend,
100
+ policy: AgentPolicy,
101
+ runtime: SandboxRuntime,
102
+ *,
103
+ spent: float,
104
+ cancel: object | None,
105
+ clock: Callable[[], float] | None,
106
+ ) -> JobResult:
107
+ settings = policy.settings
108
+ job_id = secrets.token_hex(8)
109
+ audit = AuditLog(Path(policy.output_dir) / "agent" / job_id / "audit.jsonl")
110
+ proxy = ScopeProxy(
111
+ proxy_target(session.target, settings, policy.allow_unsafe_methods),
112
+ client.policy,
113
+ allow_unsafe=policy.allow_unsafe_methods,
114
+ finding_url=urljoin(f"{session.target.base_url}/", case.canonical.url),
115
+ finding_method=case.canonical.method,
116
+ audit=audit,
117
+ )
118
+ budget = JobBudget(
119
+ settings,
120
+ clock or budget_clock(),
121
+ llm=attach_budget(backend, settings, spent),
122
+ )
123
+ result = JobResult(job_id=job_id)
124
+ hits = 0
125
+ transcript: list[str] = []
126
+ try:
127
+ proxy_url = proxy.start(runtime.bind_host)
128
+ port = urlsplit(proxy_url).port or 0
129
+ netloc = "proxy:8080" if runtime.tunneled else f"127.0.0.1:{port}"
130
+ sandbox_proxy = proxy.client_url(netloc)
131
+ runtime.start(
132
+ RuntimeSpec(
133
+ name=f"haris-agent-job-{job_id}",
134
+ proxy_url=sandbox_proxy,
135
+ image=settings.image,
136
+ network=f"haris-agent-job-{job_id}",
137
+ upstream=f"host.docker.internal:{port}",
138
+ )
139
+ )
140
+ _loop(
141
+ case,
142
+ backend,
143
+ budget,
144
+ runtime,
145
+ audit,
146
+ result,
147
+ transcript,
148
+ allowed_hosts=set(session.target.scope.allowed_domains),
149
+ cancel=cancel,
150
+ hits=hits,
151
+ )
152
+ finally:
153
+ proxy.close()
154
+ audit.close()
155
+ return result
156
+
157
+
158
+ def budget_clock() -> Callable[[], float]:
159
+ import time
160
+
161
+ return time.monotonic
162
+
163
+
164
+ def _loop(
165
+ case: CorrelatedFinding,
166
+ backend: BaseLLMBackend,
167
+ budget: JobBudget,
168
+ runtime: SandboxRuntime,
169
+ audit: AuditLog,
170
+ result: JobResult,
171
+ transcript: list[str],
172
+ *,
173
+ allowed_hosts: set[str],
174
+ cancel: object | None,
175
+ hits: int,
176
+ ) -> None:
177
+ settings = budget.settings
178
+ while True:
179
+ if cancelled(cancel):
180
+ _abort(audit, runtime, result, "cancel")
181
+ return
182
+ if reason := budget.check_clock():
183
+ _abort(audit, runtime, result, reason)
184
+ return
185
+ prompt = prompt_for(case, transcript)
186
+ audit.write("llm", role="request", digest=digest(prompt))
187
+ try:
188
+ response = backend.complete(
189
+ prompt, system=SYSTEM, temperature=0, max_tokens=STEP_TOKENS
190
+ )
191
+ except BudgetExceededError:
192
+ _abort(audit, runtime, result, "tokens")
193
+ return
194
+ audit.write("llm", role="response", digest=digest(response.text))
195
+ if reason := budget.charge_tokens(dict(response.usage)):
196
+ _abort(audit, runtime, result, reason)
197
+ return
198
+ parsed = parse_object(response.text, ToolCall)
199
+ if not parsed.items:
200
+ hits += 1
201
+ audit.write("guardrail", reason="invalid", rule="schema")
202
+ if hits >= settings.guardrail_abort_at:
203
+ _abort(audit, runtime, result, "guardrail")
204
+ return
205
+ continue
206
+ step = parsed.items[0]
207
+ if step.tool == "submit_poc":
208
+ result.poc = poc_from(step)
209
+ result.annotation = step.claim
210
+ audit.write("tool_call", tool=step.tool, arguments=step.arguments)
211
+ review_poc(backend, result.poc, audit)
212
+ return
213
+ if step.done or not step.tool:
214
+ result.annotation = step.claim
215
+ return
216
+ if reason := budget.request_tool():
217
+ _abort(audit, runtime, result, reason)
218
+ return
219
+ audit.write("tool_call", tool=step.tool, arguments=step.arguments)
220
+ hit = screen(step.tool, step.arguments, allowed_hosts=allowed_hosts)
221
+ if hit is not None:
222
+ hits += 1
223
+ audit.write("guardrail", reason=hit.reason, rule=hit.rule, tool=step.tool)
224
+ if hit.reason == "scope" or hits >= settings.guardrail_abort_at:
225
+ _abort(
226
+ audit,
227
+ runtime,
228
+ result,
229
+ "scope" if hit.reason == "scope" else "guardrail",
230
+ )
231
+ return
232
+ transcript.append(fence_text(f"blocked: {hit.reason} {hit.rule}"))
233
+ continue
234
+ output = runtime.call(step.tool, step.arguments)
235
+ audit.write("tool_result", tool=step.tool, digest=digest(output))
236
+ if output.startswith("rejected: scope") or output.startswith("rejected: ssrf"):
237
+ _abort(audit, runtime, result, "scope")
238
+ return
239
+ transcript.append(fence_text(output))
240
+
241
+
242
+ def _abort(
243
+ audit: AuditLog, runtime: SandboxRuntime, result: JobResult, reason: str
244
+ ) -> None:
245
+ result.status = "aborted"
246
+ audit.write("kill", reason=reason)
247
+ runtime.kill()
248
+
249
+
250
+ def _docker(settings: AgentSettings) -> SandboxRuntime:
251
+ from .docker_runtime import DockerRuntime
252
+
253
+ return DockerRuntime(settings)