@jaguilar87/gaia 5.0.0-rc.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 (621) hide show
  1. package/.claude-plugin/marketplace.json +33 -0
  2. package/.claude-plugin/plugin.json +26 -0
  3. package/ARCHITECTURE.md +335 -0
  4. package/CHANGELOG.md +1298 -0
  5. package/CODE_OF_CONDUCT.md +11 -0
  6. package/CONTRIBUTING.md +146 -0
  7. package/INSTALL.md +436 -0
  8. package/LICENSE +21 -0
  9. package/README.md +222 -0
  10. package/SECURITY.md +47 -0
  11. package/agents/README.md +78 -0
  12. package/agents/cloud-troubleshooter.md +73 -0
  13. package/agents/developer.md +65 -0
  14. package/agents/gaia-operator.md +64 -0
  15. package/agents/gaia-orchestrator.md +111 -0
  16. package/agents/gaia-planner.md +53 -0
  17. package/agents/gaia-system.md +71 -0
  18. package/agents/gitops-operator.md +61 -0
  19. package/agents/terraform-architect.md +63 -0
  20. package/bin/README.md +106 -0
  21. package/bin/cli/__init__.py +1 -0
  22. package/bin/cli/approvals.py +740 -0
  23. package/bin/cli/cleanup.py +562 -0
  24. package/bin/cli/context.py +283 -0
  25. package/bin/cli/doctor.py +651 -0
  26. package/bin/cli/history.py +305 -0
  27. package/bin/cli/memory.py +483 -0
  28. package/bin/cli/metrics.py +1068 -0
  29. package/bin/cli/plans.py +515 -0
  30. package/bin/cli/status.py +302 -0
  31. package/bin/cli/update.py +382 -0
  32. package/bin/gaia +112 -0
  33. package/bin/gaia-cleanup.js +531 -0
  34. package/bin/gaia-doctor.js +635 -0
  35. package/bin/gaia-evidence +126 -0
  36. package/bin/gaia-history.js +251 -0
  37. package/bin/gaia-metrics.js +1278 -0
  38. package/bin/gaia-review.js +269 -0
  39. package/bin/gaia-scan +44 -0
  40. package/bin/gaia-scan.py +589 -0
  41. package/bin/gaia-skills-diagnose.js +929 -0
  42. package/bin/gaia-status.js +278 -0
  43. package/bin/gaia-uninstall.js +111 -0
  44. package/bin/gaia-update.js +919 -0
  45. package/bin/pre-publish-validate.js +610 -0
  46. package/bin/python-detect.js +60 -0
  47. package/bin/validate-sandbox.sh +601 -0
  48. package/commands/README.md +64 -0
  49. package/commands/gaia.md +37 -0
  50. package/commands/scan-project.md +67 -0
  51. package/config/README.md +71 -0
  52. package/config/cloud/aws.json +134 -0
  53. package/config/cloud/gcp.json +139 -0
  54. package/config/context-contracts.json +158 -0
  55. package/config/crons-schema.md +81 -0
  56. package/config/git_standards.json +72 -0
  57. package/config/surface-routing.json +417 -0
  58. package/config/universal-rules.json +102 -0
  59. package/dist/gaia-ops/.claude-plugin/plugin.json +24 -0
  60. package/dist/gaia-ops/README.md +80 -0
  61. package/dist/gaia-ops/agents/cloud-troubleshooter.md +73 -0
  62. package/dist/gaia-ops/agents/developer.md +65 -0
  63. package/dist/gaia-ops/agents/gaia-operator.md +64 -0
  64. package/dist/gaia-ops/agents/gaia-orchestrator.md +111 -0
  65. package/dist/gaia-ops/agents/gaia-planner.md +53 -0
  66. package/dist/gaia-ops/agents/gaia-system.md +71 -0
  67. package/dist/gaia-ops/agents/gitops-operator.md +61 -0
  68. package/dist/gaia-ops/agents/terraform-architect.md +63 -0
  69. package/dist/gaia-ops/commands/gaia.md +37 -0
  70. package/dist/gaia-ops/config/README.md +71 -0
  71. package/dist/gaia-ops/config/cloud/aws.json +134 -0
  72. package/dist/gaia-ops/config/cloud/gcp.json +139 -0
  73. package/dist/gaia-ops/config/context-contracts.json +158 -0
  74. package/dist/gaia-ops/config/crons-schema.md +81 -0
  75. package/dist/gaia-ops/config/git_standards.json +72 -0
  76. package/dist/gaia-ops/config/surface-routing.json +417 -0
  77. package/dist/gaia-ops/config/universal-rules.json +102 -0
  78. package/dist/gaia-ops/hooks/adapters/__init__.py +52 -0
  79. package/dist/gaia-ops/hooks/adapters/base.py +219 -0
  80. package/dist/gaia-ops/hooks/adapters/channel.py +17 -0
  81. package/dist/gaia-ops/hooks/adapters/claude_code.py +1890 -0
  82. package/dist/gaia-ops/hooks/adapters/types.py +194 -0
  83. package/dist/gaia-ops/hooks/adapters/utils.py +25 -0
  84. package/dist/gaia-ops/hooks/hooks.json +192 -0
  85. package/dist/gaia-ops/hooks/modules/__init__.py +15 -0
  86. package/dist/gaia-ops/hooks/modules/agents/__init__.py +29 -0
  87. package/dist/gaia-ops/hooks/modules/agents/contract_validator.py +647 -0
  88. package/dist/gaia-ops/hooks/modules/agents/response_contract.py +496 -0
  89. package/dist/gaia-ops/hooks/modules/agents/skill_injection_verifier.py +120 -0
  90. package/dist/gaia-ops/hooks/modules/agents/state_tracker.py +267 -0
  91. package/dist/gaia-ops/hooks/modules/agents/task_info_builder.py +74 -0
  92. package/dist/gaia-ops/hooks/modules/agents/transcript_analyzer.py +458 -0
  93. package/dist/gaia-ops/hooks/modules/agents/transcript_reader.py +152 -0
  94. package/dist/gaia-ops/hooks/modules/audit/__init__.py +28 -0
  95. package/dist/gaia-ops/hooks/modules/audit/event_detector.py +168 -0
  96. package/dist/gaia-ops/hooks/modules/audit/logger.py +131 -0
  97. package/dist/gaia-ops/hooks/modules/audit/metrics.py +134 -0
  98. package/dist/gaia-ops/hooks/modules/audit/workflow_auditor.py +611 -0
  99. package/dist/gaia-ops/hooks/modules/audit/workflow_recorder.py +296 -0
  100. package/dist/gaia-ops/hooks/modules/context/__init__.py +11 -0
  101. package/dist/gaia-ops/hooks/modules/context/agentic_loop_detector.py +165 -0
  102. package/dist/gaia-ops/hooks/modules/context/anchor_tracker.py +317 -0
  103. package/dist/gaia-ops/hooks/modules/context/compact_context_builder.py +218 -0
  104. package/dist/gaia-ops/hooks/modules/context/context_freshness.py +145 -0
  105. package/dist/gaia-ops/hooks/modules/context/context_injector.py +558 -0
  106. package/dist/gaia-ops/hooks/modules/context/context_writer.py +530 -0
  107. package/dist/gaia-ops/hooks/modules/context/contracts_loader.py +161 -0
  108. package/dist/gaia-ops/hooks/modules/core/__init__.py +40 -0
  109. package/dist/gaia-ops/hooks/modules/core/hook_entry.py +78 -0
  110. package/dist/gaia-ops/hooks/modules/core/paths.py +160 -0
  111. package/dist/gaia-ops/hooks/modules/core/plugin_mode.py +149 -0
  112. package/dist/gaia-ops/hooks/modules/core/plugin_setup.py +577 -0
  113. package/dist/gaia-ops/hooks/modules/core/state.py +179 -0
  114. package/dist/gaia-ops/hooks/modules/core/stdin.py +24 -0
  115. package/dist/gaia-ops/hooks/modules/events/__init__.py +1 -0
  116. package/dist/gaia-ops/hooks/modules/events/event_writer.py +210 -0
  117. package/dist/gaia-ops/hooks/modules/memory/__init__.py +8 -0
  118. package/dist/gaia-ops/hooks/modules/memory/episode_writer.py +216 -0
  119. package/dist/gaia-ops/hooks/modules/orchestrator/__init__.py +1 -0
  120. package/dist/gaia-ops/hooks/modules/orchestrator/delegate_mode.py +122 -0
  121. package/dist/gaia-ops/hooks/modules/scanning/__init__.py +8 -0
  122. package/dist/gaia-ops/hooks/modules/scanning/scan_trigger.py +84 -0
  123. package/dist/gaia-ops/hooks/modules/security/__init__.py +120 -0
  124. package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +87 -0
  125. package/dist/gaia-ops/hooks/modules/security/approval_constants.py +23 -0
  126. package/dist/gaia-ops/hooks/modules/security/approval_grants.py +1638 -0
  127. package/dist/gaia-ops/hooks/modules/security/approval_messages.py +71 -0
  128. package/dist/gaia-ops/hooks/modules/security/approval_scopes.py +222 -0
  129. package/dist/gaia-ops/hooks/modules/security/blocked_commands.py +595 -0
  130. package/dist/gaia-ops/hooks/modules/security/blocked_message_formatter.py +87 -0
  131. package/dist/gaia-ops/hooks/modules/security/command_semantics.py +181 -0
  132. package/dist/gaia-ops/hooks/modules/security/composition_rules.py +547 -0
  133. package/dist/gaia-ops/hooks/modules/security/flag_classifiers.py +873 -0
  134. package/dist/gaia-ops/hooks/modules/security/gitops_validator.py +179 -0
  135. package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +1131 -0
  136. package/dist/gaia-ops/hooks/modules/security/network_hosts.py +481 -0
  137. package/dist/gaia-ops/hooks/modules/security/prompt_validator.py +40 -0
  138. package/dist/gaia-ops/hooks/modules/security/shell_unwrapper.py +165 -0
  139. package/dist/gaia-ops/hooks/modules/security/tiers.py +196 -0
  140. package/dist/gaia-ops/hooks/modules/session/__init__.py +10 -0
  141. package/dist/gaia-ops/hooks/modules/session/pending_scanner.py +174 -0
  142. package/dist/gaia-ops/hooks/modules/session/session_context_writer.py +100 -0
  143. package/dist/gaia-ops/hooks/modules/session/session_event_injector.py +160 -0
  144. package/dist/gaia-ops/hooks/modules/session/session_manager.py +31 -0
  145. package/dist/gaia-ops/hooks/modules/session/session_registry.py +333 -0
  146. package/dist/gaia-ops/hooks/modules/tools/__init__.py +29 -0
  147. package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +1008 -0
  148. package/dist/gaia-ops/hooks/modules/tools/cloud_pipe_validator.py +231 -0
  149. package/dist/gaia-ops/hooks/modules/tools/hook_response.py +55 -0
  150. package/dist/gaia-ops/hooks/modules/tools/shell_parser.py +227 -0
  151. package/dist/gaia-ops/hooks/modules/tools/stage_decomposer.py +315 -0
  152. package/dist/gaia-ops/hooks/modules/tools/task_validator.py +294 -0
  153. package/dist/gaia-ops/hooks/modules/validation/__init__.py +23 -0
  154. package/dist/gaia-ops/hooks/modules/validation/commit_validator.py +380 -0
  155. package/dist/gaia-ops/hooks/post_compact.py +43 -0
  156. package/dist/gaia-ops/hooks/post_tool_use.py +54 -0
  157. package/dist/gaia-ops/hooks/pre_compact.py +60 -0
  158. package/dist/gaia-ops/hooks/pre_tool_use.py +413 -0
  159. package/dist/gaia-ops/hooks/session_end_hook.py +77 -0
  160. package/dist/gaia-ops/hooks/session_start.py +81 -0
  161. package/dist/gaia-ops/hooks/stop_hook.py +70 -0
  162. package/dist/gaia-ops/hooks/subagent_start.py +71 -0
  163. package/dist/gaia-ops/hooks/subagent_stop.py +295 -0
  164. package/dist/gaia-ops/hooks/task_completed.py +70 -0
  165. package/dist/gaia-ops/hooks/user_prompt_submit.py +246 -0
  166. package/dist/gaia-ops/settings.json +72 -0
  167. package/dist/gaia-ops/skills/README.md +158 -0
  168. package/dist/gaia-ops/skills/agent-creation/SKILL.md +87 -0
  169. package/dist/gaia-ops/skills/agent-creation/examples.md +170 -0
  170. package/dist/gaia-ops/skills/agent-creation/reference.md +191 -0
  171. package/dist/gaia-ops/skills/agent-protocol/SKILL.md +93 -0
  172. package/dist/gaia-ops/skills/agent-protocol/examples.md +223 -0
  173. package/dist/gaia-ops/skills/agent-response/SKILL.md +69 -0
  174. package/dist/gaia-ops/skills/agentic-loop/SKILL.md +80 -0
  175. package/dist/gaia-ops/skills/agentic-loop/reference.md +378 -0
  176. package/dist/gaia-ops/skills/blog-writing/SKILL.md +98 -0
  177. package/dist/gaia-ops/skills/blog-writing/reference.md +130 -0
  178. package/dist/gaia-ops/skills/brief-spec/SKILL.md +185 -0
  179. package/dist/gaia-ops/skills/command-execution/SKILL.md +64 -0
  180. package/dist/gaia-ops/skills/command-execution/reference.md +83 -0
  181. package/dist/gaia-ops/skills/context-updater/SKILL.md +87 -0
  182. package/dist/gaia-ops/skills/context-updater/examples.md +71 -0
  183. package/dist/gaia-ops/skills/developer-patterns/SKILL.md +50 -0
  184. package/dist/gaia-ops/skills/developer-patterns/reference.md +112 -0
  185. package/dist/gaia-ops/skills/execution/SKILL.md +99 -0
  186. package/dist/gaia-ops/skills/fast-queries/SKILL.md +43 -0
  187. package/dist/gaia-ops/skills/gaia-compact/SKILL.md +74 -0
  188. package/dist/gaia-ops/skills/gaia-patterns/SKILL.md +108 -0
  189. package/dist/gaia-ops/skills/gaia-patterns/reference.md +395 -0
  190. package/dist/gaia-ops/skills/gaia-planner/SKILL.md +37 -0
  191. package/dist/gaia-ops/skills/gaia-planner/reference.md +107 -0
  192. package/dist/gaia-ops/skills/gaia-release/SKILL.md +85 -0
  193. package/dist/gaia-ops/skills/gaia-release/reference.md +92 -0
  194. package/dist/gaia-ops/skills/gaia-self-check/SKILL.md +114 -0
  195. package/dist/gaia-ops/skills/gaia-self-check/reference.md +453 -0
  196. package/dist/gaia-ops/skills/gaia-verify/SKILL.md +77 -0
  197. package/dist/gaia-ops/skills/gaia-verify/reference.md +80 -0
  198. package/dist/gaia-ops/skills/git-conventions/SKILL.md +47 -0
  199. package/dist/gaia-ops/skills/gitops-patterns/SKILL.md +60 -0
  200. package/dist/gaia-ops/skills/gitops-patterns/reference.md +183 -0
  201. package/dist/gaia-ops/skills/gmail-policy/SKILL.md +200 -0
  202. package/dist/gaia-ops/skills/gmail-policy/reference.md +150 -0
  203. package/dist/gaia-ops/skills/gmail-triage/SKILL.md +100 -0
  204. package/dist/gaia-ops/skills/gws-setup/SKILL.md +99 -0
  205. package/dist/gaia-ops/skills/gws-setup/reference.md +73 -0
  206. package/dist/gaia-ops/skills/investigation/SKILL.md +100 -0
  207. package/dist/gaia-ops/skills/memory-curation/SKILL.md +83 -0
  208. package/dist/gaia-ops/skills/memory-search/SKILL.md +88 -0
  209. package/dist/gaia-ops/skills/orchestrator-approval/SKILL.md +160 -0
  210. package/dist/gaia-ops/skills/orchestrator-approval/reference.md +174 -0
  211. package/dist/gaia-ops/skills/pending-approvals/SKILL.md +72 -0
  212. package/dist/gaia-ops/skills/pending-approvals/reference.md +214 -0
  213. package/dist/gaia-ops/skills/readme-writing/SKILL.md +71 -0
  214. package/dist/gaia-ops/skills/readme-writing/reference.md +188 -0
  215. package/dist/gaia-ops/skills/reference.md +135 -0
  216. package/dist/gaia-ops/skills/request-approval/SKILL.md +140 -0
  217. package/dist/gaia-ops/skills/request-approval/examples.md +140 -0
  218. package/dist/gaia-ops/skills/request-approval/reference.md +57 -0
  219. package/dist/gaia-ops/skills/schedule-task/SKILL.md +64 -0
  220. package/dist/gaia-ops/skills/schedule-task/reference.md +233 -0
  221. package/dist/gaia-ops/skills/security-tiers/SKILL.md +141 -0
  222. package/dist/gaia-ops/skills/security-tiers/destructive-commands-reference.md +623 -0
  223. package/dist/gaia-ops/skills/security-tiers/reference.md +39 -0
  224. package/dist/gaia-ops/skills/session-reflection/SKILL.md +69 -0
  225. package/dist/gaia-ops/skills/skill-creation/SKILL.md +92 -0
  226. package/dist/gaia-ops/skills/skill-creation/reference.md +29 -0
  227. package/dist/gaia-ops/skills/terraform-patterns/SKILL.md +89 -0
  228. package/dist/gaia-ops/skills/terraform-patterns/reference.md +93 -0
  229. package/dist/gaia-ops/tools/__init__.py +9 -0
  230. package/dist/gaia-ops/tools/agentic-loop/decide-status.py +210 -0
  231. package/dist/gaia-ops/tools/agentic-loop/parse-metric.py +106 -0
  232. package/dist/gaia-ops/tools/agentic-loop/record-iteration.py +221 -0
  233. package/dist/gaia-ops/tools/context/README.md +132 -0
  234. package/dist/gaia-ops/tools/context/__init__.py +42 -0
  235. package/dist/gaia-ops/tools/context/_paths.py +20 -0
  236. package/dist/gaia-ops/tools/context/context_provider.py +721 -0
  237. package/dist/gaia-ops/tools/context/context_section_reader.py +342 -0
  238. package/dist/gaia-ops/tools/context/deep_merge.py +159 -0
  239. package/dist/gaia-ops/tools/context/pending_updates.py +760 -0
  240. package/dist/gaia-ops/tools/context/surface_router.py +278 -0
  241. package/dist/gaia-ops/tools/fast-queries/README.md +65 -0
  242. package/dist/gaia-ops/tools/fast-queries/__init__.py +30 -0
  243. package/dist/gaia-ops/tools/fast-queries/appservices/quicktriage_devops_developer.sh +75 -0
  244. package/dist/gaia-ops/tools/fast-queries/cloud/aws/quicktriage_aws_troubleshooter.sh +32 -0
  245. package/dist/gaia-ops/tools/fast-queries/cloud/gcp/quicktriage_gcp_troubleshooter.sh +88 -0
  246. package/dist/gaia-ops/tools/fast-queries/gitops/quicktriage_gitops_operator.sh +48 -0
  247. package/dist/gaia-ops/tools/fast-queries/run_triage.sh +59 -0
  248. package/dist/gaia-ops/tools/fast-queries/terraform/quicktriage_terraform_architect.sh +80 -0
  249. package/dist/gaia-ops/tools/gaia_simulator/__init__.py +33 -0
  250. package/dist/gaia-ops/tools/gaia_simulator/cli.py +354 -0
  251. package/dist/gaia-ops/tools/gaia_simulator/extractor.py +457 -0
  252. package/dist/gaia-ops/tools/gaia_simulator/reporter.py +258 -0
  253. package/dist/gaia-ops/tools/gaia_simulator/routing_simulator.py +334 -0
  254. package/dist/gaia-ops/tools/gaia_simulator/runner.py +539 -0
  255. package/dist/gaia-ops/tools/gaia_simulator/skills_mapper.py +264 -0
  256. package/dist/gaia-ops/tools/memory/README.md +0 -0
  257. package/dist/gaia-ops/tools/memory/__init__.py +20 -0
  258. package/dist/gaia-ops/tools/memory/backfill_fts5.py +107 -0
  259. package/dist/gaia-ops/tools/memory/conflict_detector.py +295 -0
  260. package/dist/gaia-ops/tools/memory/episodic.py +1210 -0
  261. package/dist/gaia-ops/tools/memory/git_invalidator.py +262 -0
  262. package/dist/gaia-ops/tools/memory/paths.py +102 -0
  263. package/dist/gaia-ops/tools/memory/scoring.py +193 -0
  264. package/dist/gaia-ops/tools/memory/search_store.py +375 -0
  265. package/dist/gaia-ops/tools/persist_transcript_analysis.py +85 -0
  266. package/dist/gaia-ops/tools/review/__init__.py +1 -0
  267. package/dist/gaia-ops/tools/review/review_engine.py +157 -0
  268. package/dist/gaia-ops/tools/scan/__init__.py +35 -0
  269. package/dist/gaia-ops/tools/scan/config.py +247 -0
  270. package/dist/gaia-ops/tools/scan/merge.py +212 -0
  271. package/dist/gaia-ops/tools/scan/orchestrator.py +549 -0
  272. package/dist/gaia-ops/tools/scan/registry.py +127 -0
  273. package/dist/gaia-ops/tools/scan/scanners/__init__.py +18 -0
  274. package/dist/gaia-ops/tools/scan/scanners/base.py +137 -0
  275. package/dist/gaia-ops/tools/scan/scanners/environment.py +349 -0
  276. package/dist/gaia-ops/tools/scan/scanners/git.py +570 -0
  277. package/dist/gaia-ops/tools/scan/scanners/infrastructure.py +875 -0
  278. package/dist/gaia-ops/tools/scan/scanners/orchestration.py +600 -0
  279. package/dist/gaia-ops/tools/scan/scanners/stack.py +1085 -0
  280. package/dist/gaia-ops/tools/scan/scanners/tools.py +260 -0
  281. package/dist/gaia-ops/tools/scan/setup.py +686 -0
  282. package/dist/gaia-ops/tools/scan/tests/__init__.py +1 -0
  283. package/dist/gaia-ops/tools/scan/tests/conftest.py +796 -0
  284. package/dist/gaia-ops/tools/scan/tests/test_environment.py +323 -0
  285. package/dist/gaia-ops/tools/scan/tests/test_git.py +419 -0
  286. package/dist/gaia-ops/tools/scan/tests/test_infrastructure.py +382 -0
  287. package/dist/gaia-ops/tools/scan/tests/test_integration.py +920 -0
  288. package/dist/gaia-ops/tools/scan/tests/test_merge.py +269 -0
  289. package/dist/gaia-ops/tools/scan/tests/test_orchestration.py +304 -0
  290. package/dist/gaia-ops/tools/scan/tests/test_stack.py +604 -0
  291. package/dist/gaia-ops/tools/scan/tests/test_tools.py +349 -0
  292. package/dist/gaia-ops/tools/scan/ui.py +624 -0
  293. package/dist/gaia-ops/tools/scan/verify.py +270 -0
  294. package/dist/gaia-ops/tools/scan/walk.py +118 -0
  295. package/dist/gaia-ops/tools/scan/workspace.py +85 -0
  296. package/dist/gaia-ops/tools/validation/README.md +244 -0
  297. package/dist/gaia-ops/tools/validation/__init__.py +17 -0
  298. package/dist/gaia-ops/tools/validation/approval_gate.py +321 -0
  299. package/dist/gaia-ops/tools/validation/validate_skills.py +189 -0
  300. package/dist/gaia-security/.claude-plugin/plugin.json +24 -0
  301. package/dist/gaia-security/README.md +90 -0
  302. package/dist/gaia-security/config/universal-rules.json +102 -0
  303. package/dist/gaia-security/hooks/adapters/__init__.py +52 -0
  304. package/dist/gaia-security/hooks/adapters/base.py +219 -0
  305. package/dist/gaia-security/hooks/adapters/channel.py +17 -0
  306. package/dist/gaia-security/hooks/adapters/claude_code.py +1890 -0
  307. package/dist/gaia-security/hooks/adapters/types.py +194 -0
  308. package/dist/gaia-security/hooks/adapters/utils.py +25 -0
  309. package/dist/gaia-security/hooks/hooks.json +113 -0
  310. package/dist/gaia-security/hooks/modules/__init__.py +15 -0
  311. package/dist/gaia-security/hooks/modules/agents/__init__.py +29 -0
  312. package/dist/gaia-security/hooks/modules/agents/contract_validator.py +647 -0
  313. package/dist/gaia-security/hooks/modules/agents/response_contract.py +496 -0
  314. package/dist/gaia-security/hooks/modules/agents/skill_injection_verifier.py +120 -0
  315. package/dist/gaia-security/hooks/modules/agents/state_tracker.py +267 -0
  316. package/dist/gaia-security/hooks/modules/agents/task_info_builder.py +74 -0
  317. package/dist/gaia-security/hooks/modules/agents/transcript_analyzer.py +458 -0
  318. package/dist/gaia-security/hooks/modules/agents/transcript_reader.py +152 -0
  319. package/dist/gaia-security/hooks/modules/audit/__init__.py +28 -0
  320. package/dist/gaia-security/hooks/modules/audit/event_detector.py +168 -0
  321. package/dist/gaia-security/hooks/modules/audit/logger.py +131 -0
  322. package/dist/gaia-security/hooks/modules/audit/metrics.py +134 -0
  323. package/dist/gaia-security/hooks/modules/audit/workflow_auditor.py +611 -0
  324. package/dist/gaia-security/hooks/modules/audit/workflow_recorder.py +296 -0
  325. package/dist/gaia-security/hooks/modules/context/__init__.py +11 -0
  326. package/dist/gaia-security/hooks/modules/context/agentic_loop_detector.py +165 -0
  327. package/dist/gaia-security/hooks/modules/context/anchor_tracker.py +317 -0
  328. package/dist/gaia-security/hooks/modules/context/compact_context_builder.py +218 -0
  329. package/dist/gaia-security/hooks/modules/context/context_freshness.py +145 -0
  330. package/dist/gaia-security/hooks/modules/context/context_injector.py +558 -0
  331. package/dist/gaia-security/hooks/modules/context/context_writer.py +530 -0
  332. package/dist/gaia-security/hooks/modules/context/contracts_loader.py +161 -0
  333. package/dist/gaia-security/hooks/modules/core/__init__.py +40 -0
  334. package/dist/gaia-security/hooks/modules/core/hook_entry.py +78 -0
  335. package/dist/gaia-security/hooks/modules/core/paths.py +160 -0
  336. package/dist/gaia-security/hooks/modules/core/plugin_mode.py +149 -0
  337. package/dist/gaia-security/hooks/modules/core/plugin_setup.py +577 -0
  338. package/dist/gaia-security/hooks/modules/core/state.py +179 -0
  339. package/dist/gaia-security/hooks/modules/core/stdin.py +24 -0
  340. package/dist/gaia-security/hooks/modules/events/__init__.py +1 -0
  341. package/dist/gaia-security/hooks/modules/events/event_writer.py +210 -0
  342. package/dist/gaia-security/hooks/modules/memory/__init__.py +8 -0
  343. package/dist/gaia-security/hooks/modules/memory/episode_writer.py +216 -0
  344. package/dist/gaia-security/hooks/modules/orchestrator/__init__.py +1 -0
  345. package/dist/gaia-security/hooks/modules/orchestrator/delegate_mode.py +122 -0
  346. package/dist/gaia-security/hooks/modules/scanning/__init__.py +8 -0
  347. package/dist/gaia-security/hooks/modules/scanning/scan_trigger.py +84 -0
  348. package/dist/gaia-security/hooks/modules/security/__init__.py +120 -0
  349. package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +87 -0
  350. package/dist/gaia-security/hooks/modules/security/approval_constants.py +23 -0
  351. package/dist/gaia-security/hooks/modules/security/approval_grants.py +1638 -0
  352. package/dist/gaia-security/hooks/modules/security/approval_messages.py +71 -0
  353. package/dist/gaia-security/hooks/modules/security/approval_scopes.py +222 -0
  354. package/dist/gaia-security/hooks/modules/security/blocked_commands.py +595 -0
  355. package/dist/gaia-security/hooks/modules/security/blocked_message_formatter.py +87 -0
  356. package/dist/gaia-security/hooks/modules/security/command_semantics.py +181 -0
  357. package/dist/gaia-security/hooks/modules/security/composition_rules.py +547 -0
  358. package/dist/gaia-security/hooks/modules/security/flag_classifiers.py +873 -0
  359. package/dist/gaia-security/hooks/modules/security/gitops_validator.py +179 -0
  360. package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +1131 -0
  361. package/dist/gaia-security/hooks/modules/security/network_hosts.py +481 -0
  362. package/dist/gaia-security/hooks/modules/security/prompt_validator.py +40 -0
  363. package/dist/gaia-security/hooks/modules/security/shell_unwrapper.py +165 -0
  364. package/dist/gaia-security/hooks/modules/security/tiers.py +196 -0
  365. package/dist/gaia-security/hooks/modules/session/__init__.py +10 -0
  366. package/dist/gaia-security/hooks/modules/session/pending_scanner.py +174 -0
  367. package/dist/gaia-security/hooks/modules/session/session_context_writer.py +100 -0
  368. package/dist/gaia-security/hooks/modules/session/session_event_injector.py +160 -0
  369. package/dist/gaia-security/hooks/modules/session/session_manager.py +31 -0
  370. package/dist/gaia-security/hooks/modules/session/session_registry.py +333 -0
  371. package/dist/gaia-security/hooks/modules/tools/__init__.py +29 -0
  372. package/dist/gaia-security/hooks/modules/tools/bash_validator.py +1008 -0
  373. package/dist/gaia-security/hooks/modules/tools/cloud_pipe_validator.py +231 -0
  374. package/dist/gaia-security/hooks/modules/tools/hook_response.py +55 -0
  375. package/dist/gaia-security/hooks/modules/tools/shell_parser.py +227 -0
  376. package/dist/gaia-security/hooks/modules/tools/stage_decomposer.py +315 -0
  377. package/dist/gaia-security/hooks/modules/tools/task_validator.py +294 -0
  378. package/dist/gaia-security/hooks/modules/validation/__init__.py +23 -0
  379. package/dist/gaia-security/hooks/modules/validation/commit_validator.py +380 -0
  380. package/dist/gaia-security/hooks/post_tool_use.py +54 -0
  381. package/dist/gaia-security/hooks/pre_tool_use.py +413 -0
  382. package/dist/gaia-security/hooks/session_end_hook.py +77 -0
  383. package/dist/gaia-security/hooks/session_start.py +81 -0
  384. package/dist/gaia-security/hooks/stop_hook.py +70 -0
  385. package/dist/gaia-security/hooks/user_prompt_submit.py +246 -0
  386. package/dist/gaia-security/settings.json +58 -0
  387. package/git-hooks/commit-msg +41 -0
  388. package/hooks/README.md +100 -0
  389. package/hooks/adapters/__init__.py +52 -0
  390. package/hooks/adapters/base.py +219 -0
  391. package/hooks/adapters/channel.py +17 -0
  392. package/hooks/adapters/claude_code.py +1890 -0
  393. package/hooks/adapters/types.py +194 -0
  394. package/hooks/adapters/utils.py +25 -0
  395. package/hooks/elicitation_result.py +179 -0
  396. package/hooks/hooks.json +84 -0
  397. package/hooks/modules/README.md +189 -0
  398. package/hooks/modules/__init__.py +15 -0
  399. package/hooks/modules/agents/__init__.py +29 -0
  400. package/hooks/modules/agents/contract_validator.py +647 -0
  401. package/hooks/modules/agents/response_contract.py +496 -0
  402. package/hooks/modules/agents/skill_injection_verifier.py +120 -0
  403. package/hooks/modules/agents/state_tracker.py +267 -0
  404. package/hooks/modules/agents/task_info_builder.py +74 -0
  405. package/hooks/modules/agents/transcript_analyzer.py +458 -0
  406. package/hooks/modules/agents/transcript_reader.py +152 -0
  407. package/hooks/modules/audit/__init__.py +28 -0
  408. package/hooks/modules/audit/event_detector.py +168 -0
  409. package/hooks/modules/audit/logger.py +131 -0
  410. package/hooks/modules/audit/metrics.py +134 -0
  411. package/hooks/modules/audit/workflow_auditor.py +611 -0
  412. package/hooks/modules/audit/workflow_recorder.py +296 -0
  413. package/hooks/modules/context/__init__.py +11 -0
  414. package/hooks/modules/context/agentic_loop_detector.py +165 -0
  415. package/hooks/modules/context/anchor_tracker.py +317 -0
  416. package/hooks/modules/context/compact_context_builder.py +218 -0
  417. package/hooks/modules/context/context_freshness.py +145 -0
  418. package/hooks/modules/context/context_injector.py +558 -0
  419. package/hooks/modules/context/context_writer.py +530 -0
  420. package/hooks/modules/context/contracts_loader.py +161 -0
  421. package/hooks/modules/core/__init__.py +40 -0
  422. package/hooks/modules/core/hook_entry.py +78 -0
  423. package/hooks/modules/core/paths.py +160 -0
  424. package/hooks/modules/core/plugin_mode.py +149 -0
  425. package/hooks/modules/core/plugin_setup.py +577 -0
  426. package/hooks/modules/core/state.py +179 -0
  427. package/hooks/modules/core/stdin.py +24 -0
  428. package/hooks/modules/events/__init__.py +1 -0
  429. package/hooks/modules/events/event_writer.py +210 -0
  430. package/hooks/modules/evidence/__init__.py +34 -0
  431. package/hooks/modules/evidence/assertions.py +137 -0
  432. package/hooks/modules/evidence/index_writer.py +57 -0
  433. package/hooks/modules/evidence/loader.py +126 -0
  434. package/hooks/modules/evidence/runner.py +241 -0
  435. package/hooks/modules/memory/__init__.py +8 -0
  436. package/hooks/modules/memory/episode_writer.py +216 -0
  437. package/hooks/modules/orchestrator/__init__.py +1 -0
  438. package/hooks/modules/orchestrator/delegate_mode.py +122 -0
  439. package/hooks/modules/scanning/__init__.py +8 -0
  440. package/hooks/modules/scanning/scan_trigger.py +84 -0
  441. package/hooks/modules/security/__init__.py +120 -0
  442. package/hooks/modules/security/approval_cleanup.py +87 -0
  443. package/hooks/modules/security/approval_constants.py +23 -0
  444. package/hooks/modules/security/approval_grants.py +1638 -0
  445. package/hooks/modules/security/approval_messages.py +71 -0
  446. package/hooks/modules/security/approval_scopes.py +222 -0
  447. package/hooks/modules/security/blocked_commands.py +595 -0
  448. package/hooks/modules/security/blocked_message_formatter.py +87 -0
  449. package/hooks/modules/security/command_semantics.py +181 -0
  450. package/hooks/modules/security/composition_rules.py +547 -0
  451. package/hooks/modules/security/flag_classifiers.py +873 -0
  452. package/hooks/modules/security/gitops_validator.py +179 -0
  453. package/hooks/modules/security/mutative_verbs.py +1131 -0
  454. package/hooks/modules/security/network_hosts.py +481 -0
  455. package/hooks/modules/security/prompt_validator.py +40 -0
  456. package/hooks/modules/security/shell_unwrapper.py +165 -0
  457. package/hooks/modules/security/tiers.py +196 -0
  458. package/hooks/modules/session/__init__.py +10 -0
  459. package/hooks/modules/session/pending_scanner.py +174 -0
  460. package/hooks/modules/session/session_context_writer.py +100 -0
  461. package/hooks/modules/session/session_event_injector.py +160 -0
  462. package/hooks/modules/session/session_manager.py +31 -0
  463. package/hooks/modules/session/session_registry.py +333 -0
  464. package/hooks/modules/tools/__init__.py +29 -0
  465. package/hooks/modules/tools/bash_validator.py +1008 -0
  466. package/hooks/modules/tools/cloud_pipe_validator.py +231 -0
  467. package/hooks/modules/tools/hook_response.py +55 -0
  468. package/hooks/modules/tools/shell_parser.py +227 -0
  469. package/hooks/modules/tools/stage_decomposer.py +315 -0
  470. package/hooks/modules/tools/task_validator.py +294 -0
  471. package/hooks/modules/validation/__init__.py +23 -0
  472. package/hooks/modules/validation/commit_validator.py +380 -0
  473. package/hooks/post_compact.py +43 -0
  474. package/hooks/post_tool_use.py +54 -0
  475. package/hooks/pre_compact.py +60 -0
  476. package/hooks/pre_tool_use.py +413 -0
  477. package/hooks/session_end_hook.py +77 -0
  478. package/hooks/session_start.py +81 -0
  479. package/hooks/stop_hook.py +70 -0
  480. package/hooks/subagent_start.py +71 -0
  481. package/hooks/subagent_stop.py +295 -0
  482. package/hooks/task_completed.py +70 -0
  483. package/hooks/user_prompt_submit.py +246 -0
  484. package/index.js +83 -0
  485. package/package.json +103 -0
  486. package/pyproject.toml +32 -0
  487. package/skills/README.md +158 -0
  488. package/skills/agent-creation/SKILL.md +87 -0
  489. package/skills/agent-creation/examples.md +170 -0
  490. package/skills/agent-creation/reference.md +191 -0
  491. package/skills/agent-protocol/SKILL.md +93 -0
  492. package/skills/agent-protocol/examples.md +223 -0
  493. package/skills/agent-response/SKILL.md +69 -0
  494. package/skills/agentic-loop/SKILL.md +80 -0
  495. package/skills/agentic-loop/reference.md +378 -0
  496. package/skills/blog-writing/SKILL.md +98 -0
  497. package/skills/blog-writing/reference.md +130 -0
  498. package/skills/brief-spec/SKILL.md +185 -0
  499. package/skills/command-execution/SKILL.md +64 -0
  500. package/skills/command-execution/reference.md +83 -0
  501. package/skills/context-updater/SKILL.md +87 -0
  502. package/skills/context-updater/examples.md +71 -0
  503. package/skills/developer-patterns/SKILL.md +50 -0
  504. package/skills/developer-patterns/reference.md +112 -0
  505. package/skills/execution/SKILL.md +99 -0
  506. package/skills/fast-queries/SKILL.md +43 -0
  507. package/skills/gaia-compact/SKILL.md +74 -0
  508. package/skills/gaia-patterns/SKILL.md +108 -0
  509. package/skills/gaia-patterns/reference.md +395 -0
  510. package/skills/gaia-planner/SKILL.md +37 -0
  511. package/skills/gaia-planner/reference.md +107 -0
  512. package/skills/gaia-release/SKILL.md +85 -0
  513. package/skills/gaia-release/reference.md +92 -0
  514. package/skills/gaia-self-check/SKILL.md +114 -0
  515. package/skills/gaia-self-check/reference.md +453 -0
  516. package/skills/gaia-verify/SKILL.md +77 -0
  517. package/skills/gaia-verify/reference.md +80 -0
  518. package/skills/git-conventions/SKILL.md +47 -0
  519. package/skills/gitops-patterns/SKILL.md +60 -0
  520. package/skills/gitops-patterns/reference.md +183 -0
  521. package/skills/gmail-policy/SKILL.md +200 -0
  522. package/skills/gmail-policy/reference.md +150 -0
  523. package/skills/gmail-triage/SKILL.md +100 -0
  524. package/skills/gws-setup/SKILL.md +99 -0
  525. package/skills/gws-setup/reference.md +73 -0
  526. package/skills/investigation/SKILL.md +100 -0
  527. package/skills/memory-curation/SKILL.md +83 -0
  528. package/skills/memory-search/SKILL.md +88 -0
  529. package/skills/orchestrator-approval/SKILL.md +160 -0
  530. package/skills/orchestrator-approval/reference.md +174 -0
  531. package/skills/pending-approvals/SKILL.md +72 -0
  532. package/skills/pending-approvals/reference.md +214 -0
  533. package/skills/readme-writing/SKILL.md +71 -0
  534. package/skills/readme-writing/reference.md +188 -0
  535. package/skills/reference.md +135 -0
  536. package/skills/request-approval/SKILL.md +140 -0
  537. package/skills/request-approval/examples.md +140 -0
  538. package/skills/request-approval/reference.md +57 -0
  539. package/skills/schedule-task/SKILL.md +64 -0
  540. package/skills/schedule-task/reference.md +233 -0
  541. package/skills/security-tiers/SKILL.md +141 -0
  542. package/skills/security-tiers/destructive-commands-reference.md +623 -0
  543. package/skills/security-tiers/reference.md +39 -0
  544. package/skills/session-reflection/SKILL.md +69 -0
  545. package/skills/skill-creation/SKILL.md +92 -0
  546. package/skills/skill-creation/reference.md +29 -0
  547. package/skills/terraform-patterns/SKILL.md +89 -0
  548. package/skills/terraform-patterns/reference.md +93 -0
  549. package/templates/README.md +69 -0
  550. package/templates/managed-settings.template.json +43 -0
  551. package/tools/__init__.py +9 -0
  552. package/tools/agentic-loop/decide-status.py +210 -0
  553. package/tools/agentic-loop/parse-metric.py +106 -0
  554. package/tools/agentic-loop/record-iteration.py +221 -0
  555. package/tools/context/README.md +132 -0
  556. package/tools/context/__init__.py +42 -0
  557. package/tools/context/_paths.py +20 -0
  558. package/tools/context/context_provider.py +721 -0
  559. package/tools/context/context_section_reader.py +342 -0
  560. package/tools/context/deep_merge.py +159 -0
  561. package/tools/context/pending_updates.py +760 -0
  562. package/tools/context/surface_router.py +278 -0
  563. package/tools/fast-queries/README.md +65 -0
  564. package/tools/fast-queries/__init__.py +30 -0
  565. package/tools/fast-queries/appservices/quicktriage_devops_developer.sh +75 -0
  566. package/tools/fast-queries/cloud/aws/quicktriage_aws_troubleshooter.sh +32 -0
  567. package/tools/fast-queries/cloud/gcp/quicktriage_gcp_troubleshooter.sh +88 -0
  568. package/tools/fast-queries/gitops/quicktriage_gitops_operator.sh +48 -0
  569. package/tools/fast-queries/run_triage.sh +59 -0
  570. package/tools/fast-queries/terraform/quicktriage_terraform_architect.sh +80 -0
  571. package/tools/gaia_simulator/__init__.py +33 -0
  572. package/tools/gaia_simulator/cli.py +354 -0
  573. package/tools/gaia_simulator/extractor.py +457 -0
  574. package/tools/gaia_simulator/reporter.py +258 -0
  575. package/tools/gaia_simulator/routing_simulator.py +334 -0
  576. package/tools/gaia_simulator/runner.py +539 -0
  577. package/tools/gaia_simulator/skills_mapper.py +264 -0
  578. package/tools/memory/README.md +0 -0
  579. package/tools/memory/__init__.py +20 -0
  580. package/tools/memory/backfill_fts5.py +107 -0
  581. package/tools/memory/conflict_detector.py +295 -0
  582. package/tools/memory/episodic.py +1210 -0
  583. package/tools/memory/git_invalidator.py +262 -0
  584. package/tools/memory/paths.py +102 -0
  585. package/tools/memory/scoring.py +193 -0
  586. package/tools/memory/search_store.py +375 -0
  587. package/tools/persist_transcript_analysis.py +85 -0
  588. package/tools/review/__init__.py +1 -0
  589. package/tools/review/review_engine.py +157 -0
  590. package/tools/scan/__init__.py +35 -0
  591. package/tools/scan/config.py +247 -0
  592. package/tools/scan/merge.py +212 -0
  593. package/tools/scan/orchestrator.py +549 -0
  594. package/tools/scan/registry.py +127 -0
  595. package/tools/scan/scanners/__init__.py +18 -0
  596. package/tools/scan/scanners/base.py +137 -0
  597. package/tools/scan/scanners/environment.py +349 -0
  598. package/tools/scan/scanners/git.py +570 -0
  599. package/tools/scan/scanners/infrastructure.py +875 -0
  600. package/tools/scan/scanners/orchestration.py +600 -0
  601. package/tools/scan/scanners/stack.py +1085 -0
  602. package/tools/scan/scanners/tools.py +260 -0
  603. package/tools/scan/setup.py +686 -0
  604. package/tools/scan/tests/__init__.py +1 -0
  605. package/tools/scan/tests/conftest.py +796 -0
  606. package/tools/scan/tests/test_environment.py +323 -0
  607. package/tools/scan/tests/test_git.py +419 -0
  608. package/tools/scan/tests/test_infrastructure.py +382 -0
  609. package/tools/scan/tests/test_integration.py +920 -0
  610. package/tools/scan/tests/test_merge.py +269 -0
  611. package/tools/scan/tests/test_orchestration.py +304 -0
  612. package/tools/scan/tests/test_stack.py +604 -0
  613. package/tools/scan/tests/test_tools.py +349 -0
  614. package/tools/scan/ui.py +624 -0
  615. package/tools/scan/verify.py +270 -0
  616. package/tools/scan/walk.py +118 -0
  617. package/tools/scan/workspace.py +85 -0
  618. package/tools/validation/README.md +244 -0
  619. package/tools/validation/__init__.py +17 -0
  620. package/tools/validation/approval_gate.py +321 -0
  621. package/tools/validation/validate_skills.py +189 -0
@@ -0,0 +1,1638 @@
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 re
39
+ import secrets
40
+ import subprocess
41
+ import time
42
+ from dataclasses import dataclass, field, asdict
43
+ from enum import Enum
44
+ from pathlib import Path
45
+ from typing import Any, Dict, List, Optional
46
+
47
+ from ..core.paths import find_claude_dir, get_plugin_data_dir
48
+ from ..core.state import get_session_id
49
+ from .approval_scopes import (
50
+ ApprovalSignature,
51
+ SCOPE_FILE_PATH,
52
+ SCOPE_SEMANTIC_SIGNATURE,
53
+ SCOPE_VERB_FAMILY,
54
+ SUPPORTED_SCOPE_TYPES,
55
+ build_approval_signature,
56
+ build_file_path_signature,
57
+ matches_approval_signature,
58
+ matches_file_path_approval,
59
+ )
60
+
61
+ logger = logging.getLogger(__name__)
62
+
63
+ # Default grant TTL in minutes
64
+ DEFAULT_GRANT_TTL_MINUTES = 5
65
+
66
+ # Default pending TTL in minutes (24 hours)
67
+ DEFAULT_PENDING_TTL_MINUTES = 1440
68
+
69
+ # Cleanup throttle: only run cleanup if 60+ seconds since last run
70
+ _last_cleanup_time: float = 0.0
71
+ _CLEANUP_INTERVAL_SECONDS = 60
72
+
73
+ class ActivationStatus(str, Enum):
74
+ """Activation result statuses for pending approval flow."""
75
+ ACTIVATED = "activated"
76
+ NOT_FOUND = "not_found"
77
+ NONCE_MISMATCH = "nonce_mismatch"
78
+ SESSION_MISMATCH = "session_mismatch"
79
+ EXPIRED = "expired"
80
+ INVALID_SIGNATURE = "invalid_signature"
81
+ INVALID_PENDING = "invalid_pending"
82
+ ERROR = "error"
83
+
84
+
85
+ # Backward-compatible module-level aliases
86
+ ACTIVATION_ACTIVATED = ActivationStatus.ACTIVATED
87
+ ACTIVATION_NOT_FOUND = ActivationStatus.NOT_FOUND
88
+ ACTIVATION_NONCE_MISMATCH = ActivationStatus.NONCE_MISMATCH
89
+ ACTIVATION_SESSION_MISMATCH = ActivationStatus.SESSION_MISMATCH
90
+ ACTIVATION_EXPIRED = ActivationStatus.EXPIRED
91
+ ACTIVATION_INVALID_SIGNATURE = ActivationStatus.INVALID_SIGNATURE
92
+ ACTIVATION_INVALID_PENDING = ActivationStatus.INVALID_PENDING
93
+ ACTIVATION_ERROR = ActivationStatus.ERROR
94
+
95
+
96
+ def _is_ttl_expired(timestamp: float, ttl_minutes: int) -> bool:
97
+ """Return True if the given timestamp is older than ttl_minutes.
98
+
99
+ A ttl_minutes of 0 means "no expiry" -- always returns False.
100
+ """
101
+ if ttl_minutes == 0:
102
+ return False
103
+ if timestamp == 0:
104
+ return True
105
+ elapsed_minutes = (time.time() - timestamp) / 60
106
+ return elapsed_minutes > ttl_minutes
107
+
108
+
109
+ def _is_rejected(data: Dict[str, Any]) -> bool:
110
+ """Return True if a pending approval has been rejected."""
111
+ return data.get("status") == "rejected"
112
+
113
+
114
+ @dataclass(frozen=True)
115
+ class ApprovalActivationResult:
116
+ """Structured result for pending approval activation."""
117
+
118
+ success: bool
119
+ status: str
120
+ reason: str
121
+ grant_path: Optional[Path] = None
122
+
123
+
124
+ @dataclass
125
+ class ApprovalGrant:
126
+ """A time-limited approval grant for T3 commands.
127
+
128
+ Attributes:
129
+ session_id: The Claude session that owns this grant.
130
+ approved_verbs: Human-readable verb summary for logs/debugging.
131
+ approved_scope: Original approval scope text from the user.
132
+ scope_type: Approval scope mode (exact, semantic, or verb_family).
133
+ scope_signature: Persisted ApprovalSignature payload for matching.
134
+ granted_at: Unix timestamp when the grant was created.
135
+ ttl_minutes: How long the grant is valid.
136
+ used: Whether the grant has been consumed.
137
+ multi_use: When True, the grant is NOT consumed after a single use.
138
+ Used by SCOPE_VERB_FAMILY grants for batch operations.
139
+ """
140
+ session_id: str = ""
141
+ approved_verbs: List[str] = field(default_factory=list)
142
+ approved_scope: str = ""
143
+ scope_type: str = SCOPE_SEMANTIC_SIGNATURE
144
+ scope_signature: Optional[dict] = None
145
+ granted_at: float = 0.0
146
+ ttl_minutes: int = DEFAULT_GRANT_TTL_MINUTES
147
+ used: bool = False
148
+ confirmed: bool = False
149
+ multi_use: bool = False
150
+
151
+ def is_expired(self) -> bool:
152
+ """Check if the grant has expired."""
153
+ return _is_ttl_expired(self.granted_at, self.ttl_minutes)
154
+
155
+ def is_valid(self) -> bool:
156
+ """Check if the grant is still usable.
157
+
158
+ Multi-use grants ignore the ``used`` flag and remain valid until
159
+ their TTL expires.
160
+ """
161
+ if self.is_expired():
162
+ return False
163
+ if self.multi_use:
164
+ return True
165
+ return not self.used
166
+
167
+ def get_signature(self) -> Optional[ApprovalSignature]:
168
+ """Deserialize the persisted scope signature, if present."""
169
+ if not self.scope_signature:
170
+ return None
171
+ try:
172
+ return ApprovalSignature.from_dict(self.scope_signature)
173
+ except Exception:
174
+ return None
175
+
176
+ def matches_command(self, command: str) -> bool:
177
+ """Check whether a command falls inside this grant's explicit scope."""
178
+ signature = self.get_signature()
179
+ if signature is None:
180
+ return False
181
+ return matches_approval_signature(signature, command)
182
+
183
+
184
+ _grants_dir_created: bool = False
185
+
186
+ # Module-level flag: set by check_approval_grant() when it encounters and
187
+ # cleans up an expired grant for the requested command. Callers (e.g.
188
+ # bash_validator) can read this via last_check_found_expired() to emit a
189
+ # clear expiry message instead of a generic "no grant found" block.
190
+ _last_check_found_expired: bool = False
191
+
192
+
193
+ def last_check_found_expired() -> bool:
194
+ """Return True if the most recent check_approval_grant() call cleaned up
195
+ an expired grant that would have matched the command."""
196
+ return _last_check_found_expired
197
+
198
+
199
+ def _get_grants_dir() -> Path:
200
+ """Get the directory for approval grant files."""
201
+ global _grants_dir_created
202
+ grants_dir = get_plugin_data_dir() / "cache" / "approvals"
203
+ if not _grants_dir_created:
204
+ grants_dir.mkdir(parents=True, exist_ok=True)
205
+ _grants_dir_created = True
206
+ return grants_dir
207
+
208
+
209
+ def _get_pending_index_path(session_id: str) -> Path:
210
+ """Return the session-scoped pending-approval index path."""
211
+ return _get_grants_dir() / f"pending-index-{session_id}.json"
212
+
213
+
214
+ def _read_json_file(path: Path) -> Optional[Dict[str, Any]]:
215
+ """Read a JSON file defensively and return its dict payload."""
216
+ try:
217
+ return json.loads(path.read_text())
218
+ except Exception:
219
+ return None
220
+
221
+
222
+ def _rebuild_pending_index(session_id: str) -> None:
223
+ """Rebuild the per-session pending-approval index from authoritative files."""
224
+ index_path = _get_pending_index_path(session_id)
225
+ entries: List[Dict[str, Any]] = []
226
+
227
+ for pending_file in _get_grants_dir().glob("pending-*.json"):
228
+ if pending_file.name.startswith("pending-index-"):
229
+ continue
230
+ data = _read_json_file(pending_file)
231
+ if not data or data.get("session_id") != session_id:
232
+ continue
233
+ if _is_rejected(data):
234
+ continue
235
+
236
+ nonce = data.get("nonce")
237
+ timestamp = data.get("timestamp")
238
+ if not nonce or not isinstance(timestamp, (int, float)):
239
+ continue
240
+ ttl_minutes = data.get("ttl_minutes", DEFAULT_PENDING_TTL_MINUTES)
241
+ if _is_ttl_expired(float(timestamp), int(ttl_minutes)):
242
+ continue
243
+
244
+ entries.append(
245
+ {
246
+ "nonce": nonce,
247
+ "pending_file": pending_file.name,
248
+ "timestamp": float(timestamp),
249
+ }
250
+ )
251
+
252
+ entries.sort(key=lambda item: item["timestamp"], reverse=True)
253
+
254
+ if not entries:
255
+ index_path.unlink(missing_ok=True)
256
+ return
257
+
258
+ index_payload = {
259
+ "session_id": session_id,
260
+ "latest_nonce": entries[0]["nonce"],
261
+ "entries": entries,
262
+ }
263
+ index_path.write_text(json.dumps(index_payload, indent=2))
264
+
265
+
266
+ def _get_session_id() -> str:
267
+ """Get the current session ID. Delegates to core.state.get_session_id()."""
268
+ return get_session_id()
269
+
270
+
271
+ def get_latest_pending_approval(session_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
272
+ """Return the newest pending approval record for the current session.
273
+
274
+ This is a deterministic helper for future orchestrator logic: it reads the
275
+ session index, then dereferences the authoritative pending file instead of
276
+ asking callers to parse a nonce from agent text.
277
+ """
278
+ if session_id is None:
279
+ session_id = _get_session_id()
280
+
281
+ index_path = _get_pending_index_path(session_id)
282
+
283
+ for attempt in range(2):
284
+ if not index_path.exists():
285
+ return None
286
+
287
+ index_data = _read_json_file(index_path)
288
+ if not index_data:
289
+ _rebuild_pending_index(session_id)
290
+ continue
291
+
292
+ latest_nonce = index_data.get("latest_nonce")
293
+ entries = index_data.get("entries") or []
294
+ pending_ref = next((entry for entry in entries if entry.get("nonce") == latest_nonce), None)
295
+ if not latest_nonce or pending_ref is None:
296
+ _rebuild_pending_index(session_id)
297
+ continue
298
+
299
+ pending_path = _get_grants_dir() / pending_ref.get("pending_file", "")
300
+ pending_data = _read_json_file(pending_path)
301
+ if not pending_data or pending_data.get("session_id") != session_id:
302
+ _rebuild_pending_index(session_id)
303
+ continue
304
+
305
+ return pending_data
306
+
307
+ return None
308
+
309
+
310
+ # ============================================================================
311
+ # Nonce Generation and Pending Approval Management
312
+ # ============================================================================
313
+
314
+ def generate_nonce() -> str:
315
+ """Generate a cryptographic nonce for approval tracking.
316
+
317
+ Returns:
318
+ 32-character hex string (128 bits of entropy).
319
+ """
320
+ return secrets.token_hex(16)
321
+
322
+
323
+ # Regex for extracting a nonce from an AskUserQuestion approve label.
324
+ # Only matches labels that start with "Approve" and contain [P-<hex>].
325
+ _APPROVE_NONCE_RE = re.compile(r"^Approve\b.*\[P-([a-f0-9]+)\]")
326
+
327
+
328
+ def extract_nonce_from_label(label: str) -> Optional[str]:
329
+ """Extract the nonce from an AskUserQuestion option label.
330
+
331
+ Approve labels may contain a ``[P-<hex>]`` tag that identifies the
332
+ pending approval to activate. Reject labels never carry a nonce,
333
+ even if one is superficially present in the text.
334
+
335
+ Args:
336
+ label: The option label string (e.g. ``"Approve -- git push origin main [P-e68be5b8]"``).
337
+
338
+ Returns:
339
+ The hex nonce string if found in an Approve label, otherwise ``None``.
340
+ """
341
+ m = _APPROVE_NONCE_RE.search(label)
342
+ return m.group(1) if m else None
343
+
344
+
345
+ def load_pending_by_nonce_prefix(prefix: str) -> Optional[Dict[str, Any]]:
346
+ """Load a pending approval file whose nonce starts with the given prefix.
347
+
348
+ The ``[P-<hex>]`` tag in AskUserQuestion labels carries the first 8
349
+ characters of the full 32-character nonce. This function scans the
350
+ grants directory for a matching ``pending-{nonce}.json`` file and
351
+ returns its parsed contents.
352
+
353
+ If multiple files match (extremely unlikely with 8 hex chars), the
354
+ most recent one (by timestamp) is returned.
355
+
356
+ Args:
357
+ prefix: Hex prefix extracted from a ``[P-xxx]`` label (typically 8 chars).
358
+
359
+ Returns:
360
+ The parsed pending approval dict, or ``None`` if no match was found.
361
+ """
362
+ try:
363
+ grants_dir = _get_grants_dir()
364
+ candidates: List[Dict[str, Any]] = []
365
+
366
+ for pending_file in grants_dir.glob("pending-*.json"):
367
+ if pending_file.name.startswith("pending-index-"):
368
+ continue
369
+ # Extract nonce from filename: pending-{nonce}.json
370
+ fname_nonce = pending_file.stem.removeprefix("pending-")
371
+ if not fname_nonce.startswith(prefix):
372
+ continue
373
+ data = _read_json_file(pending_file)
374
+ if data and not _is_rejected(data):
375
+ candidates.append(data)
376
+
377
+ if not candidates:
378
+ logger.info("No pending approval found for nonce prefix %s", prefix)
379
+ return None
380
+
381
+ # Return newest by timestamp
382
+ candidates.sort(key=lambda d: d.get("timestamp", 0), reverse=True)
383
+ logger.info(
384
+ "Found pending approval for nonce prefix %s: full_nonce=%s",
385
+ prefix, candidates[0].get("nonce", "?")[:12],
386
+ )
387
+ return candidates[0]
388
+
389
+ except Exception as e:
390
+ logger.error("Error loading pending by nonce prefix %s: %s", prefix, e)
391
+ return None
392
+
393
+
394
+ # ------------------------------------------------------------------ #
395
+ # Environment snapshot capture
396
+ # ------------------------------------------------------------------ #
397
+
398
+ # CLI families whose environment state is worth capturing at blocking time.
399
+ _GIT_CMD_PATTERN = re.compile(r"\bgit\b")
400
+
401
+ _ENV_SNAPSHOT_TIMEOUT_SECONDS = 2
402
+
403
+
404
+ def _run_git_query(args: List[str], cwd: Optional[str] = None) -> Optional[str]:
405
+ """Run a git sub-command and return stripped stdout, or None on failure."""
406
+ try:
407
+ result = subprocess.run(
408
+ ["git"] + args,
409
+ capture_output=True,
410
+ text=True,
411
+ timeout=_ENV_SNAPSHOT_TIMEOUT_SECONDS,
412
+ cwd=cwd,
413
+ )
414
+ if result.returncode == 0:
415
+ return result.stdout.strip()
416
+ except Exception:
417
+ pass
418
+ return None
419
+
420
+
421
+ def capture_environment_snapshot(
422
+ command: str,
423
+ cwd: Optional[str] = None,
424
+ ) -> Dict[str, Any]:
425
+ """Capture relevant environment state at the time a command is blocked.
426
+
427
+ Designed to be fast (<2 s) and failure-tolerant -- a failed capture
428
+ returns an empty dict and MUST NOT prevent the pending file from being
429
+ written.
430
+
431
+ Currently supports:
432
+ - **git** commands: local HEAD, remote HEAD (origin/main), current branch.
433
+
434
+ Extensible to kubectl, terraform, etc. in future iterations.
435
+
436
+ Args:
437
+ command: The blocked command string.
438
+ cwd: Working directory context (used for git queries).
439
+
440
+ Returns:
441
+ A dict with captured state, or ``{}`` if nothing could be captured
442
+ or the command class is not yet supported.
443
+ """
444
+ if not _GIT_CMD_PATTERN.search(command):
445
+ return {}
446
+
447
+ try:
448
+ snapshot: Dict[str, Any] = {"command_class": "git"}
449
+
450
+ head = _run_git_query(["rev-parse", "HEAD"], cwd=cwd)
451
+ if head:
452
+ snapshot["local_head"] = head
453
+
454
+ branch = _run_git_query(["rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd)
455
+ if branch:
456
+ snapshot["branch"] = branch
457
+
458
+ remote_head = _run_git_query(
459
+ ["rev-parse", "origin/main"], cwd=cwd,
460
+ )
461
+ if remote_head:
462
+ snapshot["remote_head"] = remote_head
463
+
464
+ return snapshot
465
+
466
+ except Exception as exc:
467
+ logger.debug("Environment snapshot capture failed: %s", exc)
468
+ return {}
469
+
470
+
471
+ def write_pending_approval(
472
+ nonce: str,
473
+ command: str,
474
+ danger_verb: str,
475
+ danger_category: str,
476
+ session_id: Optional[str] = None,
477
+ ttl_minutes: int = DEFAULT_PENDING_TTL_MINUTES,
478
+ context: Optional[Dict[str, Any]] = None,
479
+ cwd: Optional[str] = None,
480
+ environment: Optional[Dict[str, Any]] = None,
481
+ ) -> Optional[Path]:
482
+ """Write a pending approval file when a T3 command is blocked.
483
+
484
+ Called by bash_validator when it detects a dangerous command and blocks it.
485
+ The nonce is included in the block response so the agent can present it
486
+ to the user for approval.
487
+
488
+ Args:
489
+ nonce: Cryptographic nonce from generate_nonce().
490
+ command: The command that was blocked.
491
+ danger_verb: The dangerous verb detected (e.g., "push", "apply").
492
+ danger_category: The danger category (e.g., "MUTATIVE", "DESTRUCTIVE").
493
+ session_id: Session ID (defaults to CLAUDE_SESSION_ID env var).
494
+ ttl_minutes: How long the pending approval is valid before expiry
495
+ (0 = no expiry).
496
+ context: Optional dict with enriched context (source, description,
497
+ risk, rollback, branch, files_changed, etc.).
498
+ cwd: Optional working directory where the command was invoked.
499
+ environment: Optional dict with environment state at blocking time.
500
+ If not provided, auto-captured via capture_environment_snapshot().
501
+
502
+ Returns:
503
+ Path to the pending file, or None on failure.
504
+ """
505
+ if session_id is None:
506
+ session_id = _get_session_id()
507
+
508
+ signature = build_approval_signature(
509
+ command,
510
+ scope_type=SCOPE_SEMANTIC_SIGNATURE,
511
+ danger_verb=danger_verb,
512
+ danger_category=danger_category,
513
+ )
514
+ if signature is None:
515
+ logger.error(
516
+ "Failed to build semantic approval signature for pending command: %s",
517
+ command,
518
+ )
519
+ return None
520
+
521
+ # Auto-capture environment if not explicitly provided.
522
+ if environment is None:
523
+ try:
524
+ environment = capture_environment_snapshot(command, cwd=cwd)
525
+ except Exception as exc:
526
+ logger.debug("Auto environment capture failed (non-fatal): %s", exc)
527
+ environment = {}
528
+
529
+ pending_data = {
530
+ "nonce": nonce,
531
+ "session_id": session_id,
532
+ "command": command,
533
+ "danger_verb": danger_verb,
534
+ "danger_category": danger_category,
535
+ "scope_type": signature.scope_type,
536
+ "scope_signature": signature.to_dict(),
537
+ "timestamp": time.time(),
538
+ "ttl_minutes": ttl_minutes,
539
+ "context": context or {},
540
+ "environment": environment,
541
+ }
542
+ if cwd is not None:
543
+ pending_data["cwd"] = cwd
544
+
545
+ try:
546
+ grants_dir = _get_grants_dir()
547
+ pending_file = grants_dir / f"pending-{nonce}.json"
548
+ pending_file.write_text(json.dumps(pending_data, indent=2))
549
+ _rebuild_pending_index(session_id)
550
+
551
+ logger.info(
552
+ "Pending approval written: nonce=%s, verb=%s, category=%s, session=%s",
553
+ nonce, danger_verb, danger_category, session_id,
554
+ )
555
+ return pending_file
556
+
557
+ except Exception as e:
558
+ logger.error("Failed to write pending approval: %s", e)
559
+ return None
560
+
561
+
562
+ def activate_pending_approval(
563
+ nonce: str,
564
+ session_id: Optional[str] = None,
565
+ ttl_minutes: int = DEFAULT_GRANT_TTL_MINUTES,
566
+ ) -> ApprovalActivationResult:
567
+ """Activate a pending approval by converting it to an active grant.
568
+
569
+ Called by the pre_tool_use hook when it detects "APPROVE:{nonce}" in a
570
+ Task resume prompt. Validates the pending file, creates an active grant,
571
+ and deletes the pending file.
572
+
573
+ Args:
574
+ nonce: The nonce from the APPROVE: token.
575
+ session_id: Current session ID for validation.
576
+ ttl_minutes: TTL for the active grant.
577
+
578
+ Returns:
579
+ Structured activation result with status and optional grant path.
580
+ """
581
+ if session_id is None:
582
+ session_id = _get_session_id()
583
+
584
+ try:
585
+ grants_dir = _get_grants_dir()
586
+ pending_file = grants_dir / f"pending-{nonce}.json"
587
+
588
+ # Pending file must exist
589
+ if not pending_file.exists():
590
+ logger.warning(
591
+ "Pending approval not found for nonce %s -- "
592
+ "may have expired or already been activated",
593
+ nonce,
594
+ )
595
+ return ApprovalActivationResult(
596
+ success=False,
597
+ status=ACTIVATION_NOT_FOUND,
598
+ reason="Pending approval not found. It may have expired or already been used.",
599
+ )
600
+
601
+ # Read and validate pending data
602
+ pending_data = json.loads(pending_file.read_text())
603
+
604
+ # Validate nonce matches exactly
605
+ if pending_data.get("nonce") != nonce:
606
+ logger.warning("Nonce mismatch in pending file: expected %s", nonce)
607
+ return ApprovalActivationResult(
608
+ success=False,
609
+ status=ACTIVATION_NONCE_MISMATCH,
610
+ reason="Nonce mismatch while activating approval.",
611
+ )
612
+
613
+ # Validate session matches
614
+ if pending_data.get("session_id") != session_id:
615
+ logger.warning(
616
+ "Session mismatch for nonce %s: pending=%s, current=%s",
617
+ nonce, pending_data.get("session_id"), session_id,
618
+ )
619
+ return ApprovalActivationResult(
620
+ success=False,
621
+ status=ACTIVATION_SESSION_MISMATCH,
622
+ reason="Approval was issued for a different Claude session.",
623
+ )
624
+
625
+ # Validate not expired
626
+ pending_timestamp = pending_data.get("timestamp", 0)
627
+ pending_ttl = pending_data.get("ttl_minutes", DEFAULT_PENDING_TTL_MINUTES)
628
+ if _is_ttl_expired(pending_timestamp, pending_ttl):
629
+ logger.warning(
630
+ "Pending approval expired for nonce %s: TTL=%d min",
631
+ nonce, pending_ttl,
632
+ )
633
+ # Clean up expired pending file
634
+ _cleanup_grant(pending_file)
635
+ _rebuild_pending_index(session_id)
636
+ return ApprovalActivationResult(
637
+ success=False,
638
+ status=ACTIVATION_EXPIRED,
639
+ reason="Approval nonce expired before activation.",
640
+ )
641
+
642
+ command = pending_data.get("command", "")
643
+ danger_verb = pending_data.get("danger_verb", "")
644
+ scope_signature_data = pending_data.get("scope_signature")
645
+ if not scope_signature_data:
646
+ logger.warning("Pending approval for nonce %s is missing scope_signature", nonce)
647
+ _cleanup_grant(pending_file)
648
+ _rebuild_pending_index(session_id)
649
+ return ApprovalActivationResult(
650
+ success=False,
651
+ status=ACTIVATION_INVALID_PENDING,
652
+ reason="Pending approval file is missing a semantic signature.",
653
+ )
654
+
655
+ signature = ApprovalSignature.from_dict(scope_signature_data)
656
+ if signature.scope_type not in (SCOPE_SEMANTIC_SIGNATURE, SCOPE_FILE_PATH):
657
+ logger.warning(
658
+ "Pending approval for nonce %s has unsupported scope_type=%s",
659
+ nonce,
660
+ signature.scope_type,
661
+ )
662
+ _cleanup_grant(pending_file)
663
+ _rebuild_pending_index(session_id)
664
+ return ApprovalActivationResult(
665
+ success=False,
666
+ status=ACTIVATION_INVALID_SIGNATURE,
667
+ reason="Pending approval uses an unsupported scope type.",
668
+ )
669
+
670
+ # For file-path scopes, verb validation is not applicable.
671
+ if signature.scope_type == SCOPE_FILE_PATH:
672
+ verbs = ["write"]
673
+ elif not signature.verb and not danger_verb:
674
+ logger.warning(
675
+ "Could not validate semantic signature for pending approval command: %s",
676
+ command,
677
+ )
678
+ return ApprovalActivationResult(
679
+ success=False,
680
+ status=ACTIVATION_INVALID_SIGNATURE,
681
+ reason="Approval signature could not be validated safely.",
682
+ )
683
+ else:
684
+ verbs = [signature.verb] if signature.verb else ([danger_verb.lower()] if danger_verb else [])
685
+
686
+ # Create active grant
687
+ grant = ApprovalGrant(
688
+ session_id=session_id,
689
+ approved_verbs=verbs,
690
+ approved_scope=command,
691
+ scope_type=signature.scope_type,
692
+ scope_signature=signature.to_dict(),
693
+ granted_at=time.time(),
694
+ ttl_minutes=ttl_minutes,
695
+ )
696
+
697
+ grant_file = grants_dir / f"grant-{session_id}-{int(time.time() * 1000)}-{nonce[:8]}.json"
698
+ grant_file.write_text(json.dumps(asdict(grant), indent=2))
699
+
700
+ # Delete pending file (one-time activation)
701
+ _cleanup_grant(pending_file)
702
+ _rebuild_pending_index(session_id)
703
+
704
+ logger.info(
705
+ "Pending approval activated: nonce=%s, verbs=%s, grant=%s",
706
+ nonce, verbs, grant_file.name,
707
+ )
708
+ return ApprovalActivationResult(
709
+ success=True,
710
+ status=ACTIVATION_ACTIVATED,
711
+ reason="Pending approval activated.",
712
+ grant_path=grant_file,
713
+ )
714
+
715
+ except (json.JSONDecodeError, TypeError) as e:
716
+ logger.error("Invalid pending approval file for nonce %s: %s", nonce, e)
717
+ return ApprovalActivationResult(
718
+ success=False,
719
+ status=ACTIVATION_INVALID_PENDING,
720
+ reason="Pending approval file is invalid or corrupt.",
721
+ )
722
+ except Exception as e:
723
+ logger.error("Failed to activate pending approval: %s", e)
724
+ return ApprovalActivationResult(
725
+ success=False,
726
+ status=ACTIVATION_ERROR,
727
+ reason="Unexpected error while activating approval.",
728
+ )
729
+
730
+ def activate_cross_session_pending(
731
+ pending_data: dict,
732
+ ttl_minutes: int = DEFAULT_GRANT_TTL_MINUTES,
733
+ session_id: Optional[str] = None,
734
+ ) -> ApprovalActivationResult:
735
+ """Create an active grant from a pending file that belongs to a prior session.
736
+
737
+ Called ONLY when the user has already confirmed approval via AskUserQuestion.
738
+ Unlike activate_pending_approval(), this function skips the session_id equality
739
+ check because the pending file is from a previous session whose nonce can never
740
+ match the current session. All other validation (nonce presence, TTL, signature)
741
+ is performed normally.
742
+
743
+ The new grant is created under the CURRENT session ID so that
744
+ check_approval_grant() can find it when the dispatched agent runs the command.
745
+ confirmed is set to True directly because the human has already approved.
746
+
747
+ Args:
748
+ pending_data: The dict loaded from a pending-{nonce}.json file.
749
+ ttl_minutes: TTL for the active grant (default DEFAULT_GRANT_TTL_MINUTES).
750
+ session_id: Optional explicit session ID to use for the new grant. When
751
+ provided this value is used directly, which avoids relying on the
752
+ CLAUDE_SESSION_ID environment variable -- important when the function
753
+ is called from a dispatched agent's subprocess where the env var may
754
+ not be set. Defaults to None, which falls back to _get_session_id()
755
+ (backward compatible).
756
+
757
+ Returns:
758
+ Structured activation result with status and optional grant path.
759
+ """
760
+ current_session_id = session_id if session_id is not None else _get_session_id()
761
+
762
+ try:
763
+ grants_dir = _get_grants_dir()
764
+
765
+ # Validate required fields
766
+ nonce = pending_data.get("nonce")
767
+ if not nonce:
768
+ return ApprovalActivationResult(
769
+ success=False,
770
+ status=ACTIVATION_INVALID_PENDING,
771
+ reason="Pending approval file is missing a nonce.",
772
+ )
773
+
774
+ pending_file = grants_dir / f"pending-{nonce}.json"
775
+
776
+ # Validate not expired (TTL check still applies)
777
+ pending_timestamp = pending_data.get("timestamp", 0)
778
+ pending_ttl = pending_data.get("ttl_minutes", DEFAULT_PENDING_TTL_MINUTES)
779
+ if _is_ttl_expired(pending_timestamp, pending_ttl):
780
+ logger.warning(
781
+ "Cross-session pending approval expired for nonce %s: TTL=%d min",
782
+ nonce, pending_ttl,
783
+ )
784
+ _cleanup_grant(pending_file)
785
+ prior_session_id = pending_data.get("session_id", "unknown")
786
+ _rebuild_pending_index(prior_session_id)
787
+ return ApprovalActivationResult(
788
+ success=False,
789
+ status=ACTIVATION_EXPIRED,
790
+ reason="Approval nonce expired before cross-session activation.",
791
+ )
792
+
793
+ command = pending_data.get("command", "")
794
+ danger_verb = pending_data.get("danger_verb", "")
795
+ scope_signature_data = pending_data.get("scope_signature")
796
+ if not scope_signature_data:
797
+ logger.warning(
798
+ "Cross-session pending approval for nonce %s is missing scope_signature",
799
+ nonce,
800
+ )
801
+ _cleanup_grant(pending_file)
802
+ prior_session_id = pending_data.get("session_id", "unknown")
803
+ _rebuild_pending_index(prior_session_id)
804
+ return ApprovalActivationResult(
805
+ success=False,
806
+ status=ACTIVATION_INVALID_PENDING,
807
+ reason="Pending approval file is missing a semantic signature.",
808
+ )
809
+
810
+ signature = ApprovalSignature.from_dict(scope_signature_data)
811
+ if signature.scope_type not in (SCOPE_SEMANTIC_SIGNATURE, SCOPE_FILE_PATH):
812
+ logger.warning(
813
+ "Cross-session pending for nonce %s has unsupported scope_type=%s",
814
+ nonce,
815
+ signature.scope_type,
816
+ )
817
+ _cleanup_grant(pending_file)
818
+ prior_session_id = pending_data.get("session_id", "unknown")
819
+ _rebuild_pending_index(prior_session_id)
820
+ return ApprovalActivationResult(
821
+ success=False,
822
+ status=ACTIVATION_INVALID_SIGNATURE,
823
+ reason="Pending approval uses an unsupported scope type.",
824
+ )
825
+
826
+ # For file-path scopes, verb validation is not applicable.
827
+ if signature.scope_type == SCOPE_FILE_PATH:
828
+ verbs = ["write"]
829
+ elif not signature.verb and not danger_verb:
830
+ logger.warning(
831
+ "Could not validate semantic signature for cross-session command: %s",
832
+ command,
833
+ )
834
+ return ApprovalActivationResult(
835
+ success=False,
836
+ status=ACTIVATION_INVALID_SIGNATURE,
837
+ reason="Approval signature could not be validated safely.",
838
+ )
839
+ else:
840
+ verbs = [signature.verb] if signature.verb else ([danger_verb.lower()] if danger_verb else [])
841
+
842
+ # Create active grant under the CURRENT session; confirmed=True because
843
+ # the human already approved via AskUserQuestion.
844
+ grant = ApprovalGrant(
845
+ session_id=current_session_id,
846
+ approved_verbs=verbs,
847
+ approved_scope=command,
848
+ scope_type=signature.scope_type,
849
+ scope_signature=signature.to_dict(),
850
+ granted_at=time.time(),
851
+ ttl_minutes=ttl_minutes,
852
+ confirmed=True,
853
+ )
854
+
855
+ grant_file = grants_dir / f"grant-{current_session_id}-{int(time.time() * 1000)}-{nonce[:8]}.json"
856
+ grant_file.write_text(json.dumps(asdict(grant), indent=2))
857
+
858
+ # Delete the old pending file (one-time activation)
859
+ _cleanup_grant(pending_file)
860
+ prior_session_id = pending_data.get("session_id", "unknown")
861
+ _rebuild_pending_index(prior_session_id)
862
+
863
+ logger.info(
864
+ "Cross-session pending activated: nonce=%s, prior_session=%s, "
865
+ "current_session=%s, verbs=%s, grant=%s",
866
+ nonce, prior_session_id, current_session_id, verbs, grant_file.name,
867
+ )
868
+ return ApprovalActivationResult(
869
+ success=True,
870
+ status=ACTIVATION_ACTIVATED,
871
+ reason="Cross-session pending approval activated.",
872
+ grant_path=grant_file,
873
+ )
874
+
875
+ except (json.JSONDecodeError, TypeError) as e:
876
+ logger.error("Invalid pending approval data for cross-session activation: %s", e)
877
+ return ApprovalActivationResult(
878
+ success=False,
879
+ status=ACTIVATION_INVALID_PENDING,
880
+ reason="Pending approval data is invalid or corrupt.",
881
+ )
882
+ except Exception as e:
883
+ logger.error("Failed to activate cross-session pending approval: %s", e)
884
+ return ApprovalActivationResult(
885
+ success=False,
886
+ status=ACTIVATION_ERROR,
887
+ reason="Unexpected error while activating cross-session approval.",
888
+ )
889
+
890
+
891
+ def check_approval_grant(command: str, session_id: str = None) -> Optional[ApprovalGrant]:
892
+ """Check if there is an active approval grant for a command.
893
+
894
+ Called by the bash_validator before blocking a dangerous command.
895
+ If a valid grant exists that matches the command, the command should
896
+ be allowed through.
897
+
898
+ Args:
899
+ command: The shell command to check.
900
+ session_id: Session ID for grant scoping (defaults to env var).
901
+
902
+ Returns:
903
+ The matching ApprovalGrant if found and valid, None otherwise.
904
+ """
905
+ global _last_check_found_expired
906
+ _last_check_found_expired = False
907
+
908
+ if not session_id:
909
+ session_id = _get_session_id()
910
+
911
+ try:
912
+ grants_dir = _get_grants_dir()
913
+ if not grants_dir.exists():
914
+ return None
915
+
916
+ # Scan grant files for this session
917
+ for grant_file in sorted(grants_dir.glob(f"grant-{session_id}-*.json")):
918
+ try:
919
+ data = json.loads(grant_file.read_text())
920
+ grant = ApprovalGrant(**data)
921
+
922
+ # Skip expired or used grants
923
+ if not grant.is_valid():
924
+ # Clean up expired grants; track if it would have matched
925
+ if grant.is_expired():
926
+ if grant.matches_command(command):
927
+ _last_check_found_expired = True
928
+ _cleanup_grant(grant_file)
929
+ continue
930
+
931
+ signature = grant.get_signature()
932
+ if signature is None or signature.scope_type not in SUPPORTED_SCOPE_TYPES:
933
+ logger.warning("Removing unsupported approval grant file %s", grant_file)
934
+ _cleanup_grant(grant_file)
935
+ continue
936
+
937
+ # Check if command matches the explicit scope signature
938
+ if grant.matches_command(command):
939
+ logger.info(
940
+ "Approval grant matched: command='%s', scope='%s', type=%s",
941
+ command[:80], grant.approved_scope, grant.scope_type,
942
+ )
943
+ return grant
944
+
945
+ except (json.JSONDecodeError, TypeError) as e:
946
+ logger.warning("Invalid grant file %s: %s", grant_file, e)
947
+ _cleanup_grant(grant_file)
948
+ continue
949
+
950
+ except Exception as e:
951
+ logger.error("Error checking approval grants: %s", e)
952
+
953
+ return None
954
+
955
+
956
+ def consume_grant(command: str, session_id: str = None) -> bool:
957
+ """Mark the first matching valid grant as used and persist to disk.
958
+
959
+ Called by bash_validator immediately after check_approval_grant() returns
960
+ a match, so that the grant can only be used once (single-use).
961
+
962
+ Args:
963
+ command: The shell command whose grant should be consumed.
964
+ session_id: Session ID for grant scoping (defaults to env var).
965
+
966
+ Returns:
967
+ True if a grant was found and consumed, False otherwise.
968
+ """
969
+ if not session_id:
970
+ session_id = _get_session_id()
971
+
972
+ try:
973
+ grants_dir = _get_grants_dir()
974
+ if not grants_dir.exists():
975
+ return False
976
+
977
+ for grant_file in sorted(grants_dir.glob(f"grant-{session_id}-*.json")):
978
+ try:
979
+ data = json.loads(grant_file.read_text())
980
+ grant = ApprovalGrant(**data)
981
+
982
+ if not grant.is_valid():
983
+ if grant.is_expired():
984
+ _cleanup_grant(grant_file)
985
+ continue
986
+
987
+ signature = grant.get_signature()
988
+ if signature is None or signature.scope_type not in SUPPORTED_SCOPE_TYPES:
989
+ continue
990
+
991
+ if grant.matches_command(command):
992
+ if grant.multi_use:
993
+ logger.info(
994
+ "Grant matched (multi-use, not consumed): command='%s', grant=%s",
995
+ command[:80], grant_file.name,
996
+ )
997
+ return True
998
+ data["used"] = True
999
+ grant_file.write_text(json.dumps(data, indent=2))
1000
+ logger.info(
1001
+ "Grant consumed (single-use): command='%s', grant=%s",
1002
+ command[:80], grant_file.name,
1003
+ )
1004
+ return True
1005
+
1006
+ except (json.JSONDecodeError, TypeError):
1007
+ continue
1008
+
1009
+ except Exception as e:
1010
+ logger.error("Error consuming grant: %s", e)
1011
+
1012
+ return False
1013
+
1014
+
1015
+ def consume_session_grants(session_id: str = None) -> int:
1016
+ """Consume all confirmed grants for a session.
1017
+
1018
+ Called at SubagentStop to clean up all grants that were used during the
1019
+ subagent's lifetime. Multi-use grants are also consumed (session is over).
1020
+
1021
+ Args:
1022
+ session_id: Session ID to scope consumption (defaults to env var).
1023
+
1024
+ Returns:
1025
+ Number of grants consumed.
1026
+ """
1027
+ if not session_id:
1028
+ session_id = _get_session_id()
1029
+
1030
+ consumed_count = 0
1031
+ try:
1032
+ grants_dir = _get_grants_dir()
1033
+ if not grants_dir.exists():
1034
+ return 0
1035
+
1036
+ for grant_file in sorted(grants_dir.glob(f"grant-{session_id}-*.json")):
1037
+ try:
1038
+ data = json.loads(grant_file.read_text())
1039
+ grant = ApprovalGrant(**data)
1040
+
1041
+ if grant.used:
1042
+ continue # already consumed
1043
+
1044
+ if not grant.is_valid():
1045
+ if grant.is_expired():
1046
+ _cleanup_grant(grant_file)
1047
+ continue
1048
+
1049
+ # Consume all confirmed grants (single-use and multi-use)
1050
+ if grant.confirmed:
1051
+ data["used"] = True
1052
+ grant_file.write_text(json.dumps(data, indent=2))
1053
+ consumed_count += 1
1054
+ logger.info(
1055
+ "Grant consumed at SubagentStop: grant=%s, multi_use=%s",
1056
+ grant_file.name, grant.multi_use,
1057
+ )
1058
+
1059
+ except (json.JSONDecodeError, TypeError):
1060
+ continue
1061
+
1062
+ except Exception as e:
1063
+ logger.error("Error consuming session grants: %s", e)
1064
+
1065
+ return consumed_count
1066
+
1067
+
1068
+ def confirm_grant(command: str, session_id: str = None) -> bool:
1069
+ """Mark the first unconfirmed grant matching command as confirmed.
1070
+
1071
+ Called after the native permission dialog accepts the first T3 execution.
1072
+ Subsequent T3 commands within the TTL window will see ``confirmed=True``
1073
+ and be auto-allowed without a native dialog.
1074
+
1075
+ Args:
1076
+ command: The shell command whose grant should be confirmed.
1077
+ session_id: Session ID for grant scoping (defaults to env var).
1078
+
1079
+ Returns:
1080
+ True if a grant was found and confirmed, False otherwise.
1081
+ """
1082
+ if not session_id:
1083
+ session_id = _get_session_id()
1084
+
1085
+ try:
1086
+ grants_dir = _get_grants_dir()
1087
+ if not grants_dir.exists():
1088
+ return False
1089
+
1090
+ for grant_file in sorted(grants_dir.glob(f"grant-{session_id}-*.json")):
1091
+ try:
1092
+ data = json.loads(grant_file.read_text())
1093
+ grant = ApprovalGrant(**data)
1094
+
1095
+ if not grant.is_valid():
1096
+ if grant.is_expired():
1097
+ _cleanup_grant(grant_file)
1098
+ continue
1099
+
1100
+ if grant.confirmed:
1101
+ continue
1102
+
1103
+ signature = grant.get_signature()
1104
+ if signature is None or signature.scope_type not in SUPPORTED_SCOPE_TYPES:
1105
+ continue
1106
+
1107
+ if grant.matches_command(command):
1108
+ data["confirmed"] = True
1109
+ grant_file.write_text(json.dumps(data, indent=2))
1110
+ logger.info(
1111
+ "Grant confirmed: command='%s', grant=%s",
1112
+ command[:80], grant_file.name,
1113
+ )
1114
+ return True
1115
+
1116
+ except (json.JSONDecodeError, TypeError):
1117
+ continue
1118
+
1119
+ except Exception as e:
1120
+ logger.error("Error confirming grant: %s", e)
1121
+
1122
+ return False
1123
+
1124
+
1125
+ def cleanup_expired_grants() -> int:
1126
+ """Remove expired grant and pending files.
1127
+
1128
+ Called periodically (e.g., at hook startup) to prevent accumulation.
1129
+ Throttled to run at most once every _CLEANUP_INTERVAL_SECONDS.
1130
+
1131
+ Returns:
1132
+ Number of files cleaned up.
1133
+ """
1134
+ global _last_cleanup_time
1135
+ now = time.time()
1136
+ if now - _last_cleanup_time < _CLEANUP_INTERVAL_SECONDS:
1137
+ return 0
1138
+ _last_cleanup_time = now
1139
+
1140
+ cleaned = 0
1141
+ sessions_to_rebuild: set[str] = set()
1142
+ try:
1143
+ grants_dir = _get_grants_dir()
1144
+ if not grants_dir.exists():
1145
+ return 0
1146
+
1147
+ # Clean up expired active grants
1148
+ for grant_file in grants_dir.glob("grant-*.json"):
1149
+ try:
1150
+ data = json.loads(grant_file.read_text())
1151
+ grant = ApprovalGrant(**data)
1152
+ signature = grant.get_signature()
1153
+ if signature is None or signature.scope_type not in SUPPORTED_SCOPE_TYPES:
1154
+ _cleanup_grant(grant_file)
1155
+ cleaned += 1
1156
+ continue
1157
+ if grant.is_expired():
1158
+ _cleanup_grant(grant_file)
1159
+ cleaned += 1
1160
+ except Exception:
1161
+ # Corrupt file, remove it
1162
+ _cleanup_grant(grant_file)
1163
+ cleaned += 1
1164
+
1165
+ # Clean up expired pending approvals
1166
+ for pending_file in grants_dir.glob("pending-*.json"):
1167
+ if pending_file.name.startswith("pending-index-"):
1168
+ continue
1169
+ try:
1170
+ data = json.loads(pending_file.read_text())
1171
+ session_id = data.get("session_id")
1172
+ if not data.get("scope_signature"):
1173
+ _cleanup_grant(pending_file)
1174
+ if session_id:
1175
+ sessions_to_rebuild.add(session_id)
1176
+ cleaned += 1
1177
+ continue
1178
+ if _is_rejected(data):
1179
+ _cleanup_grant(pending_file)
1180
+ if session_id:
1181
+ sessions_to_rebuild.add(session_id)
1182
+ cleaned += 1
1183
+ continue
1184
+ timestamp = data.get("timestamp", 0)
1185
+ ttl = data.get("ttl_minutes", DEFAULT_PENDING_TTL_MINUTES)
1186
+ if _is_ttl_expired(timestamp, ttl):
1187
+ _cleanup_grant(pending_file)
1188
+ if session_id:
1189
+ sessions_to_rebuild.add(session_id)
1190
+ cleaned += 1
1191
+ except Exception:
1192
+ # Corrupt file, remove it
1193
+ data = _read_json_file(pending_file)
1194
+ if data and data.get("session_id"):
1195
+ sessions_to_rebuild.add(data["session_id"])
1196
+ _cleanup_grant(pending_file)
1197
+ cleaned += 1
1198
+
1199
+ except Exception as e:
1200
+ logger.error("Error during grant cleanup: %s", e)
1201
+
1202
+ for session_id in sessions_to_rebuild:
1203
+ _rebuild_pending_index(session_id)
1204
+
1205
+ if cleaned:
1206
+ logger.info("Cleaned up %d expired approval/pending files", cleaned)
1207
+ return cleaned
1208
+
1209
+
1210
+ def get_pending_approvals_for_session(
1211
+ session_id: Optional[str] = None,
1212
+ ) -> List[Dict[str, Any]]:
1213
+ """Return all non-expired pending approvals for a session.
1214
+
1215
+ Args:
1216
+ session_id: Session ID to filter by (defaults to current session).
1217
+
1218
+ Returns:
1219
+ List of pending approval dicts, newest first.
1220
+ """
1221
+ if session_id is None:
1222
+ session_id = _get_session_id()
1223
+
1224
+ results: List[Dict[str, Any]] = []
1225
+ try:
1226
+ grants_dir = _get_grants_dir()
1227
+ for pending_file in grants_dir.glob("pending-*.json"):
1228
+ if pending_file.name.startswith("pending-index-"):
1229
+ continue
1230
+ data = _read_json_file(pending_file)
1231
+ if not data or data.get("session_id") != session_id:
1232
+ continue
1233
+ if _is_rejected(data):
1234
+ continue
1235
+ timestamp = data.get("timestamp", 0)
1236
+ ttl = data.get("ttl_minutes", DEFAULT_PENDING_TTL_MINUTES)
1237
+ if _is_ttl_expired(float(timestamp), int(ttl)):
1238
+ continue
1239
+ results.append(data)
1240
+ except Exception as e:
1241
+ logger.error("Error listing pending approvals for session %s: %s", session_id, e)
1242
+
1243
+ results.sort(key=lambda d: d.get("timestamp", 0), reverse=True)
1244
+ return results
1245
+
1246
+
1247
+ def find_pending_for_command(
1248
+ session_id: str,
1249
+ command: str,
1250
+ ) -> Optional[str]:
1251
+ """Find an existing pending approval nonce for this command and session.
1252
+
1253
+ When a subagent retries a blocked T3 command, a pending approval may
1254
+ already exist from the first attempt. Reusing the existing nonce
1255
+ prevents the infinite-loop of generating a new approval_id on every
1256
+ retry while the user is still reviewing the first one.
1257
+
1258
+ Args:
1259
+ session_id: Session to search.
1260
+ command: The command to match against pending approvals.
1261
+
1262
+ Returns:
1263
+ The nonce (approval_id) if a matching pending approval exists, else None.
1264
+ """
1265
+ pending_list = get_pending_approvals_for_session(session_id)
1266
+ if not pending_list:
1267
+ return None
1268
+
1269
+ # Build a signature for the incoming command to compare semantically
1270
+ target_sig = build_approval_signature(
1271
+ command,
1272
+ scope_type=SCOPE_SEMANTIC_SIGNATURE,
1273
+ )
1274
+ if target_sig is None:
1275
+ return None
1276
+
1277
+ for pending_data in pending_list:
1278
+ pending_sig_data = pending_data.get("scope_signature")
1279
+ if not pending_sig_data:
1280
+ continue
1281
+ try:
1282
+ pending_sig = ApprovalSignature.from_dict(pending_sig_data)
1283
+ if matches_approval_signature(pending_sig, command):
1284
+ nonce = pending_data.get("nonce")
1285
+ if nonce:
1286
+ logger.info(
1287
+ "Reusing existing pending approval nonce=%s for command: %s",
1288
+ nonce, command[:80],
1289
+ )
1290
+ return nonce
1291
+ except Exception:
1292
+ continue
1293
+
1294
+ return None
1295
+
1296
+
1297
+ def reject_pending(nonce_prefix: str) -> bool:
1298
+ """Mark a pending approval as rejected without deleting the file.
1299
+
1300
+ Finds the pending file whose nonce starts with ``nonce_prefix``, sets
1301
+ ``status`` to ``"rejected"`` and ``rejected_at`` to the current time,
1302
+ writes the file back, and rebuilds the session index.
1303
+
1304
+ Rejected pendings are invisible to all readers (``_is_rejected`` filter)
1305
+ and are cleaned up by the pending scanner on its next sweep.
1306
+
1307
+ Args:
1308
+ nonce_prefix: Hex prefix of the nonce (typically 8 chars from ``[P-xxx]``).
1309
+
1310
+ Returns:
1311
+ True if a matching pending was found and rejected, False otherwise.
1312
+ """
1313
+ try:
1314
+ grants_dir = _get_grants_dir()
1315
+ for pending_file in grants_dir.glob("pending-*.json"):
1316
+ if pending_file.name.startswith("pending-index-"):
1317
+ continue
1318
+ fname_nonce = pending_file.stem.removeprefix("pending-")
1319
+ if not fname_nonce.startswith(nonce_prefix):
1320
+ continue
1321
+ data = _read_json_file(pending_file)
1322
+ if not data or _is_rejected(data):
1323
+ continue
1324
+ data["status"] = "rejected"
1325
+ data["rejected_at"] = time.time()
1326
+ pending_file.write_text(json.dumps(data, indent=2))
1327
+ session_id = data.get("session_id")
1328
+ if session_id:
1329
+ _rebuild_pending_index(session_id)
1330
+ logger.info(
1331
+ "Pending approval rejected: nonce_prefix=%s, nonce=%s",
1332
+ nonce_prefix, data.get("nonce", "?"),
1333
+ )
1334
+ return True
1335
+ except Exception as e:
1336
+ logger.error("Error rejecting pending approval for prefix %s: %s", nonce_prefix, e)
1337
+ return False
1338
+
1339
+
1340
+ def write_pending_approval_for_file(
1341
+ nonce: str,
1342
+ file_path: str,
1343
+ session_id: Optional[str] = None,
1344
+ ttl_minutes: int = DEFAULT_PENDING_TTL_MINUTES,
1345
+ context: Optional[Dict[str, Any]] = None,
1346
+ ) -> Optional[Path]:
1347
+ """Write a pending approval file when a Write/Edit to a protected path is blocked.
1348
+
1349
+ Analogous to write_pending_approval() but uses SCOPE_FILE_PATH so that
1350
+ the file path (not a shell command) is the scope identifier.
1351
+
1352
+ Args:
1353
+ nonce: Cryptographic nonce from generate_nonce().
1354
+ file_path: The absolute path of the file being written/edited.
1355
+ session_id: Session ID (defaults to CLAUDE_SESSION_ID env var).
1356
+ ttl_minutes: How long the pending approval is valid before expiry
1357
+ (0 = no expiry).
1358
+ context: Optional dict with enriched context (source, description,
1359
+ risk, rollback, branch, files_changed, etc.).
1360
+
1361
+ Returns:
1362
+ Path to the pending file, or None on failure.
1363
+ """
1364
+ if session_id is None:
1365
+ session_id = _get_session_id()
1366
+
1367
+ signature = build_file_path_signature(file_path)
1368
+ if signature is None:
1369
+ logger.error(
1370
+ "Failed to build file-path approval signature for pending file: %s",
1371
+ file_path,
1372
+ )
1373
+ return None
1374
+
1375
+ pending_data = {
1376
+ "nonce": nonce,
1377
+ "session_id": session_id,
1378
+ "command": file_path,
1379
+ "danger_verb": "write",
1380
+ "danger_category": "FILE_WRITE",
1381
+ "scope_type": signature.scope_type,
1382
+ "scope_signature": signature.to_dict(),
1383
+ "timestamp": time.time(),
1384
+ "ttl_minutes": ttl_minutes,
1385
+ "context": context or {},
1386
+ }
1387
+
1388
+ try:
1389
+ grants_dir = _get_grants_dir()
1390
+ pending_file = grants_dir / f"pending-{nonce}.json"
1391
+ pending_file.write_text(json.dumps(pending_data, indent=2))
1392
+ _rebuild_pending_index(session_id)
1393
+
1394
+ logger.info(
1395
+ "Pending file-path approval written: nonce=%s, file=%s, session=%s",
1396
+ nonce, file_path, session_id,
1397
+ )
1398
+ return pending_file
1399
+
1400
+ except Exception as e:
1401
+ logger.error("Failed to write pending file-path approval: %s", e)
1402
+ return None
1403
+
1404
+
1405
+ def check_approval_grant_for_file(
1406
+ file_path: str,
1407
+ session_id: str = None,
1408
+ ) -> Optional[ApprovalGrant]:
1409
+ """Check if there is an active approval grant for a Write/Edit file path.
1410
+
1411
+ Called by _adapt_write_edit before blocking a protected-path write. If
1412
+ a valid SCOPE_FILE_PATH grant exists for this path, the write should be
1413
+ allowed through.
1414
+
1415
+ Args:
1416
+ file_path: The file path being written/edited.
1417
+ session_id: Session ID for grant scoping (defaults to env var).
1418
+
1419
+ Returns:
1420
+ The matching ApprovalGrant if found and valid, None otherwise.
1421
+ """
1422
+ if not session_id:
1423
+ session_id = _get_session_id()
1424
+
1425
+ try:
1426
+ grants_dir = _get_grants_dir()
1427
+ if not grants_dir.exists():
1428
+ return None
1429
+
1430
+ for grant_file in sorted(grants_dir.glob(f"grant-{session_id}-*.json")):
1431
+ try:
1432
+ data = json.loads(grant_file.read_text())
1433
+ grant = ApprovalGrant(**data)
1434
+
1435
+ if not grant.is_valid():
1436
+ if grant.is_expired():
1437
+ _cleanup_grant(grant_file)
1438
+ continue
1439
+
1440
+ signature = grant.get_signature()
1441
+ if signature is None or signature.scope_type != SCOPE_FILE_PATH:
1442
+ continue
1443
+
1444
+ if matches_file_path_approval(signature, file_path):
1445
+ logger.info(
1446
+ "File-path approval grant matched: file='%s', grant=%s",
1447
+ file_path, grant_file.name,
1448
+ )
1449
+ return grant
1450
+
1451
+ except (json.JSONDecodeError, TypeError) as e:
1452
+ logger.warning("Invalid grant file %s: %s", grant_file, e)
1453
+ _cleanup_grant(grant_file)
1454
+ continue
1455
+
1456
+ except Exception as e:
1457
+ logger.error("Error checking file-path approval grants: %s", e)
1458
+
1459
+ return None
1460
+
1461
+
1462
+ def find_pending_for_file(
1463
+ session_id: str,
1464
+ file_path: str,
1465
+ ) -> Optional[str]:
1466
+ """Find an existing pending approval nonce for this file path and session.
1467
+
1468
+ When a subagent retries a blocked Write/Edit, a pending approval may
1469
+ already exist from the first attempt. Reusing the existing nonce
1470
+ prevents generating a new approval_id on every retry while the user
1471
+ reviews the first one.
1472
+
1473
+ Args:
1474
+ session_id: Session to search.
1475
+ file_path: The file path to match against pending approvals.
1476
+
1477
+ Returns:
1478
+ The nonce (approval_id) if a matching pending approval exists, else None.
1479
+ """
1480
+ pending_list = get_pending_approvals_for_session(session_id)
1481
+ if not pending_list:
1482
+ return None
1483
+
1484
+ stripped = file_path.strip() if file_path else ""
1485
+ for pending_data in pending_list:
1486
+ pending_sig_data = pending_data.get("scope_signature")
1487
+ if not pending_sig_data:
1488
+ continue
1489
+ try:
1490
+ pending_sig = ApprovalSignature.from_dict(pending_sig_data)
1491
+ if matches_file_path_approval(pending_sig, stripped):
1492
+ nonce = pending_data.get("nonce")
1493
+ if nonce:
1494
+ logger.info(
1495
+ "Reusing existing pending file-path approval nonce=%s for file: %s",
1496
+ nonce, file_path,
1497
+ )
1498
+ return nonce
1499
+ except Exception:
1500
+ continue
1501
+
1502
+ return None
1503
+
1504
+
1505
+ def activate_grants_for_session(
1506
+ session_id: Optional[str] = None,
1507
+ ttl_minutes: int = DEFAULT_GRANT_TTL_MINUTES,
1508
+ ) -> List[ApprovalActivationResult]:
1509
+ """Activate ALL pending approvals for a session.
1510
+
1511
+ Called by the ElicitationResult hook when the user approves via
1512
+ AskUserQuestion. Converts every non-expired pending approval for the
1513
+ session into an active grant.
1514
+
1515
+ Args:
1516
+ session_id: Session to activate for (defaults to current session).
1517
+ ttl_minutes: TTL for the resulting active grants.
1518
+
1519
+ Returns:
1520
+ List of activation results (one per pending approval).
1521
+ """
1522
+ if session_id is None:
1523
+ session_id = _get_session_id()
1524
+
1525
+ pending_list = get_pending_approvals_for_session(session_id)
1526
+ results: List[ApprovalActivationResult] = []
1527
+
1528
+ for pending_data in pending_list:
1529
+ nonce = pending_data.get("nonce", "")
1530
+ if not nonce:
1531
+ continue
1532
+ result = activate_pending_approval(
1533
+ nonce=nonce,
1534
+ session_id=session_id,
1535
+ ttl_minutes=ttl_minutes,
1536
+ )
1537
+ results.append(result)
1538
+ logger.info(
1539
+ "Session-wide activation: nonce=%s status=%s",
1540
+ nonce,
1541
+ getattr(result.status, "value", str(result.status)),
1542
+ )
1543
+
1544
+ return results
1545
+
1546
+
1547
+ # ============================================================================
1548
+ # Batch (Verb-Family) Grant Creation
1549
+ # ============================================================================
1550
+
1551
+ DEFAULT_BATCH_TTL_MINUTES = 10
1552
+
1553
+
1554
+ def create_verb_family_grant(
1555
+ session_id: str,
1556
+ base_cmd: str,
1557
+ verb: str,
1558
+ danger_category: str = "",
1559
+ ttl_minutes: int = DEFAULT_BATCH_TTL_MINUTES,
1560
+ ) -> Optional[Path]:
1561
+ """Create a multi-use SCOPE_VERB_FAMILY grant directly (no pending phase).
1562
+
1563
+ Called when the user approves a batch operation. The resulting grant
1564
+ matches any command with the same ``base_cmd`` and ``verb``, regardless
1565
+ of arguments or non-dangerous flags, and is NOT consumed after a single
1566
+ use. It expires after ``ttl_minutes``.
1567
+
1568
+ Args:
1569
+ session_id: The Claude session that owns this grant.
1570
+ base_cmd: CLI base command (e.g., "gws", "kubectl").
1571
+ verb: The mutative verb (e.g., "modify", "delete").
1572
+ danger_category: Optional danger category for stricter matching.
1573
+ ttl_minutes: Grant lifetime in minutes (default 10).
1574
+
1575
+ Returns:
1576
+ Path to the grant file, or None on failure.
1577
+ """
1578
+ from .mutative_verbs import CATEGORY_UNKNOWN, CLI_FAMILY_LOOKUP
1579
+
1580
+ if not session_id or not base_cmd or not verb:
1581
+ logger.error(
1582
+ "create_verb_family_grant called with missing required args: "
1583
+ "session_id=%s, base_cmd=%s, verb=%s",
1584
+ session_id, base_cmd, verb,
1585
+ )
1586
+ return None
1587
+
1588
+ resolved_category = danger_category if danger_category else CATEGORY_UNKNOWN
1589
+ cli_family = CLI_FAMILY_LOOKUP.get(base_cmd, "unknown")
1590
+
1591
+ signature = ApprovalSignature(
1592
+ scope_type=SCOPE_VERB_FAMILY,
1593
+ base_cmd=base_cmd,
1594
+ cli_family=cli_family,
1595
+ danger_category=resolved_category,
1596
+ verb=verb.lower(),
1597
+ # Intentionally empty -- verb_family matching ignores these:
1598
+ semantic_tokens=(),
1599
+ normalized_flags=(),
1600
+ dangerous_flags=(),
1601
+ exact_tokens=(),
1602
+ )
1603
+
1604
+ grant = ApprovalGrant(
1605
+ session_id=session_id,
1606
+ approved_verbs=[verb.lower()],
1607
+ approved_scope=f"batch:{base_cmd} {verb}",
1608
+ scope_type=SCOPE_VERB_FAMILY,
1609
+ scope_signature=signature.to_dict(),
1610
+ granted_at=time.time(),
1611
+ ttl_minutes=ttl_minutes,
1612
+ used=False,
1613
+ confirmed=False,
1614
+ multi_use=True,
1615
+ )
1616
+
1617
+ try:
1618
+ grants_dir = _get_grants_dir()
1619
+ grant_file = grants_dir / f"grant-{session_id}-batch-{int(time.time() * 1000)}.json"
1620
+ grant_file.write_text(json.dumps(asdict(grant), indent=2))
1621
+ logger.info(
1622
+ "Verb-family batch grant created: base_cmd=%s, verb=%s, "
1623
+ "ttl=%d min, session=%s, file=%s",
1624
+ base_cmd, verb, ttl_minutes, session_id[:12], grant_file.name,
1625
+ )
1626
+ return grant_file
1627
+
1628
+ except Exception as e:
1629
+ logger.error("Failed to create verb-family grant: %s", e)
1630
+ return None
1631
+
1632
+
1633
+ def _cleanup_grant(grant_file: Path) -> None:
1634
+ """Remove a single grant or pending file."""
1635
+ try:
1636
+ grant_file.unlink(missing_ok=True)
1637
+ except Exception as e:
1638
+ logger.warning("Failed to remove grant file %s: %s", grant_file, e)