@jaguilar87/gaia-ops 4.4.0 → 4.7.2

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 (371) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +12 -3
  3. package/ARCHITECTURE.md +9 -8
  4. package/CHANGELOG.md +34 -0
  5. package/README.md +43 -11
  6. package/agents/terraform-architect.md +1 -1
  7. package/bin/README.md +2 -2
  8. package/bin/gaia-doctor.js +18 -5
  9. package/bin/gaia-history.js +0 -1
  10. package/bin/gaia-metrics.js +2 -2
  11. package/bin/gaia-scan.py +23 -1
  12. package/bin/gaia-update.js +346 -54
  13. package/bin/pre-publish-validate.js +33 -10
  14. package/commands/gaia.md +37 -0
  15. package/config/README.md +3 -9
  16. package/config/context-contracts.json +47 -15
  17. package/config/surface-routing.json +9 -1
  18. package/dist/gaia-ops/.claude-plugin/plugin.json +22 -0
  19. package/dist/gaia-ops/agents/cloud-troubleshooter.md +73 -0
  20. package/dist/gaia-ops/agents/devops-developer.md +57 -0
  21. package/dist/gaia-ops/agents/gaia-system.md +58 -0
  22. package/dist/gaia-ops/agents/gitops-operator.md +60 -0
  23. package/dist/gaia-ops/agents/speckit-planner.md +71 -0
  24. package/dist/gaia-ops/agents/terraform-architect.md +60 -0
  25. package/dist/gaia-ops/commands/gaia.md +37 -0
  26. package/dist/gaia-ops/config/README.md +58 -0
  27. package/dist/gaia-ops/config/cloud/aws.json +140 -0
  28. package/dist/gaia-ops/config/cloud/gcp.json +145 -0
  29. package/dist/gaia-ops/config/context-contracts.json +131 -0
  30. package/dist/gaia-ops/config/git_standards.json +72 -0
  31. package/dist/gaia-ops/config/surface-routing.json +197 -0
  32. package/dist/gaia-ops/config/universal-rules.json +10 -0
  33. package/dist/gaia-ops/hooks/adapters/__init__.py +52 -0
  34. package/dist/gaia-ops/hooks/adapters/base.py +219 -0
  35. package/dist/gaia-ops/hooks/adapters/channel.py +17 -0
  36. package/dist/gaia-ops/hooks/adapters/claude_code.py +1477 -0
  37. package/dist/gaia-ops/hooks/adapters/types.py +194 -0
  38. package/dist/gaia-ops/hooks/adapters/utils.py +25 -0
  39. package/dist/gaia-ops/hooks/hooks.json +126 -0
  40. package/dist/gaia-ops/hooks/modules/__init__.py +15 -0
  41. package/dist/gaia-ops/hooks/modules/agents/__init__.py +29 -0
  42. package/dist/gaia-ops/hooks/modules/agents/contract_validator.py +647 -0
  43. package/dist/gaia-ops/hooks/modules/agents/response_contract.py +496 -0
  44. package/dist/gaia-ops/hooks/modules/agents/skill_injection_verifier.py +124 -0
  45. package/dist/gaia-ops/hooks/modules/agents/task_info_builder.py +74 -0
  46. package/dist/gaia-ops/hooks/modules/agents/transcript_analyzer.py +458 -0
  47. package/dist/gaia-ops/hooks/modules/agents/transcript_reader.py +152 -0
  48. package/dist/gaia-ops/hooks/modules/audit/__init__.py +28 -0
  49. package/dist/gaia-ops/hooks/modules/audit/event_detector.py +168 -0
  50. package/dist/gaia-ops/hooks/modules/audit/logger.py +131 -0
  51. package/dist/gaia-ops/hooks/modules/audit/metrics.py +134 -0
  52. package/dist/gaia-ops/hooks/modules/audit/workflow_auditor.py +576 -0
  53. package/dist/gaia-ops/hooks/modules/audit/workflow_recorder.py +296 -0
  54. package/dist/gaia-ops/hooks/modules/context/__init__.py +11 -0
  55. package/dist/gaia-ops/hooks/modules/context/anchor_tracker.py +317 -0
  56. package/dist/gaia-ops/hooks/modules/context/compact_context_builder.py +215 -0
  57. package/dist/gaia-ops/hooks/modules/context/context_cache.py +129 -0
  58. package/dist/gaia-ops/hooks/modules/context/context_freshness.py +145 -0
  59. package/dist/gaia-ops/hooks/modules/context/context_injector.py +427 -0
  60. package/dist/gaia-ops/hooks/modules/context/context_writer.py +518 -0
  61. package/dist/gaia-ops/hooks/modules/context/contracts_loader.py +161 -0
  62. package/dist/gaia-ops/hooks/modules/core/__init__.py +40 -0
  63. package/dist/gaia-ops/hooks/modules/core/hook_entry.py +78 -0
  64. package/dist/gaia-ops/hooks/modules/core/paths.py +160 -0
  65. package/dist/gaia-ops/hooks/modules/core/plugin_mode.py +149 -0
  66. package/dist/gaia-ops/hooks/modules/core/plugin_setup.py +558 -0
  67. package/dist/gaia-ops/hooks/modules/core/state.py +179 -0
  68. package/dist/gaia-ops/hooks/modules/core/stdin.py +24 -0
  69. package/dist/gaia-ops/hooks/modules/events/__init__.py +1 -0
  70. package/dist/gaia-ops/hooks/modules/events/event_writer.py +210 -0
  71. package/dist/gaia-ops/hooks/modules/identity/__init__.py +0 -0
  72. package/dist/gaia-ops/hooks/modules/identity/identity_provider.py +21 -0
  73. package/dist/gaia-ops/hooks/modules/identity/ops_identity.py +34 -0
  74. package/dist/gaia-ops/hooks/modules/identity/security_identity.py +10 -0
  75. package/dist/gaia-ops/hooks/modules/memory/__init__.py +8 -0
  76. package/dist/gaia-ops/hooks/modules/memory/episode_writer.py +227 -0
  77. package/dist/gaia-ops/hooks/modules/orchestrator/__init__.py +1 -0
  78. package/dist/gaia-ops/hooks/modules/orchestrator/delegate_mode.py +128 -0
  79. package/dist/gaia-ops/hooks/modules/scanning/__init__.py +8 -0
  80. package/dist/gaia-ops/hooks/modules/scanning/scan_trigger.py +84 -0
  81. package/dist/gaia-ops/hooks/modules/security/__init__.py +89 -0
  82. package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +87 -0
  83. package/dist/gaia-ops/hooks/modules/security/approval_constants.py +23 -0
  84. package/dist/gaia-ops/hooks/modules/security/approval_grants.py +912 -0
  85. package/dist/gaia-ops/hooks/modules/security/approval_messages.py +71 -0
  86. package/dist/gaia-ops/hooks/modules/security/approval_scopes.py +153 -0
  87. package/dist/gaia-ops/hooks/modules/security/blocked_commands.py +584 -0
  88. package/dist/gaia-ops/hooks/modules/security/blocked_message_formatter.py +86 -0
  89. package/dist/gaia-ops/hooks/modules/security/command_semantics.py +130 -0
  90. package/dist/gaia-ops/hooks/modules/security/gitops_validator.py +179 -0
  91. package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +850 -0
  92. package/dist/gaia-ops/hooks/modules/security/prompt_validator.py +40 -0
  93. package/dist/gaia-ops/hooks/modules/security/tiers.py +196 -0
  94. package/dist/gaia-ops/hooks/modules/session/__init__.py +10 -0
  95. package/dist/gaia-ops/hooks/modules/session/session_context_writer.py +100 -0
  96. package/dist/gaia-ops/hooks/modules/session/session_event_injector.py +158 -0
  97. package/dist/gaia-ops/hooks/modules/session/session_manager.py +31 -0
  98. package/dist/gaia-ops/hooks/modules/tools/__init__.py +25 -0
  99. package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +708 -0
  100. package/dist/gaia-ops/hooks/modules/tools/cloud_pipe_validator.py +181 -0
  101. package/dist/gaia-ops/hooks/modules/tools/hook_response.py +55 -0
  102. package/dist/gaia-ops/hooks/modules/tools/shell_parser.py +227 -0
  103. package/dist/gaia-ops/hooks/modules/tools/task_validator.py +283 -0
  104. package/dist/gaia-ops/hooks/modules/validation/__init__.py +23 -0
  105. package/dist/gaia-ops/hooks/modules/validation/commit_validator.py +380 -0
  106. package/dist/gaia-ops/hooks/post_compact.py +43 -0
  107. package/dist/gaia-ops/hooks/post_tool_use.py +54 -0
  108. package/dist/gaia-ops/hooks/pre_tool_use.py +383 -0
  109. package/dist/gaia-ops/hooks/session_start.py +69 -0
  110. package/dist/gaia-ops/hooks/stop_hook.py +69 -0
  111. package/dist/gaia-ops/hooks/subagent_start.py +71 -0
  112. package/dist/gaia-ops/hooks/subagent_stop.py +288 -0
  113. package/dist/gaia-ops/hooks/task_completed.py +70 -0
  114. package/dist/gaia-ops/hooks/user_prompt_submit.py +177 -0
  115. package/dist/gaia-ops/settings.json +72 -0
  116. package/dist/gaia-ops/skills/README.md +109 -0
  117. package/dist/gaia-ops/skills/agent-protocol/SKILL.md +105 -0
  118. package/dist/gaia-ops/skills/agent-protocol/examples.md +170 -0
  119. package/dist/gaia-ops/skills/agent-response/SKILL.md +53 -0
  120. package/dist/gaia-ops/skills/approval/SKILL.md +85 -0
  121. package/dist/gaia-ops/skills/approval/examples.md +140 -0
  122. package/dist/gaia-ops/skills/approval/reference.md +57 -0
  123. package/dist/gaia-ops/skills/command-execution/SKILL.md +64 -0
  124. package/dist/gaia-ops/skills/command-execution/reference.md +83 -0
  125. package/dist/gaia-ops/skills/context-updater/SKILL.md +76 -0
  126. package/dist/gaia-ops/skills/context-updater/examples.md +71 -0
  127. package/dist/gaia-ops/skills/developer-patterns/SKILL.md +93 -0
  128. package/dist/gaia-ops/skills/developer-patterns/reference.md +112 -0
  129. package/dist/gaia-ops/skills/execution/SKILL.md +66 -0
  130. package/dist/gaia-ops/skills/fast-queries/SKILL.md +47 -0
  131. package/dist/gaia-ops/skills/gaia-patterns/SKILL.md +92 -0
  132. package/dist/gaia-ops/skills/gaia-patterns/reference.md +22 -0
  133. package/dist/gaia-ops/skills/git-conventions/SKILL.md +48 -0
  134. package/dist/gaia-ops/skills/gitops-patterns/SKILL.md +73 -0
  135. package/dist/gaia-ops/skills/gitops-patterns/reference.md +183 -0
  136. package/dist/gaia-ops/skills/investigation/SKILL.md +77 -0
  137. package/dist/gaia-ops/skills/orchestrator-approval/SKILL.md +64 -0
  138. package/dist/gaia-ops/skills/reference.md +134 -0
  139. package/dist/gaia-ops/skills/security-tiers/SKILL.md +61 -0
  140. package/dist/gaia-ops/skills/security-tiers/destructive-commands-reference.md +623 -0
  141. package/dist/gaia-ops/skills/security-tiers/reference.md +39 -0
  142. package/dist/gaia-ops/skills/skill-creation/SKILL.md +119 -0
  143. package/dist/gaia-ops/skills/specification/SKILL.md +186 -0
  144. package/dist/gaia-ops/skills/speckit-workflow/SKILL.md +165 -0
  145. package/dist/gaia-ops/skills/speckit-workflow/reference.md +117 -0
  146. package/dist/gaia-ops/skills/terraform-patterns/SKILL.md +63 -0
  147. package/dist/gaia-ops/skills/terraform-patterns/reference.md +93 -0
  148. package/dist/gaia-ops/speckit/README.md +516 -0
  149. package/dist/gaia-ops/speckit/scripts/.gitkeep +0 -0
  150. package/dist/gaia-ops/speckit/templates/adr-template.md +118 -0
  151. package/dist/gaia-ops/speckit/templates/agent-file-template.md +23 -0
  152. package/dist/gaia-ops/speckit/templates/plan-template.md +227 -0
  153. package/dist/gaia-ops/speckit/templates/spec-template.md +140 -0
  154. package/dist/gaia-ops/speckit/templates/tasks-template.md +257 -0
  155. package/dist/gaia-ops/tools/context/README.md +132 -0
  156. package/dist/gaia-ops/tools/context/__init__.py +42 -0
  157. package/dist/gaia-ops/tools/context/_paths.py +20 -0
  158. package/dist/gaia-ops/tools/context/context_provider.py +476 -0
  159. package/dist/gaia-ops/tools/context/context_section_reader.py +330 -0
  160. package/dist/gaia-ops/tools/context/deep_merge.py +159 -0
  161. package/dist/gaia-ops/tools/context/pending_updates.py +760 -0
  162. package/dist/gaia-ops/tools/context/surface_router.py +278 -0
  163. package/dist/gaia-ops/tools/fast-queries/README.md +65 -0
  164. package/dist/gaia-ops/tools/fast-queries/__init__.py +30 -0
  165. package/dist/gaia-ops/tools/fast-queries/appservices/quicktriage_devops_developer.sh +75 -0
  166. package/dist/gaia-ops/tools/fast-queries/cloud/aws/quicktriage_aws_troubleshooter.sh +32 -0
  167. package/dist/gaia-ops/tools/fast-queries/cloud/gcp/quicktriage_gcp_troubleshooter.sh +88 -0
  168. package/dist/gaia-ops/tools/fast-queries/gitops/quicktriage_gitops_operator.sh +48 -0
  169. package/dist/gaia-ops/tools/fast-queries/run_triage.sh +59 -0
  170. package/dist/gaia-ops/tools/fast-queries/terraform/quicktriage_terraform_architect.sh +80 -0
  171. package/dist/gaia-ops/tools/gaia_simulator/__init__.py +33 -0
  172. package/dist/gaia-ops/tools/gaia_simulator/cli.py +354 -0
  173. package/dist/gaia-ops/tools/gaia_simulator/extractor.py +457 -0
  174. package/dist/gaia-ops/tools/gaia_simulator/reporter.py +258 -0
  175. package/dist/gaia-ops/tools/gaia_simulator/routing_simulator.py +334 -0
  176. package/dist/gaia-ops/tools/gaia_simulator/runner.py +539 -0
  177. package/dist/gaia-ops/tools/gaia_simulator/skills_mapper.py +262 -0
  178. package/dist/gaia-ops/tools/memory/README.md +0 -0
  179. package/dist/gaia-ops/tools/memory/__init__.py +20 -0
  180. package/dist/gaia-ops/tools/memory/episodic.py +1196 -0
  181. package/dist/gaia-ops/tools/persist_transcript_analysis.py +85 -0
  182. package/dist/gaia-ops/tools/review/__init__.py +1 -0
  183. package/dist/gaia-ops/tools/review/review_engine.py +157 -0
  184. package/dist/gaia-ops/tools/scan/__init__.py +35 -0
  185. package/dist/gaia-ops/tools/scan/config.py +247 -0
  186. package/dist/gaia-ops/tools/scan/merge.py +212 -0
  187. package/dist/gaia-ops/tools/scan/orchestrator.py +549 -0
  188. package/dist/gaia-ops/tools/scan/registry.py +127 -0
  189. package/dist/gaia-ops/tools/scan/scanners/__init__.py +18 -0
  190. package/dist/gaia-ops/tools/scan/scanners/base.py +137 -0
  191. package/dist/gaia-ops/tools/scan/scanners/environment.py +324 -0
  192. package/dist/gaia-ops/tools/scan/scanners/git.py +570 -0
  193. package/dist/gaia-ops/tools/scan/scanners/infrastructure.py +875 -0
  194. package/dist/gaia-ops/tools/scan/scanners/orchestration.py +600 -0
  195. package/dist/gaia-ops/tools/scan/scanners/stack.py +1085 -0
  196. package/dist/gaia-ops/tools/scan/scanners/tools.py +260 -0
  197. package/dist/gaia-ops/tools/scan/setup.py +753 -0
  198. package/dist/gaia-ops/tools/scan/tests/__init__.py +1 -0
  199. package/dist/gaia-ops/tools/scan/tests/conftest.py +796 -0
  200. package/dist/gaia-ops/tools/scan/tests/test_environment.py +323 -0
  201. package/dist/gaia-ops/tools/scan/tests/test_git.py +419 -0
  202. package/dist/gaia-ops/tools/scan/tests/test_infrastructure.py +382 -0
  203. package/dist/gaia-ops/tools/scan/tests/test_integration.py +920 -0
  204. package/dist/gaia-ops/tools/scan/tests/test_merge.py +269 -0
  205. package/dist/gaia-ops/tools/scan/tests/test_orchestration.py +304 -0
  206. package/dist/gaia-ops/tools/scan/tests/test_stack.py +604 -0
  207. package/dist/gaia-ops/tools/scan/tests/test_tools.py +349 -0
  208. package/dist/gaia-ops/tools/scan/ui.py +624 -0
  209. package/dist/gaia-ops/tools/scan/verify.py +266 -0
  210. package/dist/gaia-ops/tools/scan/walk.py +118 -0
  211. package/dist/gaia-ops/tools/scan/workspace.py +85 -0
  212. package/dist/gaia-ops/tools/validation/README.md +244 -0
  213. package/dist/gaia-ops/tools/validation/__init__.py +17 -0
  214. package/dist/gaia-ops/tools/validation/approval_gate.py +321 -0
  215. package/dist/gaia-ops/tools/validation/validate_skills.py +189 -0
  216. package/dist/gaia-security/.claude-plugin/plugin.json +22 -0
  217. package/dist/gaia-security/config/universal-rules.json +10 -0
  218. package/dist/gaia-security/hooks/adapters/__init__.py +52 -0
  219. package/dist/gaia-security/hooks/adapters/base.py +219 -0
  220. package/dist/gaia-security/hooks/adapters/channel.py +17 -0
  221. package/dist/gaia-security/hooks/adapters/claude_code.py +1477 -0
  222. package/dist/gaia-security/hooks/adapters/types.py +194 -0
  223. package/dist/gaia-security/hooks/adapters/utils.py +25 -0
  224. package/dist/gaia-security/hooks/hooks.json +57 -0
  225. package/dist/gaia-security/hooks/modules/__init__.py +15 -0
  226. package/dist/gaia-security/hooks/modules/agents/__init__.py +29 -0
  227. package/dist/gaia-security/hooks/modules/agents/contract_validator.py +647 -0
  228. package/dist/gaia-security/hooks/modules/agents/response_contract.py +496 -0
  229. package/dist/gaia-security/hooks/modules/agents/skill_injection_verifier.py +124 -0
  230. package/dist/gaia-security/hooks/modules/agents/task_info_builder.py +74 -0
  231. package/dist/gaia-security/hooks/modules/agents/transcript_analyzer.py +458 -0
  232. package/dist/gaia-security/hooks/modules/agents/transcript_reader.py +152 -0
  233. package/dist/gaia-security/hooks/modules/audit/__init__.py +28 -0
  234. package/dist/gaia-security/hooks/modules/audit/event_detector.py +168 -0
  235. package/dist/gaia-security/hooks/modules/audit/logger.py +131 -0
  236. package/dist/gaia-security/hooks/modules/audit/metrics.py +134 -0
  237. package/dist/gaia-security/hooks/modules/audit/workflow_auditor.py +576 -0
  238. package/dist/gaia-security/hooks/modules/audit/workflow_recorder.py +296 -0
  239. package/dist/gaia-security/hooks/modules/context/__init__.py +11 -0
  240. package/dist/gaia-security/hooks/modules/context/anchor_tracker.py +317 -0
  241. package/dist/gaia-security/hooks/modules/context/compact_context_builder.py +215 -0
  242. package/dist/gaia-security/hooks/modules/context/context_cache.py +129 -0
  243. package/dist/gaia-security/hooks/modules/context/context_freshness.py +145 -0
  244. package/dist/gaia-security/hooks/modules/context/context_injector.py +427 -0
  245. package/dist/gaia-security/hooks/modules/context/context_writer.py +518 -0
  246. package/dist/gaia-security/hooks/modules/context/contracts_loader.py +161 -0
  247. package/dist/gaia-security/hooks/modules/core/__init__.py +40 -0
  248. package/dist/gaia-security/hooks/modules/core/hook_entry.py +78 -0
  249. package/dist/gaia-security/hooks/modules/core/paths.py +160 -0
  250. package/dist/gaia-security/hooks/modules/core/plugin_mode.py +149 -0
  251. package/dist/gaia-security/hooks/modules/core/plugin_setup.py +558 -0
  252. package/dist/gaia-security/hooks/modules/core/state.py +179 -0
  253. package/dist/gaia-security/hooks/modules/core/stdin.py +24 -0
  254. package/dist/gaia-security/hooks/modules/events/__init__.py +1 -0
  255. package/dist/gaia-security/hooks/modules/events/event_writer.py +210 -0
  256. package/dist/gaia-security/hooks/modules/identity/__init__.py +0 -0
  257. package/dist/gaia-security/hooks/modules/identity/identity_provider.py +21 -0
  258. package/dist/gaia-security/hooks/modules/identity/ops_identity.py +34 -0
  259. package/dist/gaia-security/hooks/modules/identity/security_identity.py +10 -0
  260. package/dist/gaia-security/hooks/modules/memory/__init__.py +8 -0
  261. package/dist/gaia-security/hooks/modules/memory/episode_writer.py +227 -0
  262. package/dist/gaia-security/hooks/modules/orchestrator/__init__.py +1 -0
  263. package/dist/gaia-security/hooks/modules/orchestrator/delegate_mode.py +128 -0
  264. package/dist/gaia-security/hooks/modules/scanning/__init__.py +8 -0
  265. package/dist/gaia-security/hooks/modules/scanning/scan_trigger.py +84 -0
  266. package/dist/gaia-security/hooks/modules/security/__init__.py +89 -0
  267. package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +87 -0
  268. package/dist/gaia-security/hooks/modules/security/approval_constants.py +23 -0
  269. package/dist/gaia-security/hooks/modules/security/approval_grants.py +912 -0
  270. package/dist/gaia-security/hooks/modules/security/approval_messages.py +71 -0
  271. package/dist/gaia-security/hooks/modules/security/approval_scopes.py +153 -0
  272. package/dist/gaia-security/hooks/modules/security/blocked_commands.py +584 -0
  273. package/dist/gaia-security/hooks/modules/security/blocked_message_formatter.py +86 -0
  274. package/dist/gaia-security/hooks/modules/security/command_semantics.py +130 -0
  275. package/dist/gaia-security/hooks/modules/security/gitops_validator.py +179 -0
  276. package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +850 -0
  277. package/dist/gaia-security/hooks/modules/security/prompt_validator.py +40 -0
  278. package/dist/gaia-security/hooks/modules/security/tiers.py +196 -0
  279. package/dist/gaia-security/hooks/modules/session/__init__.py +10 -0
  280. package/dist/gaia-security/hooks/modules/session/session_context_writer.py +100 -0
  281. package/dist/gaia-security/hooks/modules/session/session_event_injector.py +158 -0
  282. package/dist/gaia-security/hooks/modules/session/session_manager.py +31 -0
  283. package/dist/gaia-security/hooks/modules/tools/__init__.py +25 -0
  284. package/dist/gaia-security/hooks/modules/tools/bash_validator.py +708 -0
  285. package/dist/gaia-security/hooks/modules/tools/cloud_pipe_validator.py +181 -0
  286. package/dist/gaia-security/hooks/modules/tools/hook_response.py +55 -0
  287. package/dist/gaia-security/hooks/modules/tools/shell_parser.py +227 -0
  288. package/dist/gaia-security/hooks/modules/tools/task_validator.py +283 -0
  289. package/dist/gaia-security/hooks/modules/validation/__init__.py +23 -0
  290. package/dist/gaia-security/hooks/modules/validation/commit_validator.py +380 -0
  291. package/dist/gaia-security/hooks/post_tool_use.py +54 -0
  292. package/dist/gaia-security/hooks/pre_tool_use.py +383 -0
  293. package/dist/gaia-security/hooks/session_start.py +69 -0
  294. package/dist/gaia-security/hooks/stop_hook.py +69 -0
  295. package/dist/gaia-security/hooks/user_prompt_submit.py +177 -0
  296. package/dist/gaia-security/settings.json +58 -0
  297. package/git-hooks/commit-msg +41 -0
  298. package/hooks/README.md +8 -6
  299. package/hooks/adapters/channel.py +0 -25
  300. package/hooks/adapters/claude_code.py +364 -125
  301. package/hooks/elicitation_result.py +132 -0
  302. package/hooks/hooks.json +10 -1
  303. package/hooks/modules/README.md +3 -2
  304. package/hooks/modules/agents/contract_validator.py +3 -51
  305. package/hooks/modules/agents/response_contract.py +4 -8
  306. package/hooks/modules/agents/transcript_reader.py +4 -5
  307. package/hooks/modules/audit/__init__.py +4 -6
  308. package/hooks/modules/audit/event_detector.py +0 -2
  309. package/hooks/modules/audit/metrics.py +108 -187
  310. package/hooks/modules/audit/workflow_auditor.py +0 -4
  311. package/hooks/modules/audit/workflow_recorder.py +0 -5
  312. package/hooks/modules/context/compact_context_builder.py +1 -0
  313. package/hooks/modules/context/context_cache.py +129 -0
  314. package/hooks/modules/context/context_injector.py +18 -40
  315. package/hooks/modules/context/context_writer.py +1 -25
  316. package/hooks/modules/context/contracts_loader.py +7 -10
  317. package/hooks/modules/core/hook_entry.py +1 -0
  318. package/hooks/modules/core/paths.py +12 -13
  319. package/hooks/modules/core/plugin_mode.py +74 -4
  320. package/hooks/modules/core/plugin_setup.py +395 -23
  321. package/hooks/modules/events/__init__.py +1 -0
  322. package/hooks/modules/events/event_writer.py +210 -0
  323. package/hooks/modules/identity/ops_identity.py +18 -27
  324. package/hooks/modules/memory/episode_writer.py +1 -6
  325. package/hooks/modules/orchestrator/__init__.py +1 -0
  326. package/hooks/modules/orchestrator/delegate_mode.py +128 -0
  327. package/hooks/modules/security/__init__.py +2 -4
  328. package/hooks/modules/security/approval_constants.py +5 -1
  329. package/hooks/modules/security/approval_grants.py +189 -6
  330. package/hooks/modules/security/approval_messages.py +9 -21
  331. package/hooks/modules/security/blocked_commands.py +98 -34
  332. package/hooks/modules/security/command_semantics.py +0 -4
  333. package/hooks/modules/security/gitops_validator.py +1 -11
  334. package/hooks/modules/security/mutative_verbs.py +179 -38
  335. package/hooks/modules/security/tiers.py +1 -19
  336. package/hooks/modules/session/session_event_injector.py +1 -25
  337. package/hooks/modules/tools/bash_validator.py +310 -94
  338. package/hooks/modules/tools/shell_parser.py +0 -1
  339. package/hooks/modules/tools/task_validator.py +9 -29
  340. package/hooks/post_tool_use.py +0 -72
  341. package/hooks/pre_tool_use.py +42 -102
  342. package/hooks/session_start.py +4 -2
  343. package/hooks/subagent_start.py +6 -2
  344. package/hooks/subagent_stop.py +1 -13
  345. package/hooks/user_prompt_submit.py +119 -37
  346. package/index.js +1 -1
  347. package/package.json +5 -3
  348. package/skills/README.md +3 -5
  349. package/skills/agent-protocol/SKILL.md +17 -16
  350. package/skills/agent-protocol/examples.md +6 -6
  351. package/skills/agent-response/SKILL.md +11 -14
  352. package/skills/approval/SKILL.md +28 -13
  353. package/skills/approval/reference.md +2 -2
  354. package/skills/execution/SKILL.md +1 -1
  355. package/skills/gaia-patterns/SKILL.md +2 -3
  356. package/skills/orchestrator-approval/SKILL.md +22 -50
  357. package/skills/security-tiers/SKILL.md +1 -1
  358. package/templates/README.md +9 -9
  359. package/templates/managed-settings.template.json +43 -0
  360. package/tools/gaia_simulator/runner.py +34 -1
  361. package/tools/scan/orchestrator.py +13 -0
  362. package/tools/scan/scanners/base.py +8 -0
  363. package/tools/scan/scanners/git.py +78 -0
  364. package/tools/scan/scanners/infrastructure.py +65 -0
  365. package/tools/scan/scanners/stack.py +110 -0
  366. package/tools/scan/setup.py +120 -13
  367. package/tools/scan/workspace.py +85 -0
  368. package/config/context-contracts.aws.json +0 -42
  369. package/config/context-contracts.gcp.json +0 -39
  370. package/skills/project-dispatch/SKILL.md +0 -34
  371. package/templates/settings.template.json +0 -226
@@ -0,0 +1,912 @@
1
+ """
2
+ Approval grant management for T3 command passthrough.
3
+
4
+ Two-phase nonce-based approval flow:
5
+
6
+ Phase 1 -- BLOCKING:
7
+ bash_validator detects a T3 command, generates a cryptographic nonce,
8
+ writes a pending-{nonce}.json file, and returns a block response that
9
+ includes the nonce for the agent to present.
10
+
11
+ Phase 2 -- ACTIVATION:
12
+ The orchestrator resumes the agent with "APPROVE:{nonce}". The
13
+ pre_tool_use hook finds the pending file, validates it (session, TTL,
14
+ nonce match), converts it to an active grant, and deletes the pending
15
+ file. The agent retries the command; bash_validator finds the active
16
+ grant and allows it.
17
+
18
+ Grants are:
19
+ - Scoped to a session (CLAUDE_SESSION_ID)
20
+ - Time-limited (default 10 minutes)
21
+ - Cleaned up after use or expiry
22
+ - Stored in .claude/cache/approvals/
23
+
24
+ Security properties:
25
+ - Grants are created ONLY by the hook (not by agents)
26
+ - Nonce-activated grants are scoped to a semantic command signature
27
+ - Grants expire automatically
28
+ - The deny list (blocked_commands.py) is NEVER bypassed -- grants only
29
+ override the dangerous verb detector
30
+ - Nonces are 128-bit random hex (cannot be guessed)
31
+ - Pending files are session-scoped (cannot be activated from another session)
32
+ - A nonce can only be activated ONCE (pending file deleted on activation)
33
+ """
34
+
35
+ import json
36
+ import logging
37
+ import os
38
+ import secrets
39
+ import time
40
+ from dataclasses import dataclass, field, asdict
41
+ from enum import Enum
42
+ from pathlib import Path
43
+ from typing import Any, Dict, List, Optional
44
+
45
+ from ..core.paths import find_claude_dir, get_plugin_data_dir
46
+ from ..core.state import get_session_id
47
+ from .approval_scopes import (
48
+ ApprovalSignature,
49
+ SCOPE_SEMANTIC_SIGNATURE,
50
+ SUPPORTED_SCOPE_TYPES,
51
+ build_approval_signature,
52
+ matches_approval_signature,
53
+ )
54
+
55
+ logger = logging.getLogger(__name__)
56
+
57
+ # Default grant TTL in minutes
58
+ DEFAULT_GRANT_TTL_MINUTES = 5
59
+
60
+ # Cleanup throttle: only run cleanup if 60+ seconds since last run
61
+ _last_cleanup_time: float = 0.0
62
+ _CLEANUP_INTERVAL_SECONDS = 60
63
+
64
+ class ActivationStatus(str, Enum):
65
+ """Activation result statuses for pending approval flow."""
66
+ ACTIVATED = "activated"
67
+ NOT_FOUND = "not_found"
68
+ NONCE_MISMATCH = "nonce_mismatch"
69
+ SESSION_MISMATCH = "session_mismatch"
70
+ EXPIRED = "expired"
71
+ INVALID_SIGNATURE = "invalid_signature"
72
+ INVALID_PENDING = "invalid_pending"
73
+ ERROR = "error"
74
+
75
+
76
+ # Backward-compatible module-level aliases
77
+ ACTIVATION_ACTIVATED = ActivationStatus.ACTIVATED
78
+ ACTIVATION_NOT_FOUND = ActivationStatus.NOT_FOUND
79
+ ACTIVATION_NONCE_MISMATCH = ActivationStatus.NONCE_MISMATCH
80
+ ACTIVATION_SESSION_MISMATCH = ActivationStatus.SESSION_MISMATCH
81
+ ACTIVATION_EXPIRED = ActivationStatus.EXPIRED
82
+ ACTIVATION_INVALID_SIGNATURE = ActivationStatus.INVALID_SIGNATURE
83
+ ACTIVATION_INVALID_PENDING = ActivationStatus.INVALID_PENDING
84
+ ACTIVATION_ERROR = ActivationStatus.ERROR
85
+
86
+
87
+ def _is_ttl_expired(timestamp: float, ttl_minutes: int) -> bool:
88
+ """Return True if the given timestamp is older than ttl_minutes."""
89
+ if timestamp == 0:
90
+ return True
91
+ elapsed_minutes = (time.time() - timestamp) / 60
92
+ return elapsed_minutes > ttl_minutes
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class ApprovalActivationResult:
97
+ """Structured result for pending approval activation."""
98
+
99
+ success: bool
100
+ status: str
101
+ reason: str
102
+ grant_path: Optional[Path] = None
103
+
104
+
105
+ @dataclass
106
+ class ApprovalGrant:
107
+ """A time-limited approval grant for T3 commands.
108
+
109
+ Attributes:
110
+ session_id: The Claude session that owns this grant.
111
+ approved_verbs: Human-readable verb summary for logs/debugging.
112
+ approved_scope: Original approval scope text from the user.
113
+ scope_type: Approval scope mode (exact or semantic).
114
+ scope_signature: Persisted ApprovalSignature payload for matching.
115
+ granted_at: Unix timestamp when the grant was created.
116
+ ttl_minutes: How long the grant is valid.
117
+ used: Whether the grant has been consumed.
118
+ """
119
+ session_id: str = ""
120
+ approved_verbs: List[str] = field(default_factory=list)
121
+ approved_scope: str = ""
122
+ scope_type: str = SCOPE_SEMANTIC_SIGNATURE
123
+ scope_signature: Optional[dict] = None
124
+ granted_at: float = 0.0
125
+ ttl_minutes: int = DEFAULT_GRANT_TTL_MINUTES
126
+ used: bool = False
127
+ confirmed: bool = False
128
+
129
+ def is_expired(self) -> bool:
130
+ """Check if the grant has expired."""
131
+ return _is_ttl_expired(self.granted_at, self.ttl_minutes)
132
+
133
+ def is_valid(self) -> bool:
134
+ """Check if the grant is still usable."""
135
+ return not self.is_expired() and not self.used
136
+
137
+ def get_signature(self) -> Optional[ApprovalSignature]:
138
+ """Deserialize the persisted scope signature, if present."""
139
+ if not self.scope_signature:
140
+ return None
141
+ try:
142
+ return ApprovalSignature.from_dict(self.scope_signature)
143
+ except Exception:
144
+ return None
145
+
146
+ def matches_command(self, command: str) -> bool:
147
+ """Check whether a command falls inside this grant's explicit scope."""
148
+ signature = self.get_signature()
149
+ if signature is None:
150
+ return False
151
+ return matches_approval_signature(signature, command)
152
+
153
+
154
+ _grants_dir_created: bool = False
155
+
156
+ # Module-level flag: set by check_approval_grant() when it encounters and
157
+ # cleans up an expired grant for the requested command. Callers (e.g.
158
+ # bash_validator) can read this via last_check_found_expired() to emit a
159
+ # clear expiry message instead of a generic "no grant found" block.
160
+ _last_check_found_expired: bool = False
161
+
162
+
163
+ def last_check_found_expired() -> bool:
164
+ """Return True if the most recent check_approval_grant() call cleaned up
165
+ an expired grant that would have matched the command."""
166
+ return _last_check_found_expired
167
+
168
+
169
+ def _get_grants_dir() -> Path:
170
+ """Get the directory for approval grant files."""
171
+ global _grants_dir_created
172
+ grants_dir = get_plugin_data_dir() / "cache" / "approvals"
173
+ if not _grants_dir_created:
174
+ grants_dir.mkdir(parents=True, exist_ok=True)
175
+ _grants_dir_created = True
176
+ return grants_dir
177
+
178
+
179
+ def _get_pending_index_path(session_id: str) -> Path:
180
+ """Return the session-scoped pending-approval index path."""
181
+ return _get_grants_dir() / f"pending-index-{session_id}.json"
182
+
183
+
184
+ def _read_json_file(path: Path) -> Optional[Dict[str, Any]]:
185
+ """Read a JSON file defensively and return its dict payload."""
186
+ try:
187
+ return json.loads(path.read_text())
188
+ except Exception:
189
+ return None
190
+
191
+
192
+ def _rebuild_pending_index(session_id: str) -> None:
193
+ """Rebuild the per-session pending-approval index from authoritative files."""
194
+ index_path = _get_pending_index_path(session_id)
195
+ entries: List[Dict[str, Any]] = []
196
+
197
+ for pending_file in _get_grants_dir().glob("pending-*.json"):
198
+ if pending_file.name.startswith("pending-index-"):
199
+ continue
200
+ data = _read_json_file(pending_file)
201
+ if not data or data.get("session_id") != session_id:
202
+ continue
203
+
204
+ nonce = data.get("nonce")
205
+ timestamp = data.get("timestamp")
206
+ if not nonce or not isinstance(timestamp, (int, float)):
207
+ continue
208
+ ttl_minutes = data.get("ttl_minutes", DEFAULT_GRANT_TTL_MINUTES)
209
+ if _is_ttl_expired(float(timestamp), int(ttl_minutes)):
210
+ continue
211
+
212
+ entries.append(
213
+ {
214
+ "nonce": nonce,
215
+ "pending_file": pending_file.name,
216
+ "timestamp": float(timestamp),
217
+ }
218
+ )
219
+
220
+ entries.sort(key=lambda item: item["timestamp"], reverse=True)
221
+
222
+ if not entries:
223
+ index_path.unlink(missing_ok=True)
224
+ return
225
+
226
+ index_payload = {
227
+ "session_id": session_id,
228
+ "latest_nonce": entries[0]["nonce"],
229
+ "entries": entries,
230
+ }
231
+ index_path.write_text(json.dumps(index_payload, indent=2))
232
+
233
+
234
+ def _get_session_id() -> str:
235
+ """Get the current session ID. Delegates to core.state.get_session_id()."""
236
+ return get_session_id()
237
+
238
+
239
+ def get_latest_pending_approval(session_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
240
+ """Return the newest pending approval record for the current session.
241
+
242
+ This is a deterministic helper for future orchestrator logic: it reads the
243
+ session index, then dereferences the authoritative pending file instead of
244
+ asking callers to parse a nonce from agent text.
245
+ """
246
+ if session_id is None:
247
+ session_id = _get_session_id()
248
+
249
+ index_path = _get_pending_index_path(session_id)
250
+
251
+ for attempt in range(2):
252
+ if not index_path.exists():
253
+ return None
254
+
255
+ index_data = _read_json_file(index_path)
256
+ if not index_data:
257
+ _rebuild_pending_index(session_id)
258
+ continue
259
+
260
+ latest_nonce = index_data.get("latest_nonce")
261
+ entries = index_data.get("entries") or []
262
+ pending_ref = next((entry for entry in entries if entry.get("nonce") == latest_nonce), None)
263
+ if not latest_nonce or pending_ref is None:
264
+ _rebuild_pending_index(session_id)
265
+ continue
266
+
267
+ pending_path = _get_grants_dir() / pending_ref.get("pending_file", "")
268
+ pending_data = _read_json_file(pending_path)
269
+ if not pending_data or pending_data.get("session_id") != session_id:
270
+ _rebuild_pending_index(session_id)
271
+ continue
272
+
273
+ return pending_data
274
+
275
+ return None
276
+
277
+
278
+ # ============================================================================
279
+ # Nonce Generation and Pending Approval Management
280
+ # ============================================================================
281
+
282
+ def generate_nonce() -> str:
283
+ """Generate a cryptographic nonce for approval tracking.
284
+
285
+ Returns:
286
+ 32-character hex string (128 bits of entropy).
287
+ """
288
+ return secrets.token_hex(16)
289
+
290
+
291
+ def write_pending_approval(
292
+ nonce: str,
293
+ command: str,
294
+ danger_verb: str,
295
+ danger_category: str,
296
+ session_id: Optional[str] = None,
297
+ ttl_minutes: int = DEFAULT_GRANT_TTL_MINUTES,
298
+ ) -> Optional[Path]:
299
+ """Write a pending approval file when a T3 command is blocked.
300
+
301
+ Called by bash_validator when it detects a dangerous command and blocks it.
302
+ The nonce is included in the block response so the agent can present it
303
+ to the user for approval.
304
+
305
+ Args:
306
+ nonce: Cryptographic nonce from generate_nonce().
307
+ command: The command that was blocked.
308
+ danger_verb: The dangerous verb detected (e.g., "commit", "apply").
309
+ danger_category: The danger category (e.g., "MUTATIVE", "DESTRUCTIVE").
310
+ session_id: Session ID (defaults to CLAUDE_SESSION_ID env var).
311
+ ttl_minutes: How long the pending approval is valid before expiry.
312
+
313
+ Returns:
314
+ Path to the pending file, or None on failure.
315
+ """
316
+ if session_id is None:
317
+ session_id = _get_session_id()
318
+
319
+ signature = build_approval_signature(
320
+ command,
321
+ scope_type=SCOPE_SEMANTIC_SIGNATURE,
322
+ danger_verb=danger_verb,
323
+ danger_category=danger_category,
324
+ )
325
+ if signature is None:
326
+ logger.error(
327
+ "Failed to build semantic approval signature for pending command: %s",
328
+ command,
329
+ )
330
+ return None
331
+
332
+ pending_data = {
333
+ "nonce": nonce,
334
+ "session_id": session_id,
335
+ "command": command,
336
+ "danger_verb": danger_verb,
337
+ "danger_category": danger_category,
338
+ "scope_type": signature.scope_type,
339
+ "scope_signature": signature.to_dict(),
340
+ "timestamp": time.time(),
341
+ "ttl_minutes": ttl_minutes,
342
+ }
343
+
344
+ try:
345
+ grants_dir = _get_grants_dir()
346
+ pending_file = grants_dir / f"pending-{nonce}.json"
347
+ pending_file.write_text(json.dumps(pending_data, indent=2))
348
+ _rebuild_pending_index(session_id)
349
+
350
+ logger.info(
351
+ "Pending approval written: nonce=%s, verb=%s, category=%s, session=%s",
352
+ nonce, danger_verb, danger_category, session_id,
353
+ )
354
+ return pending_file
355
+
356
+ except Exception as e:
357
+ logger.error("Failed to write pending approval: %s", e)
358
+ return None
359
+
360
+
361
+ def activate_pending_approval(
362
+ nonce: str,
363
+ session_id: Optional[str] = None,
364
+ ttl_minutes: int = DEFAULT_GRANT_TTL_MINUTES,
365
+ ) -> ApprovalActivationResult:
366
+ """Activate a pending approval by converting it to an active grant.
367
+
368
+ Called by the pre_tool_use hook when it detects "APPROVE:{nonce}" in a
369
+ Task resume prompt. Validates the pending file, creates an active grant,
370
+ and deletes the pending file.
371
+
372
+ Args:
373
+ nonce: The nonce from the APPROVE: token.
374
+ session_id: Current session ID for validation.
375
+ ttl_minutes: TTL for the active grant.
376
+
377
+ Returns:
378
+ Structured activation result with status and optional grant path.
379
+ """
380
+ if session_id is None:
381
+ session_id = _get_session_id()
382
+
383
+ try:
384
+ grants_dir = _get_grants_dir()
385
+ pending_file = grants_dir / f"pending-{nonce}.json"
386
+
387
+ # Pending file must exist
388
+ if not pending_file.exists():
389
+ logger.warning(
390
+ "Pending approval not found for nonce %s -- "
391
+ "may have expired or already been activated",
392
+ nonce,
393
+ )
394
+ return ApprovalActivationResult(
395
+ success=False,
396
+ status=ACTIVATION_NOT_FOUND,
397
+ reason="Pending approval not found. It may have expired or already been used.",
398
+ )
399
+
400
+ # Read and validate pending data
401
+ pending_data = json.loads(pending_file.read_text())
402
+
403
+ # Validate nonce matches exactly
404
+ if pending_data.get("nonce") != nonce:
405
+ logger.warning("Nonce mismatch in pending file: expected %s", nonce)
406
+ return ApprovalActivationResult(
407
+ success=False,
408
+ status=ACTIVATION_NONCE_MISMATCH,
409
+ reason="Nonce mismatch while activating approval.",
410
+ )
411
+
412
+ # Validate session matches
413
+ if pending_data.get("session_id") != session_id:
414
+ logger.warning(
415
+ "Session mismatch for nonce %s: pending=%s, current=%s",
416
+ nonce, pending_data.get("session_id"), session_id,
417
+ )
418
+ return ApprovalActivationResult(
419
+ success=False,
420
+ status=ACTIVATION_SESSION_MISMATCH,
421
+ reason="Approval was issued for a different Claude session.",
422
+ )
423
+
424
+ # Validate not expired
425
+ pending_timestamp = pending_data.get("timestamp", 0)
426
+ pending_ttl = pending_data.get("ttl_minutes", DEFAULT_GRANT_TTL_MINUTES)
427
+ if _is_ttl_expired(pending_timestamp, pending_ttl):
428
+ logger.warning(
429
+ "Pending approval expired for nonce %s: TTL=%d min",
430
+ nonce, pending_ttl,
431
+ )
432
+ # Clean up expired pending file
433
+ _cleanup_grant(pending_file)
434
+ _rebuild_pending_index(session_id)
435
+ return ApprovalActivationResult(
436
+ success=False,
437
+ status=ACTIVATION_EXPIRED,
438
+ reason="Approval nonce expired before activation.",
439
+ )
440
+
441
+ command = pending_data.get("command", "")
442
+ danger_verb = pending_data.get("danger_verb", "")
443
+ scope_signature_data = pending_data.get("scope_signature")
444
+ if not scope_signature_data:
445
+ logger.warning("Pending approval for nonce %s is missing scope_signature", nonce)
446
+ _cleanup_grant(pending_file)
447
+ _rebuild_pending_index(session_id)
448
+ return ApprovalActivationResult(
449
+ success=False,
450
+ status=ACTIVATION_INVALID_PENDING,
451
+ reason="Pending approval file is missing a semantic signature.",
452
+ )
453
+
454
+ signature = ApprovalSignature.from_dict(scope_signature_data)
455
+ if signature.scope_type != SCOPE_SEMANTIC_SIGNATURE:
456
+ logger.warning(
457
+ "Pending approval for nonce %s has unsupported scope_type=%s",
458
+ nonce,
459
+ signature.scope_type,
460
+ )
461
+ _cleanup_grant(pending_file)
462
+ _rebuild_pending_index(session_id)
463
+ return ApprovalActivationResult(
464
+ success=False,
465
+ status=ACTIVATION_INVALID_SIGNATURE,
466
+ reason="Pending approval uses an unsupported scope type.",
467
+ )
468
+
469
+ if not signature.verb and not danger_verb:
470
+ logger.warning(
471
+ "Could not validate semantic signature for pending approval command: %s",
472
+ command,
473
+ )
474
+ return ApprovalActivationResult(
475
+ success=False,
476
+ status=ACTIVATION_INVALID_SIGNATURE,
477
+ reason="Approval signature could not be validated safely.",
478
+ )
479
+
480
+ verbs = [signature.verb] if signature.verb else ([danger_verb.lower()] if danger_verb else [])
481
+
482
+ # Create active grant
483
+ grant = ApprovalGrant(
484
+ session_id=session_id,
485
+ approved_verbs=verbs,
486
+ approved_scope=command,
487
+ scope_type=signature.scope_type,
488
+ scope_signature=signature.to_dict(),
489
+ granted_at=time.time(),
490
+ ttl_minutes=ttl_minutes,
491
+ )
492
+
493
+ grant_file = grants_dir / f"grant-{session_id}-{int(time.time() * 1000)}.json"
494
+ grant_file.write_text(json.dumps(asdict(grant), indent=2))
495
+
496
+ # Delete pending file (one-time activation)
497
+ _cleanup_grant(pending_file)
498
+ _rebuild_pending_index(session_id)
499
+
500
+ logger.info(
501
+ "Pending approval activated: nonce=%s, verbs=%s, grant=%s",
502
+ nonce, verbs, grant_file.name,
503
+ )
504
+ return ApprovalActivationResult(
505
+ success=True,
506
+ status=ACTIVATION_ACTIVATED,
507
+ reason="Pending approval activated.",
508
+ grant_path=grant_file,
509
+ )
510
+
511
+ except (json.JSONDecodeError, TypeError) as e:
512
+ logger.error("Invalid pending approval file for nonce %s: %s", nonce, e)
513
+ return ApprovalActivationResult(
514
+ success=False,
515
+ status=ACTIVATION_INVALID_PENDING,
516
+ reason="Pending approval file is invalid or corrupt.",
517
+ )
518
+ except Exception as e:
519
+ logger.error("Failed to activate pending approval: %s", e)
520
+ return ApprovalActivationResult(
521
+ success=False,
522
+ status=ACTIVATION_ERROR,
523
+ reason="Unexpected error while activating approval.",
524
+ )
525
+
526
+ def check_approval_grant(command: str, session_id: str = None) -> Optional[ApprovalGrant]:
527
+ """Check if there is an active approval grant for a command.
528
+
529
+ Called by the bash_validator before blocking a dangerous command.
530
+ If a valid grant exists that matches the command, the command should
531
+ be allowed through.
532
+
533
+ Args:
534
+ command: The shell command to check.
535
+ session_id: Session ID for grant scoping (defaults to env var).
536
+
537
+ Returns:
538
+ The matching ApprovalGrant if found and valid, None otherwise.
539
+ """
540
+ global _last_check_found_expired
541
+ _last_check_found_expired = False
542
+
543
+ if not session_id:
544
+ session_id = _get_session_id()
545
+
546
+ try:
547
+ grants_dir = _get_grants_dir()
548
+ if not grants_dir.exists():
549
+ return None
550
+
551
+ # Scan grant files for this session
552
+ for grant_file in sorted(grants_dir.glob(f"grant-{session_id}-*.json")):
553
+ try:
554
+ data = json.loads(grant_file.read_text())
555
+ grant = ApprovalGrant(**data)
556
+
557
+ # Skip expired or used grants
558
+ if not grant.is_valid():
559
+ # Clean up expired grants; track if it would have matched
560
+ if grant.is_expired():
561
+ if grant.matches_command(command):
562
+ _last_check_found_expired = True
563
+ _cleanup_grant(grant_file)
564
+ continue
565
+
566
+ signature = grant.get_signature()
567
+ if signature is None or signature.scope_type not in SUPPORTED_SCOPE_TYPES:
568
+ logger.warning("Removing unsupported approval grant file %s", grant_file)
569
+ _cleanup_grant(grant_file)
570
+ continue
571
+
572
+ # Check if command matches the explicit scope signature
573
+ if grant.matches_command(command):
574
+ logger.info(
575
+ "Approval grant matched: command='%s', scope='%s', type=%s",
576
+ command[:80], grant.approved_scope, grant.scope_type,
577
+ )
578
+ return grant
579
+
580
+ except (json.JSONDecodeError, TypeError) as e:
581
+ logger.warning("Invalid grant file %s: %s", grant_file, e)
582
+ _cleanup_grant(grant_file)
583
+ continue
584
+
585
+ except Exception as e:
586
+ logger.error("Error checking approval grants: %s", e)
587
+
588
+ return None
589
+
590
+
591
+ def consume_grant(command: str, session_id: str = None) -> bool:
592
+ """Mark the first matching valid grant as used and persist to disk.
593
+
594
+ Called by bash_validator immediately after check_approval_grant() returns
595
+ a match, so that the grant can only be used once (single-use).
596
+
597
+ Args:
598
+ command: The shell command whose grant should be consumed.
599
+ session_id: Session ID for grant scoping (defaults to env var).
600
+
601
+ Returns:
602
+ True if a grant was found and consumed, False otherwise.
603
+ """
604
+ if not session_id:
605
+ session_id = _get_session_id()
606
+
607
+ try:
608
+ grants_dir = _get_grants_dir()
609
+ if not grants_dir.exists():
610
+ return False
611
+
612
+ for grant_file in sorted(grants_dir.glob(f"grant-{session_id}-*.json")):
613
+ try:
614
+ data = json.loads(grant_file.read_text())
615
+ grant = ApprovalGrant(**data)
616
+
617
+ if not grant.is_valid():
618
+ if grant.is_expired():
619
+ _cleanup_grant(grant_file)
620
+ continue
621
+
622
+ signature = grant.get_signature()
623
+ if signature is None or signature.scope_type not in SUPPORTED_SCOPE_TYPES:
624
+ continue
625
+
626
+ if grant.matches_command(command):
627
+ data["used"] = True
628
+ grant_file.write_text(json.dumps(data, indent=2))
629
+ logger.info(
630
+ "Grant consumed (single-use): command='%s', grant=%s",
631
+ command[:80], grant_file.name,
632
+ )
633
+ return True
634
+
635
+ except (json.JSONDecodeError, TypeError):
636
+ continue
637
+
638
+ except Exception as e:
639
+ logger.error("Error consuming grant: %s", e)
640
+
641
+ return False
642
+
643
+
644
+ def confirm_grant(command: str, session_id: str = None) -> bool:
645
+ """Mark the first unconfirmed grant matching command as confirmed.
646
+
647
+ Called after the native permission dialog accepts the first T3 execution.
648
+ Subsequent T3 commands within the TTL window will see ``confirmed=True``
649
+ and be auto-allowed without a native dialog.
650
+
651
+ Args:
652
+ command: The shell command whose grant should be confirmed.
653
+ session_id: Session ID for grant scoping (defaults to env var).
654
+
655
+ Returns:
656
+ True if a grant was found and confirmed, False otherwise.
657
+ """
658
+ if not session_id:
659
+ session_id = _get_session_id()
660
+
661
+ try:
662
+ grants_dir = _get_grants_dir()
663
+ if not grants_dir.exists():
664
+ return False
665
+
666
+ for grant_file in sorted(grants_dir.glob(f"grant-{session_id}-*.json")):
667
+ try:
668
+ data = json.loads(grant_file.read_text())
669
+ grant = ApprovalGrant(**data)
670
+
671
+ if not grant.is_valid():
672
+ if grant.is_expired():
673
+ _cleanup_grant(grant_file)
674
+ continue
675
+
676
+ if grant.confirmed:
677
+ continue
678
+
679
+ signature = grant.get_signature()
680
+ if signature is None or signature.scope_type not in SUPPORTED_SCOPE_TYPES:
681
+ continue
682
+
683
+ if grant.matches_command(command):
684
+ data["confirmed"] = True
685
+ grant_file.write_text(json.dumps(data, indent=2))
686
+ logger.info(
687
+ "Grant confirmed: command='%s', grant=%s",
688
+ command[:80], grant_file.name,
689
+ )
690
+ return True
691
+
692
+ except (json.JSONDecodeError, TypeError):
693
+ continue
694
+
695
+ except Exception as e:
696
+ logger.error("Error confirming grant: %s", e)
697
+
698
+ return False
699
+
700
+
701
+ def cleanup_expired_grants() -> int:
702
+ """Remove expired grant and pending files.
703
+
704
+ Called periodically (e.g., at hook startup) to prevent accumulation.
705
+ Throttled to run at most once every _CLEANUP_INTERVAL_SECONDS.
706
+
707
+ Returns:
708
+ Number of files cleaned up.
709
+ """
710
+ global _last_cleanup_time
711
+ now = time.time()
712
+ if now - _last_cleanup_time < _CLEANUP_INTERVAL_SECONDS:
713
+ return 0
714
+ _last_cleanup_time = now
715
+
716
+ cleaned = 0
717
+ sessions_to_rebuild: set[str] = set()
718
+ try:
719
+ grants_dir = _get_grants_dir()
720
+ if not grants_dir.exists():
721
+ return 0
722
+
723
+ # Clean up expired active grants
724
+ for grant_file in grants_dir.glob("grant-*.json"):
725
+ try:
726
+ data = json.loads(grant_file.read_text())
727
+ grant = ApprovalGrant(**data)
728
+ signature = grant.get_signature()
729
+ if signature is None or signature.scope_type not in SUPPORTED_SCOPE_TYPES:
730
+ _cleanup_grant(grant_file)
731
+ cleaned += 1
732
+ continue
733
+ if grant.is_expired():
734
+ _cleanup_grant(grant_file)
735
+ cleaned += 1
736
+ except Exception:
737
+ # Corrupt file, remove it
738
+ _cleanup_grant(grant_file)
739
+ cleaned += 1
740
+
741
+ # Clean up expired pending approvals
742
+ for pending_file in grants_dir.glob("pending-*.json"):
743
+ if pending_file.name.startswith("pending-index-"):
744
+ continue
745
+ try:
746
+ data = json.loads(pending_file.read_text())
747
+ session_id = data.get("session_id")
748
+ if not data.get("scope_signature"):
749
+ _cleanup_grant(pending_file)
750
+ if session_id:
751
+ sessions_to_rebuild.add(session_id)
752
+ cleaned += 1
753
+ continue
754
+ timestamp = data.get("timestamp", 0)
755
+ ttl = data.get("ttl_minutes", DEFAULT_GRANT_TTL_MINUTES)
756
+ if _is_ttl_expired(timestamp, ttl):
757
+ _cleanup_grant(pending_file)
758
+ if session_id:
759
+ sessions_to_rebuild.add(session_id)
760
+ cleaned += 1
761
+ except Exception:
762
+ # Corrupt file, remove it
763
+ data = _read_json_file(pending_file)
764
+ if data and data.get("session_id"):
765
+ sessions_to_rebuild.add(data["session_id"])
766
+ _cleanup_grant(pending_file)
767
+ cleaned += 1
768
+
769
+ except Exception as e:
770
+ logger.error("Error during grant cleanup: %s", e)
771
+
772
+ for session_id in sessions_to_rebuild:
773
+ _rebuild_pending_index(session_id)
774
+
775
+ if cleaned:
776
+ logger.info("Cleaned up %d expired approval/pending files", cleaned)
777
+ return cleaned
778
+
779
+
780
+ def get_pending_approvals_for_session(
781
+ session_id: Optional[str] = None,
782
+ ) -> List[Dict[str, Any]]:
783
+ """Return all non-expired pending approvals for a session.
784
+
785
+ Args:
786
+ session_id: Session ID to filter by (defaults to current session).
787
+
788
+ Returns:
789
+ List of pending approval dicts, newest first.
790
+ """
791
+ if session_id is None:
792
+ session_id = _get_session_id()
793
+
794
+ results: List[Dict[str, Any]] = []
795
+ try:
796
+ grants_dir = _get_grants_dir()
797
+ for pending_file in grants_dir.glob("pending-*.json"):
798
+ if pending_file.name.startswith("pending-index-"):
799
+ continue
800
+ data = _read_json_file(pending_file)
801
+ if not data or data.get("session_id") != session_id:
802
+ continue
803
+ timestamp = data.get("timestamp", 0)
804
+ ttl = data.get("ttl_minutes", DEFAULT_GRANT_TTL_MINUTES)
805
+ if _is_ttl_expired(float(timestamp), int(ttl)):
806
+ continue
807
+ results.append(data)
808
+ except Exception as e:
809
+ logger.error("Error listing pending approvals for session %s: %s", session_id, e)
810
+
811
+ results.sort(key=lambda d: d.get("timestamp", 0), reverse=True)
812
+ return results
813
+
814
+
815
+ def find_pending_for_command(
816
+ session_id: str,
817
+ command: str,
818
+ ) -> Optional[str]:
819
+ """Find an existing pending approval nonce for this command and session.
820
+
821
+ When a subagent retries a blocked T3 command, a pending approval may
822
+ already exist from the first attempt. Reusing the existing nonce
823
+ prevents the infinite-loop of generating a new approval_id on every
824
+ retry while the user is still reviewing the first one.
825
+
826
+ Args:
827
+ session_id: Session to search.
828
+ command: The command to match against pending approvals.
829
+
830
+ Returns:
831
+ The nonce (approval_id) if a matching pending approval exists, else None.
832
+ """
833
+ pending_list = get_pending_approvals_for_session(session_id)
834
+ if not pending_list:
835
+ return None
836
+
837
+ # Build a signature for the incoming command to compare semantically
838
+ target_sig = build_approval_signature(
839
+ command,
840
+ scope_type=SCOPE_SEMANTIC_SIGNATURE,
841
+ )
842
+ if target_sig is None:
843
+ return None
844
+
845
+ for pending_data in pending_list:
846
+ pending_sig_data = pending_data.get("scope_signature")
847
+ if not pending_sig_data:
848
+ continue
849
+ try:
850
+ pending_sig = ApprovalSignature.from_dict(pending_sig_data)
851
+ if matches_approval_signature(pending_sig, command):
852
+ nonce = pending_data.get("nonce")
853
+ if nonce:
854
+ logger.info(
855
+ "Reusing existing pending approval nonce=%s for command: %s",
856
+ nonce, command[:80],
857
+ )
858
+ return nonce
859
+ except Exception:
860
+ continue
861
+
862
+ return None
863
+
864
+
865
+ def activate_grants_for_session(
866
+ session_id: Optional[str] = None,
867
+ ttl_minutes: int = DEFAULT_GRANT_TTL_MINUTES,
868
+ ) -> List[ApprovalActivationResult]:
869
+ """Activate ALL pending approvals for a session.
870
+
871
+ Called by the ElicitationResult hook when the user approves via
872
+ AskUserQuestion. Converts every non-expired pending approval for the
873
+ session into an active grant.
874
+
875
+ Args:
876
+ session_id: Session to activate for (defaults to current session).
877
+ ttl_minutes: TTL for the resulting active grants.
878
+
879
+ Returns:
880
+ List of activation results (one per pending approval).
881
+ """
882
+ if session_id is None:
883
+ session_id = _get_session_id()
884
+
885
+ pending_list = get_pending_approvals_for_session(session_id)
886
+ results: List[ApprovalActivationResult] = []
887
+
888
+ for pending_data in pending_list:
889
+ nonce = pending_data.get("nonce", "")
890
+ if not nonce:
891
+ continue
892
+ result = activate_pending_approval(
893
+ nonce=nonce,
894
+ session_id=session_id,
895
+ ttl_minutes=ttl_minutes,
896
+ )
897
+ results.append(result)
898
+ logger.info(
899
+ "Session-wide activation: nonce=%s status=%s",
900
+ nonce,
901
+ getattr(result.status, "value", str(result.status)),
902
+ )
903
+
904
+ return results
905
+
906
+
907
+ def _cleanup_grant(grant_file: Path) -> None:
908
+ """Remove a single grant or pending file."""
909
+ try:
910
+ grant_file.unlink(missing_ok=True)
911
+ except Exception as e:
912
+ logger.warning("Failed to remove grant file %s: %s", grant_file, e)