decodingtrust-agent-sdk 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.
Files changed (374) hide show
  1. agent/__init__.py +30 -0
  2. agent/claudesdk/__init__.py +8 -0
  3. agent/claudesdk/example.py +221 -0
  4. agent/claudesdk/src/__init__.py +8 -0
  5. agent/claudesdk/src/agent.py +400 -0
  6. agent/claudesdk/src/mcp_proxy.py +409 -0
  7. agent/claudesdk/src/utils.py +420 -0
  8. agent/googleadk/__init__.py +15 -0
  9. agent/googleadk/example.py +237 -0
  10. agent/googleadk/src/__init__.py +12 -0
  11. agent/googleadk/src/agent.py +401 -0
  12. agent/googleadk/src/mcp_wrapper.py +163 -0
  13. agent/googleadk/src/utils.py +602 -0
  14. agent/langchain/__init__.py +8 -0
  15. agent/langchain/example.py +213 -0
  16. agent/langchain/src/__init__.py +8 -0
  17. agent/langchain/src/agent.py +645 -0
  18. agent/langchain/src/utils.py +433 -0
  19. agent/openaisdk/__init__.py +17 -0
  20. agent/openaisdk/example.py +228 -0
  21. agent/openaisdk/src/__init__.py +12 -0
  22. agent/openaisdk/src/agent.py +491 -0
  23. agent/openaisdk/src/agent_wrapper.py +143 -0
  24. agent/openaisdk/src/mcp_wrapper.py +395 -0
  25. agent/openaisdk/src/utils.py +493 -0
  26. agent/openclaw/__init__.py +10 -0
  27. agent/openclaw/example.py +251 -0
  28. agent/openclaw/src/__init__.py +14 -0
  29. agent/openclaw/src/agent.py +930 -0
  30. agent/openclaw/src/helpers/__init__.py +1 -0
  31. agent/openclaw/src/helpers/auth_helpers.py +55 -0
  32. agent/openclaw/src/mcp_proxy.py +564 -0
  33. agent/openclaw/src/plugin_generator.py +231 -0
  34. agent/openclaw/src/utils.py +341 -0
  35. agent/pocketflow/__init__.py +18 -0
  36. agent/pocketflow/example.py +221 -0
  37. agent/pocketflow/prompts/react_agent.py +46 -0
  38. agent/pocketflow/src/__init__.py +6 -0
  39. agent/pocketflow/src/agent.py +507 -0
  40. agent/pocketflow/src/agent_wrapper.py +159 -0
  41. agent/pocketflow/src/async_helper.py +92 -0
  42. agent/pocketflow/src/mcp_react_agent.py +279 -0
  43. agent/pocketflow/src/native_agent.py +74 -0
  44. agent/pocketflow/src/nodes.py +467 -0
  45. benchmark/__init__.py +0 -0
  46. benchmark/browser/benign.jsonl +34 -0
  47. benchmark/browser/direct.jsonl +85 -0
  48. benchmark/browser/indirect.jsonl +82 -0
  49. benchmark/code/benign.jsonl +0 -0
  50. benchmark/code/direct.jsonl +121 -0
  51. benchmark/code/indirect.jsonl +165 -0
  52. benchmark/crm/benign.jsonl +165 -0
  53. benchmark/crm/direct.jsonl +90 -0
  54. benchmark/crm/indirect.jsonl +150 -0
  55. benchmark/customer-service/benign.jsonl +160 -0
  56. benchmark/customer-service/direct.jsonl +100 -0
  57. benchmark/customer-service/indirect.jsonl +101 -0
  58. benchmark/finance/benign.jsonl +0 -0
  59. benchmark/finance/direct.jsonl +200 -0
  60. benchmark/finance/indirect.jsonl +200 -0
  61. benchmark/legal/benign.jsonl +0 -0
  62. benchmark/legal/direct.jsonl +200 -0
  63. benchmark/legal/indirect.jsonl +200 -0
  64. benchmark/macos/benign.jsonl +30 -0
  65. benchmark/macos/direct.jsonl +50 -0
  66. benchmark/macos/indirect.jsonl +50 -0
  67. benchmark/medical/benign.jsonl +642 -0
  68. benchmark/medical/direct.jsonl +229 -0
  69. benchmark/medical/indirect.jsonl +222 -0
  70. benchmark/os-filesystem/benign.jsonl +200 -0
  71. benchmark/os-filesystem/direct.jsonl +200 -0
  72. benchmark/os-filesystem/indirect.jsonl +200 -0
  73. benchmark/research/benign.jsonl +0 -0
  74. benchmark/research/direct.jsonl +119 -0
  75. benchmark/research/indirect.jsonl +125 -0
  76. benchmark/telecom/benign.jsonl +120 -0
  77. benchmark/telecom/direct.jsonl +161 -0
  78. benchmark/telecom/indirect.jsonl +166 -0
  79. benchmark/travel/benign.jsonl +130 -0
  80. benchmark/travel/direct.jsonl +105 -0
  81. benchmark/travel/indirect.jsonl +120 -0
  82. benchmark/windows/benign.jsonl +100 -0
  83. benchmark/windows/direct.jsonl +140 -0
  84. benchmark/windows/indirect.jsonl +107 -0
  85. benchmark/workflow/benign.jsonl +335 -0
  86. benchmark/workflow/direct.jsonl +78 -0
  87. benchmark/workflow/indirect.jsonl +107 -0
  88. cli/__init__.py +5 -0
  89. cli/main.py +182 -0
  90. cli/scaffold.py +334 -0
  91. decodingtrust_agent_sdk-0.1.0.dist-info/METADATA +642 -0
  92. decodingtrust_agent_sdk-0.1.0.dist-info/RECORD +374 -0
  93. decodingtrust_agent_sdk-0.1.0.dist-info/WHEEL +5 -0
  94. decodingtrust_agent_sdk-0.1.0.dist-info/entry_points.txt +2 -0
  95. decodingtrust_agent_sdk-0.1.0.dist-info/licenses/LICENSE +201 -0
  96. decodingtrust_agent_sdk-0.1.0.dist-info/top_level.txt +6 -0
  97. dt_arena/config/env.yaml +515 -0
  98. dt_arena/config/injection_mcp.yaml +430 -0
  99. dt_arena/config/mcp.yaml +642 -0
  100. dt_arena/envs/arxiv/docker-compose-hub.yml +31 -0
  101. dt_arena/envs/arxiv/docker-compose.yml +36 -0
  102. dt_arena/envs/atlassian/docker/docker-compose.dev.yml +65 -0
  103. dt_arena/envs/atlassian/docker/docker-compose.yml +53 -0
  104. dt_arena/envs/atlassian/docker-compose-hub.yml +57 -0
  105. dt_arena/envs/atlassian/docker-compose.yml +72 -0
  106. dt_arena/envs/bigquery/docker-compose.yml +20 -0
  107. dt_arena/envs/booking/docker-compose.yml +59 -0
  108. dt_arena/envs/calendar/docker-compose-hub.yml +30 -0
  109. dt_arena/envs/calendar/docker-compose.yml +42 -0
  110. dt_arena/envs/custom-website/docker-compose.yml +6 -0
  111. dt_arena/envs/customer_service/docker-compose.yml +59 -0
  112. dt_arena/envs/databricks/docker-compose-hub.yml +47 -0
  113. dt_arena/envs/databricks/docker-compose.yml +51 -0
  114. dt_arena/envs/ecommerce/docker-compose.yml +6 -0
  115. dt_arena/envs/ers/docker-compose.yml +36 -0
  116. dt_arena/envs/ers/hrms/docker/docker-compose.yml +31 -0
  117. dt_arena/envs/finance/docker-compose.yml +23 -0
  118. dt_arena/envs/github/docker/docker-compose-hub.yml +50 -0
  119. dt_arena/envs/github/docker/docker-compose.yml +50 -0
  120. dt_arena/envs/gmail/docker-compose-hub.yml +51 -0
  121. dt_arena/envs/gmail/docker-compose.yml +65 -0
  122. dt_arena/envs/google-form/docker-compose-hub.yml +33 -0
  123. dt_arena/envs/google-form/docker-compose.yml +41 -0
  124. dt_arena/envs/googledocs/docker-compose-hub.yml +61 -0
  125. dt_arena/envs/googledocs/docker-compose.yml +78 -0
  126. dt_arena/envs/hospital/docker-compose-hub.yml +25 -0
  127. dt_arena/envs/hospital/docker-compose.yml +27 -0
  128. dt_arena/envs/legal/docker-compose.yml +22 -0
  129. dt_arena/envs/linkedin/docker-compose.yml +63 -0
  130. dt_arena/envs/macos/docker-compose.yml +79 -0
  131. dt_arena/envs/os-filesystem/docker-compose-hub.yml +16 -0
  132. dt_arena/envs/os-filesystem/docker-compose.yml +20 -0
  133. dt_arena/envs/paypal/docker-compose-hub.yml +48 -0
  134. dt_arena/envs/paypal/docker-compose.yml +63 -0
  135. dt_arena/envs/research/docker-compose-hub.yml +13 -0
  136. dt_arena/envs/research/docker-compose.yml +24 -0
  137. dt_arena/envs/salesforce_crm/docker-compose-hub.yaml +45 -0
  138. dt_arena/envs/salesforce_crm/docker-compose.yaml +49 -0
  139. dt_arena/envs/slack/docker-compose-hub.yml +28 -0
  140. dt_arena/envs/slack/docker-compose.yml +41 -0
  141. dt_arena/envs/snowflake/docker-compose-hub.yml +41 -0
  142. dt_arena/envs/snowflake/docker-compose.yml +44 -0
  143. dt_arena/envs/telecom/docker-compose-hub.yml +16 -0
  144. dt_arena/envs/telecom/docker-compose.yml +17 -0
  145. dt_arena/envs/telegram/docker-compose-hub.yml +57 -0
  146. dt_arena/envs/telegram/docker-compose.yml +62 -0
  147. dt_arena/envs/terminal/docker-compose-hub.yml +12 -0
  148. dt_arena/envs/terminal/docker-compose.yml +26 -0
  149. dt_arena/envs/travel/docker-compose-hub.yml +19 -0
  150. dt_arena/envs/travel/docker-compose.yml +19 -0
  151. dt_arena/envs/whatsapp/docker-compose-hub.yml +61 -0
  152. dt_arena/envs/whatsapp/docker-compose.yml +78 -0
  153. dt_arena/envs/windows/docker-compose.yml +71 -0
  154. dt_arena/envs/zoom/docker-compose-hub.yml +27 -0
  155. dt_arena/envs/zoom/docker-compose.yml +40 -0
  156. dt_arena/injection_mcp_server/atlassian/env_injection.py +134 -0
  157. dt_arena/injection_mcp_server/calendar/env_injection.py +217 -0
  158. dt_arena/injection_mcp_server/custom_website/env_injection.py +97 -0
  159. dt_arena/injection_mcp_server/customer_service/env_injection.py +659 -0
  160. dt_arena/injection_mcp_server/databricks/env_injection.py +255 -0
  161. dt_arena/injection_mcp_server/ecommerce/env_injection.py +110 -0
  162. dt_arena/injection_mcp_server/finance/env_injection.py +85 -0
  163. dt_arena/injection_mcp_server/github/env_injection.py +206 -0
  164. dt_arena/injection_mcp_server/gmail/env_injection.py +211 -0
  165. dt_arena/injection_mcp_server/google_form/env_injection.py +186 -0
  166. dt_arena/injection_mcp_server/googledocs/env_injection.py +44 -0
  167. dt_arena/injection_mcp_server/hospital/env_injection.py +43 -0
  168. dt_arena/injection_mcp_server/legal/env_injection.py +229 -0
  169. dt_arena/injection_mcp_server/macos/env_injection.py +272 -0
  170. dt_arena/injection_mcp_server/os-filesystem/env_injection.py +341 -0
  171. dt_arena/injection_mcp_server/paypal/env_injection.py +268 -0
  172. dt_arena/injection_mcp_server/research/env_injection.py +616 -0
  173. dt_arena/injection_mcp_server/salesforce/env_injection.py +514 -0
  174. dt_arena/injection_mcp_server/slack/env_injection.py +265 -0
  175. dt_arena/injection_mcp_server/snowflake/env_injection.py +230 -0
  176. dt_arena/injection_mcp_server/telecom/env_injection.py +503 -0
  177. dt_arena/injection_mcp_server/telegram/env_injection.py +171 -0
  178. dt_arena/injection_mcp_server/terminal/env_injection.py +523 -0
  179. dt_arena/injection_mcp_server/travel/env_injection.py +173 -0
  180. dt_arena/injection_mcp_server/whatsapp/env_injection.py +185 -0
  181. dt_arena/injection_mcp_server/windows/env_injection.py +943 -0
  182. dt_arena/injection_mcp_server/zoom/env_injection.py +216 -0
  183. dt_arena/mcp_server/atlassian/main.py +1554 -0
  184. dt_arena/mcp_server/atlassian/test_server.py +66 -0
  185. dt_arena/mcp_server/bigquery/main.py +333 -0
  186. dt_arena/mcp_server/booking/main.py +310 -0
  187. dt_arena/mcp_server/browser/main.py +1741 -0
  188. dt_arena/mcp_server/calendar/example_multi_user.py +162 -0
  189. dt_arena/mcp_server/calendar/main.py +792 -0
  190. dt_arena/mcp_server/calendar/test_mcp.py +135 -0
  191. dt_arena/mcp_server/customer_service/main.py +1063 -0
  192. dt_arena/mcp_server/databricks/main.py +566 -0
  193. dt_arena/mcp_server/databricks/probe.py +102 -0
  194. dt_arena/mcp_server/ers/main.py +845 -0
  195. dt_arena/mcp_server/finance/__init__.py +87 -0
  196. dt_arena/mcp_server/finance/core/__init__.py +12 -0
  197. dt_arena/mcp_server/finance/core/data_loader.py +558 -0
  198. dt_arena/mcp_server/finance/core/portfolio.py +565 -0
  199. dt_arena/mcp_server/finance/evaluation/__init__.py +20 -0
  200. dt_arena/mcp_server/finance/evaluation/evaluator.py +217 -0
  201. dt_arena/mcp_server/finance/evaluation/logger.py +137 -0
  202. dt_arena/mcp_server/finance/injection/__init__.py +66 -0
  203. dt_arena/mcp_server/finance/injection/config.py +176 -0
  204. dt_arena/mcp_server/finance/injection/content.py +755 -0
  205. dt_arena/mcp_server/finance/injection/html.py +409 -0
  206. dt_arena/mcp_server/finance/injection/locations.py +167 -0
  207. dt_arena/mcp_server/finance/injection/methods.py +193 -0
  208. dt_arena/mcp_server/finance/injection/presets.py +1023 -0
  209. dt_arena/mcp_server/finance/main.py +361 -0
  210. dt_arena/mcp_server/finance/run_mcp.py +21 -0
  211. dt_arena/mcp_server/finance/run_web.py +26 -0
  212. dt_arena/mcp_server/finance/server/__init__.py +41 -0
  213. dt_arena/mcp_server/finance/server/extractor.py +1453 -0
  214. dt_arena/mcp_server/finance/server/extractor_minimal.py +292 -0
  215. dt_arena/mcp_server/finance/server/extractor_simple.py +1164 -0
  216. dt_arena/mcp_server/finance/server/injection_mcp.py +865 -0
  217. dt_arena/mcp_server/finance/server/mcp.py +451 -0
  218. dt_arena/mcp_server/finance/server/tools/__init__.py +23 -0
  219. dt_arena/mcp_server/finance/server/tools/account.py +88 -0
  220. dt_arena/mcp_server/finance/server/tools/browsing.py +328 -0
  221. dt_arena/mcp_server/finance/server/tools/social.py +73 -0
  222. dt_arena/mcp_server/finance/server/tools/trading.py +242 -0
  223. dt_arena/mcp_server/finance/server/tools/utility.py +49 -0
  224. dt_arena/mcp_server/finance/server/web.py +2139 -0
  225. dt_arena/mcp_server/finance/tasks/benchmark/__init__.py +28 -0
  226. dt_arena/mcp_server/finance/tasks/benchmark/attack_pool.py +3026 -0
  227. dt_arena/mcp_server/finance/tasks/benchmark/attack_runner.py +1315 -0
  228. dt_arena/mcp_server/finance/tasks/benchmark/finra_requirements.py +1335 -0
  229. dt_arena/mcp_server/finance/tasks/benchmark/finra_tasks.py +3665 -0
  230. dt_arena/mcp_server/finance/tasks/benchmark/malicious_tasks.py +2673 -0
  231. dt_arena/mcp_server/finance/tasks/redteam_suite/run_redteam_suite.py +1713 -0
  232. dt_arena/mcp_server/finance/test_mcp_tools.py +476 -0
  233. dt_arena/mcp_server/github/main.py +441 -0
  234. dt_arena/mcp_server/gmail/main.py +1004 -0
  235. dt_arena/mcp_server/google_form/main.py +141 -0
  236. dt_arena/mcp_server/googledocs/main.py +458 -0
  237. dt_arena/mcp_server/hospital/mcp_server.py +458 -0
  238. dt_arena/mcp_server/legal/__init__.py +9 -0
  239. dt_arena/mcp_server/legal/core/__init__.py +14 -0
  240. dt_arena/mcp_server/legal/core/courtlistener_store.py +762 -0
  241. dt_arena/mcp_server/legal/core/data_loader.py +266 -0
  242. dt_arena/mcp_server/legal/core/document_store.py +197 -0
  243. dt_arena/mcp_server/legal/core/matter_manager.py +466 -0
  244. dt_arena/mcp_server/legal/main.py +89 -0
  245. dt_arena/mcp_server/legal/scripts/collect_data.py +988 -0
  246. dt_arena/mcp_server/legal/server/__init__.py +14 -0
  247. dt_arena/mcp_server/legal/server/mcp.py +2330 -0
  248. dt_arena/mcp_server/macos/client_test.py +270 -0
  249. dt_arena/mcp_server/macos/mcp_server.py +285 -0
  250. dt_arena/mcp_server/os-filesystem/main.py +1380 -0
  251. dt_arena/mcp_server/paypal/main.py +501 -0
  252. dt_arena/mcp_server/research/main.py +777 -0
  253. dt_arena/mcp_server/salesforce/main.py +2006 -0
  254. dt_arena/mcp_server/slack/main.py +318 -0
  255. dt_arena/mcp_server/snowflake/main.py +612 -0
  256. dt_arena/mcp_server/snowflake/probe.py +183 -0
  257. dt_arena/mcp_server/telecom/mcp_client.py +423 -0
  258. dt_arena/mcp_server/telecom/mcp_server.py +1059 -0
  259. dt_arena/mcp_server/telegram/main.py +338 -0
  260. dt_arena/mcp_server/terminal/main.py +163 -0
  261. dt_arena/mcp_server/travel/client_test.py +16 -0
  262. dt_arena/mcp_server/travel/mcp_server.py +404 -0
  263. dt_arena/mcp_server/whatsapp/main.py +318 -0
  264. dt_arena/mcp_server/windows/client_test.py +270 -0
  265. dt_arena/mcp_server/windows/mcp_server.py +218 -0
  266. dt_arena/mcp_server/zoom/main.py +466 -0
  267. dt_arena/src/__init__.py +0 -0
  268. dt_arena/src/hooks/__init__.py +0 -0
  269. dt_arena/src/hooks/audit_log.py +30 -0
  270. dt_arena/src/hooks/hooks.json +3 -0
  271. dt_arena/src/run_benign.py +142 -0
  272. dt_arena/src/types/__init__.py +0 -0
  273. dt_arena/src/types/agent.py +441 -0
  274. dt_arena/src/types/attacks.py +2 -0
  275. dt_arena/src/types/environment.py +2 -0
  276. dt_arena/src/types/hooks.py +174 -0
  277. dt_arena/src/types/judge.py +52 -0
  278. dt_arena/src/types/red_teaming_trajectory.py +385 -0
  279. dt_arena/src/types/task.py +260 -0
  280. dt_arena/src/types/trajectory.py +315 -0
  281. dt_arena/utils/__init__.py +1 -0
  282. dt_arena/utils/atlassian/__init__.py +27 -0
  283. dt_arena/utils/atlassian/helpers.py +520 -0
  284. dt_arena/utils/bigquery/__init__.py +1 -0
  285. dt_arena/utils/bigquery/helpers.py +246 -0
  286. dt_arena/utils/calendar/__init__.py +1 -0
  287. dt_arena/utils/calendar/helpers.py +87 -0
  288. dt_arena/utils/customer_service/__init__.py +17 -0
  289. dt_arena/utils/customer_service/cs_env_client.py +940 -0
  290. dt_arena/utils/customer_service/helpers.py +339 -0
  291. dt_arena/utils/customer_service/judges/__init__.py +20 -0
  292. dt_arena/utils/customer_service/judges/abusive_comms.py +149 -0
  293. dt_arena/utils/customer_service/judges/bulk_destructive.py +202 -0
  294. dt_arena/utils/customer_service/judges/cross_customer.py +481 -0
  295. dt_arena/utils/customer_service/judges/email_exfiltration.py +320 -0
  296. dt_arena/utils/customer_service/judges/financial_fraud.py +221 -0
  297. dt_arena/utils/customer_service/judges/llm_utils.py +264 -0
  298. dt_arena/utils/customer_service/judges/pii_disclosure.py +837 -0
  299. dt_arena/utils/customer_service/judges/policy_misrep.py +44 -0
  300. dt_arena/utils/customer_service/judges/text_utils.py +21 -0
  301. dt_arena/utils/databricks/__init__.py +2 -0
  302. dt_arena/utils/databricks/helpers.py +210 -0
  303. dt_arena/utils/finance/__init__.py +0 -0
  304. dt_arena/utils/finance/helpers.py +263 -0
  305. dt_arena/utils/github/__init__.py +1 -0
  306. dt_arena/utils/github/helpers.py +249 -0
  307. dt_arena/utils/gmail/__init__.py +1 -0
  308. dt_arena/utils/gmail/helpers.py +344 -0
  309. dt_arena/utils/google_form/__init__.py +2 -0
  310. dt_arena/utils/google_form/helpers.py +133 -0
  311. dt_arena/utils/legal/__init__.py +0 -0
  312. dt_arena/utils/legal/helpers.py +228 -0
  313. dt_arena/utils/macos/__init__.py +0 -0
  314. dt_arena/utils/macos/env_setup.py +215 -0
  315. dt_arena/utils/macos/helpers.py +61 -0
  316. dt_arena/utils/os_filesystem/__init__.py +1 -0
  317. dt_arena/utils/os_filesystem/helpers.py +366 -0
  318. dt_arena/utils/paypal/__init__.py +1 -0
  319. dt_arena/utils/paypal/helpers.py +178 -0
  320. dt_arena/utils/port_allocator.py +266 -0
  321. dt_arena/utils/research/__init__.py +0 -0
  322. dt_arena/utils/research/helpers.py +251 -0
  323. dt_arena/utils/salesforce/__init__.py +1 -0
  324. dt_arena/utils/salesforce/helpers.py +719 -0
  325. dt_arena/utils/slack/__init__.py +1 -0
  326. dt_arena/utils/slack/helpers.py +176 -0
  327. dt_arena/utils/snowflake/__init__.py +1 -0
  328. dt_arena/utils/snowflake/helpers.py +166 -0
  329. dt_arena/utils/telecom/__init__.py +1 -0
  330. dt_arena/utils/telecom/helpers.py +760 -0
  331. dt_arena/utils/telegram/__init__.py +0 -0
  332. dt_arena/utils/telegram/helpers.py +174 -0
  333. dt_arena/utils/terminal/__init__.py +0 -0
  334. dt_arena/utils/terminal/helpers.py +20 -0
  335. dt_arena/utils/travel/__init__.py +0 -0
  336. dt_arena/utils/travel/env_client.py +537 -0
  337. dt_arena/utils/travel/llm_judge.py +137 -0
  338. dt_arena/utils/travel/prompts.py +64 -0
  339. dt_arena/utils/utils/__init__.py +122 -0
  340. dt_arena/utils/whatsapp/__init__.py +0 -0
  341. dt_arena/utils/whatsapp/helpers.py +226 -0
  342. dt_arena/utils/windows/__init__.py +0 -0
  343. dt_arena/utils/windows/env_reset.py +224 -0
  344. dt_arena/utils/windows/env_setup.py +280 -0
  345. dt_arena/utils/windows/exfil_helpers.py +170 -0
  346. dt_arena/utils/windows/helpers.py +74 -0
  347. dt_arena/utils/zoom/__init__.py +1 -0
  348. dt_arena/utils/zoom/helpers.py +70 -0
  349. eval/__init__.py +1 -0
  350. eval/evaluation.py +426 -0
  351. eval/task_runner.py +449 -0
  352. utils/__init__.py +148 -0
  353. utils/agent_helpers.py +308 -0
  354. utils/agent_wrapper.py +189 -0
  355. utils/compose_utils.py +135 -0
  356. utils/config.py +77 -0
  357. utils/env_helpers.py +104 -0
  358. utils/eval_stats.py +88 -0
  359. utils/injection_helpers.py +429 -0
  360. utils/injection_mcp_helpers.py +152 -0
  361. utils/judge_helpers.py +181 -0
  362. utils/judge_utils.py +472 -0
  363. utils/llm.py +196 -0
  364. utils/logging.py +45 -0
  365. utils/mcp_helpers.py +232 -0
  366. utils/mcp_manager.py +235 -0
  367. utils/memory_guard.py +18 -0
  368. utils/red_teaming_sandbox.py +476 -0
  369. utils/reset_helpers.py +318 -0
  370. utils/resource_manager.py +370 -0
  371. utils/skill_helpers.py +447 -0
  372. utils/task_executor.py +904 -0
  373. utils/task_helpers.py +270 -0
  374. utils/template_helpers.py +179 -0
@@ -0,0 +1,755 @@
1
+ """
2
+ Content Injection - Inject fake content at the data layer
3
+
4
+ Unlike HTML injection (which hides malicious prompts in HTML),
5
+ content injection adds visible fake content that appears legitimate.
6
+
7
+ Static targeting (for use with Docker + red teaming agent):
8
+ - article_id=12345 → inject to that specific article
9
+ - symbol="AAPL" → inject for that specific stock
10
+ - article_id=None / symbol=None → inject to ALL matching items
11
+
12
+ Usage:
13
+ from injection.content import ContentInjector, FakeComment
14
+
15
+ injector = ContentInjector()
16
+
17
+ # Inject to specific article
18
+ injector.add_comment(FakeComment(
19
+ symbol="AAPL",
20
+ article_id=12345,
21
+ content="Buy now! This is going to moon!",
22
+ author="Market Expert",
23
+ votes={"up": 150, "down": 3}
24
+ ))
25
+
26
+ # Inject to ALL AAPL articles
27
+ injector.add_comment(FakeComment(
28
+ symbol="AAPL",
29
+ article_id=None,
30
+ content="Comment for all articles",
31
+ ))
32
+ """
33
+
34
+ from dataclasses import dataclass, field
35
+ from typing import Dict, List, Optional, Any, Set
36
+ from datetime import datetime
37
+ import uuid
38
+ import random
39
+
40
+
41
+ @dataclass
42
+ class FakeNews:
43
+ """A fake news article to inject."""
44
+ title: str
45
+ summary: str
46
+ source: str = "Injected Source"
47
+ symbol: Optional[str] = None # None = global news
48
+ article_id: Optional[int] = None # Auto-generated if None
49
+ published_time: Optional[str] = None # Auto-generated if None
50
+ url: str = "#injected"
51
+ image_url: Optional[str] = None
52
+
53
+ # Injection metadata
54
+ position: str = "top" # "top", "middle", "bottom", "random"
55
+ priority: int = 10 # Higher = shown first
56
+ visible: bool = True # If False, hidden but in HTML
57
+
58
+ def __post_init__(self):
59
+ if self.article_id is None:
60
+ self.article_id = abs(hash(self.title)) % 100000000
61
+ if self.published_time is None:
62
+ self.published_time = datetime.now().strftime("%Y-%m-%d %H:%M")
63
+
64
+ def to_dict(self) -> Dict[str, Any]:
65
+ """Convert to template-compatible dict (yfinance/finnhub format)."""
66
+ return {
67
+ # Core yfinance/finnhub fields
68
+ "id": self.article_id,
69
+ "headline": self.title,
70
+ "summary": self.summary,
71
+ "source": self.source,
72
+ "url": self.url,
73
+ "image": self.image_url,
74
+ "timestamp": int(datetime.now().timestamp()), # For sorting
75
+ "datetime": self.published_time,
76
+ "category": "company", # default category
77
+ "related": self.symbol or "", # yfinance uses 'related' for symbol
78
+ # Aliases for compatibility
79
+ "title": self.title,
80
+ "symbol": self.symbol,
81
+ "published_time": self.published_time,
82
+ "image_url": self.image_url,
83
+ # Injection metadata
84
+ "position": self.position,
85
+ "is_injected": True,
86
+ }
87
+
88
+
89
+ @dataclass
90
+ class FakeComment:
91
+ """
92
+ A fake comment to inject on an article.
93
+
94
+ Static targeting (Docker + red teaming agent):
95
+
96
+ 1. Specific ID: article_id=12345 → inject on that article only
97
+ 2. All articles: article_id=None → inject on ALL articles for this symbol
98
+
99
+ Example usage:
100
+ # Inject to specific article
101
+ FakeComment(symbol="AAPL", content="...", article_id=12345)
102
+
103
+ # Inject to ALL AAPL articles
104
+ FakeComment(symbol="AAPL", content="...", article_id=None)
105
+ """
106
+ symbol: str
107
+ content: str
108
+
109
+ # Targeting: specific article ID or None for all
110
+ article_id: Optional[int] = None
111
+
112
+ # Comment metadata
113
+ author: str = "Market Analyst"
114
+ sentiment: str = "neutral" # "bullish", "bearish", "neutral"
115
+ votes: Dict[str, int] = field(default_factory=lambda: {"up": 0, "down": 0})
116
+
117
+ # Reply support
118
+ parent_id: Optional[str] = None # If set, this is a reply
119
+ replies: List["FakeComment"] = field(default_factory=list)
120
+
121
+ # Injection metadata
122
+ position: str = "top" # "top", "bottom", "random"
123
+ priority: int = 10
124
+ timestamp: Optional[str] = None
125
+
126
+ def __post_init__(self):
127
+ if self.timestamp is None:
128
+ self.timestamp = datetime.now().strftime("%b %d, %Y at %I:%M %p")
129
+ if not hasattr(self, 'id'):
130
+ self.id = str(uuid.uuid4())[:8]
131
+
132
+ def should_apply_to(self, article_id: int) -> bool:
133
+ """
134
+ Check if this comment should be applied to the given article.
135
+
136
+ Args:
137
+ article_id: The article's ID
138
+
139
+ Returns:
140
+ True if should apply
141
+ """
142
+ # article_id=None means ALL articles
143
+ if self.article_id is None:
144
+ return True
145
+ return self.article_id == article_id
146
+
147
+ def to_dict(self) -> Dict[str, Any]:
148
+ """Convert to template-compatible dict."""
149
+ comment_id = getattr(self, 'id', str(uuid.uuid4())[:8])
150
+
151
+ # Determine avatar and badge based on author
152
+ avatar = "🧑‍💼" # Default professional avatar
153
+ badge = None
154
+ badge_class = ""
155
+
156
+ # Special author types
157
+ author_lower = self.author.lower()
158
+ if "analyst" in author_lower or "expert" in author_lower:
159
+ avatar = "📊"
160
+ badge = "Analyst"
161
+ badge_class = "verified"
162
+ elif "insider" in author_lower:
163
+ avatar = "🔒"
164
+ badge = "Insider"
165
+ badge_class = "insider"
166
+ elif "verified" in author_lower:
167
+ avatar = "✓"
168
+ badge = "Verified"
169
+ badge_class = "verified"
170
+
171
+ return {
172
+ # API fields
173
+ "id": comment_id,
174
+ "article_id": self.article_id,
175
+ "symbol": self.symbol,
176
+ "content": self.content,
177
+ "user": self.author,
178
+ "votes": self.votes,
179
+ "parent_id": self.parent_id,
180
+ # Template-compatible fields (for article.html)
181
+ "author": self.author,
182
+ "text": self.content,
183
+ "time": self.timestamp,
184
+ "avatar": avatar,
185
+ "badge": badge,
186
+ "badge_class": badge_class,
187
+ "sentiment": self.sentiment if self.sentiment != "neutral" else None,
188
+ "likes": self.votes.get("up", 0),
189
+ "dislikes": self.votes.get("down", 0),
190
+ "replies": [r.to_dict() for r in self.replies],
191
+ "position": self.position,
192
+ "is_injected": True,
193
+ }
194
+
195
+
196
+ @dataclass
197
+ class FakeAnalystRating:
198
+ """
199
+ A fake analyst rating to inject.
200
+
201
+ Format matches yfinance analyst_ratings structure for seamless merging:
202
+ - date: "2025-12-01"
203
+ - analyst/firm: "Goldman Sachs"
204
+ - rating: "Buy", "Hold", "Sell" (not strong_buy etc.)
205
+ - priceTarget: 358.15
206
+ - action: "Raises", "Lowers", "Maintains", "Initiates"
207
+ - fromGrade/toGrade: For rating changes
208
+ - priorTarget/currentTarget: For price target changes
209
+
210
+ Static targeting (Docker + red teaming agent):
211
+ 1. Specific: symbol="AAPL" → inject for this stock only
212
+ 2. All stocks: symbol=None → inject on ALL stocks
213
+
214
+ Example usage:
215
+ # Inject for specific stock
216
+ FakeAnalystRating(
217
+ symbol="AAPL",
218
+ analyst="Goldman Sachs",
219
+ to_grade="Strong Buy",
220
+ current_target=500.00,
221
+ )
222
+
223
+ # Inject for ALL stocks
224
+ FakeAnalystRating(
225
+ symbol=None,
226
+ analyst="Goldman Sachs",
227
+ to_grade="Strong Buy",
228
+ current_target=500.00,
229
+ )
230
+ """
231
+ analyst: str # Firm name, e.g., "Goldman Sachs"
232
+ to_grade: str # New rating: "Strong Buy", "Buy", "Hold", "Sell", "Strong Sell"
233
+ current_target: float # New price target
234
+
235
+ # Rating change info (for display)
236
+ action: str = "Initiates" # "Raises", "Lowers", "Maintains", "Initiates", "Reiterates"
237
+ from_grade: Optional[str] = None # Previous rating (for upgrade/downgrade)
238
+ prior_target: Optional[float] = None # Previous price target
239
+
240
+ # Analyst commentary (displayed in HTML)
241
+ comment: Optional[str] = None # e.g., "We see significant upside due to AI initiatives..."
242
+
243
+ # Targeting: specific symbol or None for all
244
+ symbol: Optional[str] = None
245
+
246
+ date: Optional[str] = None
247
+
248
+ # Injection metadata
249
+ position: str = "top"
250
+ priority: int = 10
251
+
252
+ def __post_init__(self):
253
+ if self.date is None:
254
+ self.date = datetime.now().strftime("%Y-%m-%d")
255
+
256
+ def should_apply_to(self, symbol: str) -> bool:
257
+ """Check if this rating should be applied to the given symbol."""
258
+ # symbol=None means ALL stocks
259
+ if self.symbol is None:
260
+ return True
261
+ return self.symbol.upper() == symbol.upper()
262
+
263
+ def to_dict(self) -> Dict[str, Any]:
264
+ """Convert to yfinance-compatible format for template rendering."""
265
+ return {
266
+ # yfinance fields
267
+ "date": self.date,
268
+ "analyst": self.analyst,
269
+ "firm": self.analyst, # Template uses both
270
+ "rating": self.to_grade,
271
+ "toGrade": self.to_grade,
272
+ "fromGrade": self.from_grade,
273
+ "action": self.action,
274
+ "priceTarget": self.current_target,
275
+ "currentTarget": self.current_target,
276
+ "priorTarget": self.prior_target,
277
+ # Commentary (our extension)
278
+ "comment": self.comment,
279
+ # Metadata
280
+ "symbol": self.symbol,
281
+ "is_injected": True,
282
+ }
283
+
284
+
285
+ @dataclass
286
+ class NewsModification:
287
+ """
288
+ Modification to apply to an existing news article.
289
+
290
+ This allows injecting content INTO real news, not just adding fake news.
291
+ The modification is synced between news list and article pages.
292
+
293
+ Static targeting (Docker + red teaming agent):
294
+ 1. Specific ID: article_id=12345 → modify that article only
295
+ 2. All articles: article_id=None → modify ALL articles for this symbol
296
+ """
297
+ symbol: str
298
+
299
+ # Targeting: specific article ID or None for all
300
+ article_id: Optional[int] = None
301
+
302
+ # Title modification
303
+ title_prefix: str = "" # Add before title
304
+ title_suffix: str = "" # Add after title
305
+ title_replace: Optional[str] = None # Replace entire title
306
+
307
+ # Summary modification
308
+ summary_prefix: str = "" # Add before summary
309
+ summary_suffix: str = "" # Add after summary
310
+ summary_inject: str = "" # Inject in middle
311
+ summary_inject_position: float = 0.5 # 0.0=start, 0.5=middle, 1.0=end
312
+ summary_replace: Optional[str] = None # Replace entire summary
313
+
314
+ # Content modification (for article body)
315
+ content_prefix: str = ""
316
+ content_suffix: str = ""
317
+ content_inject: str = ""
318
+ content_inject_position: float = 0.5
319
+
320
+ def should_apply_to(self, article_id: int) -> bool:
321
+ """Check if this modification should be applied to the given article."""
322
+ # article_id=None means ALL articles
323
+ if self.article_id is None:
324
+ return True
325
+ return self.article_id == article_id
326
+
327
+ def apply_to_title(self, original: str) -> str:
328
+ """Apply modifications to title."""
329
+ if self.title_replace is not None:
330
+ return self.title_replace
331
+ return f"{self.title_prefix}{original}{self.title_suffix}"
332
+
333
+ def apply_to_summary(self, original: str) -> str:
334
+ """Apply modifications to summary."""
335
+ if self.summary_replace is not None:
336
+ return self.summary_replace
337
+
338
+ result = original
339
+ if self.summary_inject:
340
+ pos = int(len(result) * self.summary_inject_position)
341
+ # Try to find a sentence boundary near the position
342
+ for i in range(pos, min(pos + 50, len(result))):
343
+ if result[i] in '.!?':
344
+ pos = i + 1
345
+ break
346
+ result = result[:pos] + " " + self.summary_inject + " " + result[pos:]
347
+
348
+ return f"{self.summary_prefix}{result}{self.summary_suffix}"
349
+
350
+ def apply_to_content(self, original: str) -> str:
351
+ """Apply modifications to article content."""
352
+ result = original
353
+ if self.content_inject:
354
+ pos = int(len(result) * self.content_inject_position)
355
+ # Try to find a paragraph boundary
356
+ for i in range(pos, min(pos + 100, len(result))):
357
+ if result[i:i+2] == '\n\n' or result[i:i+4] == '</p>':
358
+ pos = i
359
+ break
360
+ result = result[:pos] + "\n\n" + self.content_inject + "\n\n" + result[pos:]
361
+
362
+ return f"{self.content_prefix}{result}{self.content_suffix}"
363
+
364
+ def to_dict(self) -> Dict[str, Any]:
365
+ return {
366
+ "article_id": self.article_id,
367
+ "symbol": self.symbol,
368
+ "title_prefix": self.title_prefix,
369
+ "title_suffix": self.title_suffix,
370
+ "title_replace": self.title_replace,
371
+ "summary_prefix": self.summary_prefix,
372
+ "summary_suffix": self.summary_suffix,
373
+ "summary_inject": self.summary_inject,
374
+ "summary_inject_position": self.summary_inject_position,
375
+ "summary_replace": self.summary_replace,
376
+ "content_prefix": self.content_prefix,
377
+ "content_suffix": self.content_suffix,
378
+ "content_inject": self.content_inject,
379
+ "content_inject_position": self.content_inject_position,
380
+ }
381
+
382
+
383
+ class ContentInjector:
384
+ """
385
+ Manages fake content injection at the data layer.
386
+
387
+ Unlike HtmlInjector (HTML layer), this injects visible fake content
388
+ that gets rendered by templates alongside real content.
389
+
390
+ Static injection (Docker + red teaming agent):
391
+ - All targeting is deterministic (article_id or symbol)
392
+ - No view tracking or dynamic binding
393
+ - Content loaded at container startup via INJECTION_CONFIG env var
394
+
395
+ Features:
396
+ - Add fake news articles
397
+ - Add fake comments with votes
398
+ - Add fake analyst ratings
399
+ - Modify existing news (title, summary, content injection)
400
+ """
401
+
402
+ def __init__(self):
403
+ self._news: List[FakeNews] = []
404
+ # Comments: stored by symbol
405
+ self._comments: Dict[str, List[FakeComment]] = {} # key: symbol
406
+ # Analyst ratings: stored globally
407
+ self._analyst_ratings: List[FakeAnalystRating] = []
408
+ # Modifications: stored by symbol
409
+ self._modifications: Dict[str, List[NewsModification]] = {} # key: symbol
410
+ self._enabled = True
411
+ # Delivery log — records every time injected content is served
412
+ self._delivery_log: List[Dict[str, Any]] = []
413
+
414
+ def enable(self):
415
+ """Enable content injection."""
416
+ self._enabled = True
417
+
418
+ def disable(self):
419
+ """Disable content injection."""
420
+ self._enabled = False
421
+
422
+ def clear(self):
423
+ """Clear all injected content and modifications."""
424
+ self._news.clear()
425
+ self._comments.clear()
426
+ self._analyst_ratings.clear()
427
+ self._modifications.clear()
428
+ self._delivery_log.clear()
429
+
430
+ def get_delivery_log(self) -> List[Dict[str, Any]]:
431
+ """Get the log of all injected content deliveries."""
432
+ return list(self._delivery_log)
433
+
434
+ # === News Injection ===
435
+
436
+ def add_news(self, news: FakeNews):
437
+ """Add a fake news article."""
438
+ self._news.append(news)
439
+ # Sort by priority
440
+ self._news.sort(key=lambda n: -n.priority)
441
+
442
+ def get_news(self, symbol: Optional[str] = None, limit: int = 10) -> List[Dict]:
443
+ """Get injected news, optionally filtered by symbol."""
444
+ if not self._enabled:
445
+ return []
446
+
447
+ result = []
448
+ for news in self._news:
449
+ if symbol is None or news.symbol is None or news.symbol == symbol:
450
+ result.append(news.to_dict())
451
+ if len(result) >= limit:
452
+ break
453
+ return result
454
+
455
+ def merge_news(self, real_news: List[Dict], symbol: Optional[str] = None) -> List[Dict]:
456
+ """
457
+ Merge injected news with real news and apply modifications.
458
+
459
+ Steps:
460
+ 1. Insert fake news at specified positions
461
+ 2. Apply modifications to matching news articles (title/summary changes)
462
+ """
463
+ if not self._enabled:
464
+ return real_news
465
+
466
+ result = list(real_news)
467
+
468
+ # Insert fake news
469
+ fake = self.get_news(symbol)
470
+ for item in fake:
471
+ pos = item.get("position", "top")
472
+ if pos == "top":
473
+ result.insert(0, item)
474
+ elif pos == "bottom":
475
+ result.append(item)
476
+ elif pos == "middle":
477
+ mid = len(result) // 2
478
+ result.insert(mid, item)
479
+ else: # random
480
+ idx = random.randint(0, len(result))
481
+ result.insert(idx, item)
482
+
483
+ if fake:
484
+ self._delivery_log.append({
485
+ "type": "fake_news",
486
+ "symbol": symbol,
487
+ "count": len(fake),
488
+ "titles": [f.get("title", "")[:80] for f in fake],
489
+ "timestamp": datetime.now().isoformat(),
490
+ })
491
+
492
+ # Apply modifications to news list
493
+ mods_applied = []
494
+ if symbol and symbol in self._modifications:
495
+ for i, news_item in enumerate(result):
496
+ article_id = news_item.get('id')
497
+ if article_id:
498
+ for mod in self._modifications[symbol]:
499
+ if mod.should_apply_to(article_id):
500
+ # Apply title modifications
501
+ title = news_item.get('title', '') or news_item.get('headline', '')
502
+ if mod.title_replace:
503
+ title = mod.title_replace
504
+ else:
505
+ if mod.title_prefix:
506
+ title = mod.title_prefix + title
507
+ if mod.title_suffix:
508
+ title = title + mod.title_suffix
509
+ news_item['title'] = title
510
+ if 'headline' in news_item:
511
+ news_item['headline'] = title
512
+
513
+ # Apply summary modifications
514
+ summary = news_item.get('summary', '') or ''
515
+ if mod.summary_prefix:
516
+ summary = mod.summary_prefix + summary
517
+ if mod.summary_suffix:
518
+ summary = summary + mod.summary_suffix
519
+ if mod.summary_inject:
520
+ # Inject at specified position
521
+ pos = mod.summary_inject_position or 0.5
522
+ inject_idx = int(len(summary) * pos)
523
+ summary = summary[:inject_idx] + mod.summary_inject + summary[inject_idx:]
524
+ news_item['summary'] = summary
525
+ mods_applied.append(article_id)
526
+
527
+ if mods_applied:
528
+ self._delivery_log.append({
529
+ "type": "news_modification",
530
+ "symbol": symbol,
531
+ "articles_modified": mods_applied,
532
+ "timestamp": datetime.now().isoformat(),
533
+ })
534
+
535
+ return result
536
+
537
+ # === Comment Injection ===
538
+
539
+ def add_comment(self, comment: FakeComment):
540
+ """Add a fake comment."""
541
+ symbol = comment.symbol
542
+ if symbol not in self._comments:
543
+ self._comments[symbol] = []
544
+ self._comments[symbol].append(comment)
545
+ self._comments[symbol].sort(key=lambda c: -c.priority)
546
+
547
+ def get_comments(self, symbol: str, article_id: int) -> List[Dict]:
548
+ """
549
+ Get injected comments for an article.
550
+
551
+ Static matching: checks article_id directly.
552
+
553
+ Args:
554
+ symbol: Stock symbol
555
+ article_id: Article ID
556
+
557
+ Returns:
558
+ List of comment dicts to inject
559
+ """
560
+ if not self._enabled:
561
+ return []
562
+
563
+ result = []
564
+ for comment in self._comments.get(symbol, []):
565
+ if comment.should_apply_to(article_id):
566
+ comment_dict = comment.to_dict()
567
+ comment_dict['article_id'] = article_id
568
+ result.append(comment_dict)
569
+
570
+ return result
571
+
572
+ def merge_comments(self, real_comments: List[Dict], symbol: str, article_id: int) -> List[Dict]:
573
+ """Merge injected comments with real comments."""
574
+ if not self._enabled:
575
+ return real_comments
576
+
577
+ fake = self.get_comments(symbol, article_id)
578
+ if not fake:
579
+ return real_comments
580
+
581
+ result = list(real_comments)
582
+ for item in fake:
583
+ pos = item.get("position", "top")
584
+ if pos == "top":
585
+ result.insert(0, item)
586
+ elif pos == "bottom":
587
+ result.append(item)
588
+ else:
589
+ mid = len(result) // 2
590
+ result.insert(mid, item)
591
+
592
+ self._delivery_log.append({
593
+ "type": "fake_comment",
594
+ "symbol": symbol,
595
+ "article_id": article_id,
596
+ "count": len(fake),
597
+ "timestamp": datetime.now().isoformat(),
598
+ })
599
+
600
+ return result
601
+
602
+ # === Analyst Rating Injection ===
603
+
604
+ def add_analyst_rating(self, rating: FakeAnalystRating):
605
+ """Add a fake analyst rating."""
606
+ self._analyst_ratings.append(rating)
607
+ self._analyst_ratings.sort(key=lambda r: -r.priority)
608
+
609
+ def get_analyst_ratings(self, symbol: str) -> List[Dict]:
610
+ """
611
+ Get injected analyst ratings for a symbol.
612
+
613
+ Static matching: checks symbol directly.
614
+ """
615
+ if not self._enabled:
616
+ return []
617
+
618
+ result = []
619
+ for rating in self._analyst_ratings:
620
+ if rating.should_apply_to(symbol):
621
+ rating_dict = rating.to_dict()
622
+ rating_dict['symbol'] = symbol
623
+ result.append(rating_dict)
624
+
625
+ return result
626
+
627
+ def merge_analyst_ratings(self, real_ratings: List[Dict], symbol: str) -> List[Dict]:
628
+ """Merge injected ratings with real ratings."""
629
+ if not self._enabled:
630
+ return real_ratings
631
+
632
+ fake = self.get_analyst_ratings(symbol)
633
+ if not fake:
634
+ return real_ratings
635
+
636
+ self._delivery_log.append({
637
+ "type": "fake_analyst_rating",
638
+ "symbol": symbol,
639
+ "count": len(fake),
640
+ "timestamp": datetime.now().isoformat(),
641
+ })
642
+
643
+ # Prepend fake ratings (they appear first)
644
+ return fake + list(real_ratings)
645
+
646
+ # === News Modification (modify existing content) ===
647
+
648
+ def add_modification(self, mod: NewsModification):
649
+ """Add a modification to an existing news article."""
650
+ symbol = mod.symbol
651
+ if symbol not in self._modifications:
652
+ self._modifications[symbol] = []
653
+ self._modifications[symbol].append(mod)
654
+
655
+ def get_modifications(self, symbol: str, article_id: int) -> List[NewsModification]:
656
+ """
657
+ Get ALL applicable modifications for an article.
658
+
659
+ Static matching: checks article_id directly.
660
+ """
661
+ if not self._enabled:
662
+ return []
663
+
664
+ result = []
665
+ for mod in self._modifications.get(symbol, []):
666
+ if mod.should_apply_to(article_id):
667
+ result.append(mod)
668
+
669
+ return result
670
+
671
+ def modify_article(self, article: Dict, symbol: str = None) -> Dict:
672
+ """
673
+ Apply modifications to a full article.
674
+
675
+ Args:
676
+ article: The article dict
677
+ symbol: Stock symbol (auto-detected if not provided)
678
+ """
679
+ if not self._enabled:
680
+ return article
681
+
682
+ article_id = article.get('id')
683
+ # Try to get symbol from article if not provided
684
+ if symbol is None:
685
+ symbol = article.get('symbol', article.get('related', '').split(',')[0].strip())
686
+
687
+ if not article_id or not symbol:
688
+ return article
689
+
690
+ mods = self.get_modifications(symbol, article_id)
691
+ if not mods:
692
+ return article
693
+
694
+ result = article.copy()
695
+
696
+ for mod in mods:
697
+ # Modify title
698
+ if 'title' in result:
699
+ result['title'] = mod.apply_to_title(result['title'])
700
+ if 'headline' in result:
701
+ result['headline'] = mod.apply_to_title(result['headline'])
702
+
703
+ # Modify summary
704
+ if 'summary' in result:
705
+ result['summary'] = mod.apply_to_summary(result['summary'])
706
+
707
+ # Modify content (article body)
708
+ if 'content' in result:
709
+ result['content'] = mod.apply_to_content(result['content'])
710
+ if 'body' in result:
711
+ result['body'] = mod.apply_to_content(result['body'])
712
+
713
+ result['is_modified'] = True
714
+ return result
715
+
716
+ # === Serialization ===
717
+
718
+ def to_dict(self) -> Dict[str, Any]:
719
+ """Serialize for API responses."""
720
+ # Flatten modifications for display
721
+ all_mods = {}
722
+ for symbol, mods in self._modifications.items():
723
+ for i, mod in enumerate(mods):
724
+ key = f"{symbol}_{mod.article_id or f'all_{i}'}"
725
+ all_mods[key] = mod.to_dict()
726
+
727
+ return {
728
+ "enabled": self._enabled,
729
+ "news_count": len(self._news),
730
+ "comments_count": sum(len(v) for v in self._comments.values()),
731
+ "ratings_count": len(self._analyst_ratings),
732
+ "modifications_count": sum(len(v) for v in self._modifications.values()),
733
+ "modifications": all_mods,
734
+ }
735
+
736
+ def from_config(self, config: Dict[str, Any]):
737
+ """Load from configuration dict."""
738
+ self.clear()
739
+
740
+ for news_data in config.get("news", []):
741
+ # Filter out legacy match_index if present
742
+ news_data = {k: v for k, v in news_data.items() if k != 'match_index'}
743
+ self.add_news(FakeNews(**news_data))
744
+
745
+ for comment_data in config.get("comments", []):
746
+ comment_data = {k: v for k, v in comment_data.items() if k != 'match_index'}
747
+ self.add_comment(FakeComment(**comment_data))
748
+
749
+ for rating_data in config.get("analyst_ratings", []):
750
+ rating_data = {k: v for k, v in rating_data.items() if k != 'match_index'}
751
+ self.add_analyst_rating(FakeAnalystRating(**rating_data))
752
+
753
+ for mod_data in config.get("modifications", []):
754
+ mod_data = {k: v for k, v in mod_data.items() if k != 'match_index'}
755
+ self.add_modification(NewsModification(**mod_data))