@jaguilar87/gaia 5.0.0-rc1
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.
- package/.claude-plugin/marketplace.json +33 -0
- package/.claude-plugin/plugin.json +26 -0
- package/ARCHITECTURE.md +335 -0
- package/CHANGELOG.md +1212 -0
- package/CODE_OF_CONDUCT.md +11 -0
- package/CONTRIBUTING.md +146 -0
- package/INSTALL.md +436 -0
- package/LICENSE +21 -0
- package/README.md +222 -0
- package/SECURITY.md +47 -0
- package/agents/README.md +78 -0
- package/agents/cloud-troubleshooter.md +73 -0
- package/agents/developer.md +65 -0
- package/agents/gaia-operator.md +64 -0
- package/agents/gaia-orchestrator.md +237 -0
- package/agents/gaia-planner.md +53 -0
- package/agents/gaia-system.md +70 -0
- package/agents/gitops-operator.md +61 -0
- package/agents/terraform-architect.md +63 -0
- package/bin/README.md +106 -0
- package/bin/cli/__init__.py +1 -0
- package/bin/cli/approvals.py +740 -0
- package/bin/cli/cleanup.py +562 -0
- package/bin/cli/context.py +283 -0
- package/bin/cli/doctor.py +628 -0
- package/bin/cli/history.py +305 -0
- package/bin/cli/memory.py +464 -0
- package/bin/cli/metrics.py +1068 -0
- package/bin/cli/plans.py +515 -0
- package/bin/cli/status.py +302 -0
- package/bin/cli/update.py +382 -0
- package/bin/gaia +112 -0
- package/bin/gaia-cleanup.js +531 -0
- package/bin/gaia-doctor.js +635 -0
- package/bin/gaia-evidence +126 -0
- package/bin/gaia-history.js +251 -0
- package/bin/gaia-metrics.js +1278 -0
- package/bin/gaia-review.js +269 -0
- package/bin/gaia-scan +44 -0
- package/bin/gaia-scan.py +589 -0
- package/bin/gaia-skills-diagnose.js +929 -0
- package/bin/gaia-status.js +278 -0
- package/bin/gaia-uninstall.js +111 -0
- package/bin/gaia-update.js +816 -0
- package/bin/pre-publish-validate.js +610 -0
- package/bin/python-detect.js +60 -0
- package/commands/README.md +64 -0
- package/commands/gaia.md +37 -0
- package/commands/scan-project.md +67 -0
- package/config/README.md +71 -0
- package/config/cloud/aws.json +134 -0
- package/config/cloud/gcp.json +139 -0
- package/config/context-contracts.json +158 -0
- package/config/crons-schema.md +81 -0
- package/config/git_standards.json +72 -0
- package/config/surface-routing.json +421 -0
- package/config/universal-rules.json +102 -0
- package/dist/gaia-ops/.claude-plugin/plugin.json +24 -0
- package/dist/gaia-ops/README.md +80 -0
- package/dist/gaia-ops/agents/cloud-troubleshooter.md +73 -0
- package/dist/gaia-ops/agents/developer.md +65 -0
- package/dist/gaia-ops/agents/gaia-operator.md +64 -0
- package/dist/gaia-ops/agents/gaia-orchestrator.md +237 -0
- package/dist/gaia-ops/agents/gaia-planner.md +53 -0
- package/dist/gaia-ops/agents/gaia-system.md +70 -0
- package/dist/gaia-ops/agents/gitops-operator.md +61 -0
- package/dist/gaia-ops/agents/terraform-architect.md +63 -0
- package/dist/gaia-ops/commands/gaia.md +37 -0
- package/dist/gaia-ops/config/README.md +71 -0
- package/dist/gaia-ops/config/cloud/aws.json +134 -0
- package/dist/gaia-ops/config/cloud/gcp.json +139 -0
- package/dist/gaia-ops/config/context-contracts.json +158 -0
- package/dist/gaia-ops/config/crons-schema.md +81 -0
- package/dist/gaia-ops/config/git_standards.json +72 -0
- package/dist/gaia-ops/config/surface-routing.json +421 -0
- package/dist/gaia-ops/config/universal-rules.json +102 -0
- package/dist/gaia-ops/hooks/adapters/__init__.py +52 -0
- package/dist/gaia-ops/hooks/adapters/base.py +219 -0
- package/dist/gaia-ops/hooks/adapters/channel.py +17 -0
- package/dist/gaia-ops/hooks/adapters/claude_code.py +1890 -0
- package/dist/gaia-ops/hooks/adapters/types.py +194 -0
- package/dist/gaia-ops/hooks/adapters/utils.py +25 -0
- package/dist/gaia-ops/hooks/hooks.json +163 -0
- package/dist/gaia-ops/hooks/modules/__init__.py +15 -0
- package/dist/gaia-ops/hooks/modules/agents/__init__.py +29 -0
- package/dist/gaia-ops/hooks/modules/agents/contract_validator.py +647 -0
- package/dist/gaia-ops/hooks/modules/agents/response_contract.py +496 -0
- package/dist/gaia-ops/hooks/modules/agents/skill_injection_verifier.py +120 -0
- package/dist/gaia-ops/hooks/modules/agents/state_tracker.py +267 -0
- package/dist/gaia-ops/hooks/modules/agents/task_info_builder.py +74 -0
- package/dist/gaia-ops/hooks/modules/agents/transcript_analyzer.py +458 -0
- package/dist/gaia-ops/hooks/modules/agents/transcript_reader.py +152 -0
- package/dist/gaia-ops/hooks/modules/audit/__init__.py +28 -0
- package/dist/gaia-ops/hooks/modules/audit/event_detector.py +168 -0
- package/dist/gaia-ops/hooks/modules/audit/logger.py +131 -0
- package/dist/gaia-ops/hooks/modules/audit/metrics.py +134 -0
- package/dist/gaia-ops/hooks/modules/audit/workflow_auditor.py +611 -0
- package/dist/gaia-ops/hooks/modules/audit/workflow_recorder.py +296 -0
- package/dist/gaia-ops/hooks/modules/context/__init__.py +11 -0
- package/dist/gaia-ops/hooks/modules/context/agentic_loop_detector.py +165 -0
- package/dist/gaia-ops/hooks/modules/context/anchor_tracker.py +317 -0
- package/dist/gaia-ops/hooks/modules/context/compact_context_builder.py +218 -0
- package/dist/gaia-ops/hooks/modules/context/context_freshness.py +145 -0
- package/dist/gaia-ops/hooks/modules/context/context_injector.py +558 -0
- package/dist/gaia-ops/hooks/modules/context/context_writer.py +530 -0
- package/dist/gaia-ops/hooks/modules/context/contracts_loader.py +161 -0
- package/dist/gaia-ops/hooks/modules/core/__init__.py +40 -0
- package/dist/gaia-ops/hooks/modules/core/hook_entry.py +78 -0
- package/dist/gaia-ops/hooks/modules/core/paths.py +160 -0
- package/dist/gaia-ops/hooks/modules/core/plugin_mode.py +149 -0
- package/dist/gaia-ops/hooks/modules/core/plugin_setup.py +577 -0
- package/dist/gaia-ops/hooks/modules/core/state.py +179 -0
- package/dist/gaia-ops/hooks/modules/core/stdin.py +24 -0
- package/dist/gaia-ops/hooks/modules/events/__init__.py +1 -0
- package/dist/gaia-ops/hooks/modules/events/event_writer.py +210 -0
- package/dist/gaia-ops/hooks/modules/memory/__init__.py +8 -0
- package/dist/gaia-ops/hooks/modules/memory/episode_writer.py +216 -0
- package/dist/gaia-ops/hooks/modules/orchestrator/__init__.py +1 -0
- package/dist/gaia-ops/hooks/modules/orchestrator/delegate_mode.py +122 -0
- package/dist/gaia-ops/hooks/modules/scanning/__init__.py +8 -0
- package/dist/gaia-ops/hooks/modules/scanning/scan_trigger.py +84 -0
- package/dist/gaia-ops/hooks/modules/security/__init__.py +120 -0
- package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +87 -0
- package/dist/gaia-ops/hooks/modules/security/approval_constants.py +23 -0
- package/dist/gaia-ops/hooks/modules/security/approval_grants.py +1638 -0
- package/dist/gaia-ops/hooks/modules/security/approval_messages.py +71 -0
- package/dist/gaia-ops/hooks/modules/security/approval_scopes.py +222 -0
- package/dist/gaia-ops/hooks/modules/security/blocked_commands.py +595 -0
- package/dist/gaia-ops/hooks/modules/security/blocked_message_formatter.py +87 -0
- package/dist/gaia-ops/hooks/modules/security/command_semantics.py +181 -0
- package/dist/gaia-ops/hooks/modules/security/composition_rules.py +547 -0
- package/dist/gaia-ops/hooks/modules/security/flag_classifiers.py +873 -0
- package/dist/gaia-ops/hooks/modules/security/gitops_validator.py +179 -0
- package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +1131 -0
- package/dist/gaia-ops/hooks/modules/security/network_hosts.py +481 -0
- package/dist/gaia-ops/hooks/modules/security/prompt_validator.py +40 -0
- package/dist/gaia-ops/hooks/modules/security/shell_unwrapper.py +165 -0
- package/dist/gaia-ops/hooks/modules/security/tiers.py +196 -0
- package/dist/gaia-ops/hooks/modules/session/__init__.py +10 -0
- package/dist/gaia-ops/hooks/modules/session/pending_scanner.py +174 -0
- package/dist/gaia-ops/hooks/modules/session/session_context_writer.py +100 -0
- package/dist/gaia-ops/hooks/modules/session/session_event_injector.py +160 -0
- package/dist/gaia-ops/hooks/modules/session/session_manager.py +31 -0
- package/dist/gaia-ops/hooks/modules/session/session_registry.py +232 -0
- package/dist/gaia-ops/hooks/modules/tools/__init__.py +29 -0
- package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +1008 -0
- package/dist/gaia-ops/hooks/modules/tools/cloud_pipe_validator.py +231 -0
- package/dist/gaia-ops/hooks/modules/tools/hook_response.py +55 -0
- package/dist/gaia-ops/hooks/modules/tools/shell_parser.py +227 -0
- package/dist/gaia-ops/hooks/modules/tools/stage_decomposer.py +315 -0
- package/dist/gaia-ops/hooks/modules/tools/task_validator.py +294 -0
- package/dist/gaia-ops/hooks/modules/validation/__init__.py +23 -0
- package/dist/gaia-ops/hooks/modules/validation/commit_validator.py +380 -0
- package/dist/gaia-ops/hooks/post_compact.py +43 -0
- package/dist/gaia-ops/hooks/post_tool_use.py +54 -0
- package/dist/gaia-ops/hooks/pre_compact.py +60 -0
- package/dist/gaia-ops/hooks/pre_tool_use.py +413 -0
- package/dist/gaia-ops/hooks/session_start.py +81 -0
- package/dist/gaia-ops/hooks/stop_hook.py +82 -0
- package/dist/gaia-ops/hooks/subagent_start.py +71 -0
- package/dist/gaia-ops/hooks/subagent_stop.py +295 -0
- package/dist/gaia-ops/hooks/task_completed.py +70 -0
- package/dist/gaia-ops/hooks/user_prompt_submit.py +246 -0
- package/dist/gaia-ops/settings.json +72 -0
- package/dist/gaia-ops/skills/README.md +154 -0
- package/dist/gaia-ops/skills/agent-protocol/SKILL.md +93 -0
- package/dist/gaia-ops/skills/agent-protocol/examples.md +223 -0
- package/dist/gaia-ops/skills/agent-response/SKILL.md +69 -0
- package/dist/gaia-ops/skills/agentic-loop/SKILL.md +80 -0
- package/dist/gaia-ops/skills/agentic-loop/reference.md +378 -0
- package/dist/gaia-ops/skills/blog-writing/SKILL.md +98 -0
- package/dist/gaia-ops/skills/blog-writing/reference.md +130 -0
- package/dist/gaia-ops/skills/brief-spec/SKILL.md +182 -0
- package/dist/gaia-ops/skills/command-execution/SKILL.md +64 -0
- package/dist/gaia-ops/skills/command-execution/reference.md +83 -0
- package/dist/gaia-ops/skills/context-updater/SKILL.md +87 -0
- package/dist/gaia-ops/skills/context-updater/examples.md +71 -0
- package/dist/gaia-ops/skills/developer-patterns/SKILL.md +50 -0
- package/dist/gaia-ops/skills/developer-patterns/reference.md +112 -0
- package/dist/gaia-ops/skills/execution/SKILL.md +99 -0
- package/dist/gaia-ops/skills/fast-queries/SKILL.md +43 -0
- package/dist/gaia-ops/skills/gaia-compact/SKILL.md +74 -0
- package/dist/gaia-ops/skills/gaia-patterns/SKILL.md +108 -0
- package/dist/gaia-ops/skills/gaia-patterns/reference.md +395 -0
- package/dist/gaia-ops/skills/gaia-planner/SKILL.md +37 -0
- package/dist/gaia-ops/skills/gaia-planner/reference.md +107 -0
- package/dist/gaia-ops/skills/gaia-release/SKILL.md +82 -0
- package/dist/gaia-ops/skills/gaia-release/reference.md +102 -0
- package/dist/gaia-ops/skills/gaia-self-check/SKILL.md +114 -0
- package/dist/gaia-ops/skills/gaia-self-check/reference.md +453 -0
- package/dist/gaia-ops/skills/gaia-verify/SKILL.md +77 -0
- package/dist/gaia-ops/skills/gaia-verify/reference.md +80 -0
- package/dist/gaia-ops/skills/git-conventions/SKILL.md +47 -0
- package/dist/gaia-ops/skills/gitops-patterns/SKILL.md +60 -0
- package/dist/gaia-ops/skills/gitops-patterns/reference.md +183 -0
- package/dist/gaia-ops/skills/gmail-policy/SKILL.md +200 -0
- package/dist/gaia-ops/skills/gmail-policy/reference.md +150 -0
- package/dist/gaia-ops/skills/gmail-triage/SKILL.md +100 -0
- package/dist/gaia-ops/skills/gws-setup/SKILL.md +99 -0
- package/dist/gaia-ops/skills/gws-setup/reference.md +73 -0
- package/dist/gaia-ops/skills/investigation/SKILL.md +100 -0
- package/dist/gaia-ops/skills/memory-curation/SKILL.md +83 -0
- package/dist/gaia-ops/skills/memory-search/SKILL.md +88 -0
- package/dist/gaia-ops/skills/orchestrator-approval/SKILL.md +160 -0
- package/dist/gaia-ops/skills/orchestrator-approval/reference.md +174 -0
- package/dist/gaia-ops/skills/pending-approvals/SKILL.md +72 -0
- package/dist/gaia-ops/skills/pending-approvals/reference.md +214 -0
- package/dist/gaia-ops/skills/readme-writing/SKILL.md +71 -0
- package/dist/gaia-ops/skills/readme-writing/reference.md +188 -0
- package/dist/gaia-ops/skills/reference.md +135 -0
- package/dist/gaia-ops/skills/request-approval/SKILL.md +140 -0
- package/dist/gaia-ops/skills/request-approval/examples.md +140 -0
- package/dist/gaia-ops/skills/request-approval/reference.md +57 -0
- package/dist/gaia-ops/skills/schedule-task/SKILL.md +64 -0
- package/dist/gaia-ops/skills/schedule-task/reference.md +233 -0
- package/dist/gaia-ops/skills/security-tiers/SKILL.md +141 -0
- package/dist/gaia-ops/skills/security-tiers/destructive-commands-reference.md +623 -0
- package/dist/gaia-ops/skills/security-tiers/reference.md +39 -0
- package/dist/gaia-ops/skills/skill-creation/SKILL.md +92 -0
- package/dist/gaia-ops/skills/skill-creation/reference.md +29 -0
- package/dist/gaia-ops/skills/terraform-patterns/SKILL.md +89 -0
- package/dist/gaia-ops/skills/terraform-patterns/reference.md +93 -0
- package/dist/gaia-ops/tools/__init__.py +9 -0
- package/dist/gaia-ops/tools/agentic-loop/decide-status.py +210 -0
- package/dist/gaia-ops/tools/agentic-loop/parse-metric.py +106 -0
- package/dist/gaia-ops/tools/agentic-loop/record-iteration.py +221 -0
- package/dist/gaia-ops/tools/context/README.md +132 -0
- package/dist/gaia-ops/tools/context/__init__.py +42 -0
- package/dist/gaia-ops/tools/context/_paths.py +20 -0
- package/dist/gaia-ops/tools/context/context_provider.py +721 -0
- package/dist/gaia-ops/tools/context/context_section_reader.py +342 -0
- package/dist/gaia-ops/tools/context/deep_merge.py +159 -0
- package/dist/gaia-ops/tools/context/pending_updates.py +760 -0
- package/dist/gaia-ops/tools/context/surface_router.py +278 -0
- package/dist/gaia-ops/tools/fast-queries/README.md +65 -0
- package/dist/gaia-ops/tools/fast-queries/__init__.py +30 -0
- package/dist/gaia-ops/tools/fast-queries/appservices/quicktriage_devops_developer.sh +75 -0
- package/dist/gaia-ops/tools/fast-queries/cloud/aws/quicktriage_aws_troubleshooter.sh +32 -0
- package/dist/gaia-ops/tools/fast-queries/cloud/gcp/quicktriage_gcp_troubleshooter.sh +88 -0
- package/dist/gaia-ops/tools/fast-queries/gitops/quicktriage_gitops_operator.sh +48 -0
- package/dist/gaia-ops/tools/fast-queries/run_triage.sh +59 -0
- package/dist/gaia-ops/tools/fast-queries/terraform/quicktriage_terraform_architect.sh +80 -0
- package/dist/gaia-ops/tools/gaia_simulator/__init__.py +33 -0
- package/dist/gaia-ops/tools/gaia_simulator/cli.py +354 -0
- package/dist/gaia-ops/tools/gaia_simulator/extractor.py +457 -0
- package/dist/gaia-ops/tools/gaia_simulator/reporter.py +258 -0
- package/dist/gaia-ops/tools/gaia_simulator/routing_simulator.py +334 -0
- package/dist/gaia-ops/tools/gaia_simulator/runner.py +539 -0
- package/dist/gaia-ops/tools/gaia_simulator/skills_mapper.py +264 -0
- package/dist/gaia-ops/tools/memory/README.md +0 -0
- package/dist/gaia-ops/tools/memory/__init__.py +20 -0
- package/dist/gaia-ops/tools/memory/backfill_fts5.py +107 -0
- package/dist/gaia-ops/tools/memory/conflict_detector.py +295 -0
- package/dist/gaia-ops/tools/memory/episodic.py +1210 -0
- package/dist/gaia-ops/tools/memory/git_invalidator.py +262 -0
- package/dist/gaia-ops/tools/memory/paths.py +102 -0
- package/dist/gaia-ops/tools/memory/scoring.py +193 -0
- package/dist/gaia-ops/tools/memory/search_store.py +360 -0
- package/dist/gaia-ops/tools/persist_transcript_analysis.py +85 -0
- package/dist/gaia-ops/tools/review/__init__.py +1 -0
- package/dist/gaia-ops/tools/review/review_engine.py +157 -0
- package/dist/gaia-ops/tools/scan/__init__.py +35 -0
- package/dist/gaia-ops/tools/scan/config.py +247 -0
- package/dist/gaia-ops/tools/scan/merge.py +212 -0
- package/dist/gaia-ops/tools/scan/orchestrator.py +549 -0
- package/dist/gaia-ops/tools/scan/registry.py +127 -0
- package/dist/gaia-ops/tools/scan/scanners/__init__.py +18 -0
- package/dist/gaia-ops/tools/scan/scanners/base.py +137 -0
- package/dist/gaia-ops/tools/scan/scanners/environment.py +349 -0
- package/dist/gaia-ops/tools/scan/scanners/git.py +570 -0
- package/dist/gaia-ops/tools/scan/scanners/infrastructure.py +875 -0
- package/dist/gaia-ops/tools/scan/scanners/orchestration.py +600 -0
- package/dist/gaia-ops/tools/scan/scanners/stack.py +1085 -0
- package/dist/gaia-ops/tools/scan/scanners/tools.py +260 -0
- package/dist/gaia-ops/tools/scan/setup.py +686 -0
- package/dist/gaia-ops/tools/scan/tests/__init__.py +1 -0
- package/dist/gaia-ops/tools/scan/tests/conftest.py +796 -0
- package/dist/gaia-ops/tools/scan/tests/test_environment.py +323 -0
- package/dist/gaia-ops/tools/scan/tests/test_git.py +419 -0
- package/dist/gaia-ops/tools/scan/tests/test_infrastructure.py +382 -0
- package/dist/gaia-ops/tools/scan/tests/test_integration.py +920 -0
- package/dist/gaia-ops/tools/scan/tests/test_merge.py +269 -0
- package/dist/gaia-ops/tools/scan/tests/test_orchestration.py +304 -0
- package/dist/gaia-ops/tools/scan/tests/test_stack.py +604 -0
- package/dist/gaia-ops/tools/scan/tests/test_tools.py +349 -0
- package/dist/gaia-ops/tools/scan/ui.py +624 -0
- package/dist/gaia-ops/tools/scan/verify.py +270 -0
- package/dist/gaia-ops/tools/scan/walk.py +118 -0
- package/dist/gaia-ops/tools/scan/workspace.py +85 -0
- package/dist/gaia-ops/tools/validation/README.md +244 -0
- package/dist/gaia-ops/tools/validation/__init__.py +17 -0
- package/dist/gaia-ops/tools/validation/approval_gate.py +321 -0
- package/dist/gaia-ops/tools/validation/validate_skills.py +189 -0
- package/dist/gaia-security/.claude-plugin/plugin.json +24 -0
- package/dist/gaia-security/README.md +90 -0
- package/dist/gaia-security/config/universal-rules.json +102 -0
- package/dist/gaia-security/hooks/adapters/__init__.py +52 -0
- package/dist/gaia-security/hooks/adapters/base.py +219 -0
- package/dist/gaia-security/hooks/adapters/channel.py +17 -0
- package/dist/gaia-security/hooks/adapters/claude_code.py +1890 -0
- package/dist/gaia-security/hooks/adapters/types.py +194 -0
- package/dist/gaia-security/hooks/adapters/utils.py +25 -0
- package/dist/gaia-security/hooks/hooks.json +84 -0
- package/dist/gaia-security/hooks/modules/__init__.py +15 -0
- package/dist/gaia-security/hooks/modules/agents/__init__.py +29 -0
- package/dist/gaia-security/hooks/modules/agents/contract_validator.py +647 -0
- package/dist/gaia-security/hooks/modules/agents/response_contract.py +496 -0
- package/dist/gaia-security/hooks/modules/agents/skill_injection_verifier.py +120 -0
- package/dist/gaia-security/hooks/modules/agents/state_tracker.py +267 -0
- package/dist/gaia-security/hooks/modules/agents/task_info_builder.py +74 -0
- package/dist/gaia-security/hooks/modules/agents/transcript_analyzer.py +458 -0
- package/dist/gaia-security/hooks/modules/agents/transcript_reader.py +152 -0
- package/dist/gaia-security/hooks/modules/audit/__init__.py +28 -0
- package/dist/gaia-security/hooks/modules/audit/event_detector.py +168 -0
- package/dist/gaia-security/hooks/modules/audit/logger.py +131 -0
- package/dist/gaia-security/hooks/modules/audit/metrics.py +134 -0
- package/dist/gaia-security/hooks/modules/audit/workflow_auditor.py +611 -0
- package/dist/gaia-security/hooks/modules/audit/workflow_recorder.py +296 -0
- package/dist/gaia-security/hooks/modules/context/__init__.py +11 -0
- package/dist/gaia-security/hooks/modules/context/agentic_loop_detector.py +165 -0
- package/dist/gaia-security/hooks/modules/context/anchor_tracker.py +317 -0
- package/dist/gaia-security/hooks/modules/context/compact_context_builder.py +218 -0
- package/dist/gaia-security/hooks/modules/context/context_freshness.py +145 -0
- package/dist/gaia-security/hooks/modules/context/context_injector.py +558 -0
- package/dist/gaia-security/hooks/modules/context/context_writer.py +530 -0
- package/dist/gaia-security/hooks/modules/context/contracts_loader.py +161 -0
- package/dist/gaia-security/hooks/modules/core/__init__.py +40 -0
- package/dist/gaia-security/hooks/modules/core/hook_entry.py +78 -0
- package/dist/gaia-security/hooks/modules/core/paths.py +160 -0
- package/dist/gaia-security/hooks/modules/core/plugin_mode.py +149 -0
- package/dist/gaia-security/hooks/modules/core/plugin_setup.py +577 -0
- package/dist/gaia-security/hooks/modules/core/state.py +179 -0
- package/dist/gaia-security/hooks/modules/core/stdin.py +24 -0
- package/dist/gaia-security/hooks/modules/events/__init__.py +1 -0
- package/dist/gaia-security/hooks/modules/events/event_writer.py +210 -0
- package/dist/gaia-security/hooks/modules/memory/__init__.py +8 -0
- package/dist/gaia-security/hooks/modules/memory/episode_writer.py +216 -0
- package/dist/gaia-security/hooks/modules/orchestrator/__init__.py +1 -0
- package/dist/gaia-security/hooks/modules/orchestrator/delegate_mode.py +122 -0
- package/dist/gaia-security/hooks/modules/scanning/__init__.py +8 -0
- package/dist/gaia-security/hooks/modules/scanning/scan_trigger.py +84 -0
- package/dist/gaia-security/hooks/modules/security/__init__.py +120 -0
- package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +87 -0
- package/dist/gaia-security/hooks/modules/security/approval_constants.py +23 -0
- package/dist/gaia-security/hooks/modules/security/approval_grants.py +1638 -0
- package/dist/gaia-security/hooks/modules/security/approval_messages.py +71 -0
- package/dist/gaia-security/hooks/modules/security/approval_scopes.py +222 -0
- package/dist/gaia-security/hooks/modules/security/blocked_commands.py +595 -0
- package/dist/gaia-security/hooks/modules/security/blocked_message_formatter.py +87 -0
- package/dist/gaia-security/hooks/modules/security/command_semantics.py +181 -0
- package/dist/gaia-security/hooks/modules/security/composition_rules.py +547 -0
- package/dist/gaia-security/hooks/modules/security/flag_classifiers.py +873 -0
- package/dist/gaia-security/hooks/modules/security/gitops_validator.py +179 -0
- package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +1131 -0
- package/dist/gaia-security/hooks/modules/security/network_hosts.py +481 -0
- package/dist/gaia-security/hooks/modules/security/prompt_validator.py +40 -0
- package/dist/gaia-security/hooks/modules/security/shell_unwrapper.py +165 -0
- package/dist/gaia-security/hooks/modules/security/tiers.py +196 -0
- package/dist/gaia-security/hooks/modules/session/__init__.py +10 -0
- package/dist/gaia-security/hooks/modules/session/pending_scanner.py +174 -0
- package/dist/gaia-security/hooks/modules/session/session_context_writer.py +100 -0
- package/dist/gaia-security/hooks/modules/session/session_event_injector.py +160 -0
- package/dist/gaia-security/hooks/modules/session/session_manager.py +31 -0
- package/dist/gaia-security/hooks/modules/session/session_registry.py +232 -0
- package/dist/gaia-security/hooks/modules/tools/__init__.py +29 -0
- package/dist/gaia-security/hooks/modules/tools/bash_validator.py +1008 -0
- package/dist/gaia-security/hooks/modules/tools/cloud_pipe_validator.py +231 -0
- package/dist/gaia-security/hooks/modules/tools/hook_response.py +55 -0
- package/dist/gaia-security/hooks/modules/tools/shell_parser.py +227 -0
- package/dist/gaia-security/hooks/modules/tools/stage_decomposer.py +315 -0
- package/dist/gaia-security/hooks/modules/tools/task_validator.py +294 -0
- package/dist/gaia-security/hooks/modules/validation/__init__.py +23 -0
- package/dist/gaia-security/hooks/modules/validation/commit_validator.py +380 -0
- package/dist/gaia-security/hooks/post_tool_use.py +54 -0
- package/dist/gaia-security/hooks/pre_tool_use.py +413 -0
- package/dist/gaia-security/hooks/session_start.py +81 -0
- package/dist/gaia-security/hooks/stop_hook.py +82 -0
- package/dist/gaia-security/hooks/user_prompt_submit.py +246 -0
- package/dist/gaia-security/settings.json +58 -0
- package/git-hooks/commit-msg +41 -0
- package/hooks/README.md +100 -0
- package/hooks/adapters/__init__.py +52 -0
- package/hooks/adapters/base.py +219 -0
- package/hooks/adapters/channel.py +17 -0
- package/hooks/adapters/claude_code.py +1890 -0
- package/hooks/adapters/types.py +194 -0
- package/hooks/adapters/utils.py +25 -0
- package/hooks/elicitation_result.py +179 -0
- package/hooks/hooks.json +84 -0
- package/hooks/modules/README.md +189 -0
- package/hooks/modules/__init__.py +15 -0
- package/hooks/modules/agents/__init__.py +29 -0
- package/hooks/modules/agents/contract_validator.py +647 -0
- package/hooks/modules/agents/response_contract.py +496 -0
- package/hooks/modules/agents/skill_injection_verifier.py +120 -0
- package/hooks/modules/agents/state_tracker.py +267 -0
- package/hooks/modules/agents/task_info_builder.py +74 -0
- package/hooks/modules/agents/transcript_analyzer.py +458 -0
- package/hooks/modules/agents/transcript_reader.py +152 -0
- package/hooks/modules/audit/__init__.py +28 -0
- package/hooks/modules/audit/event_detector.py +168 -0
- package/hooks/modules/audit/logger.py +131 -0
- package/hooks/modules/audit/metrics.py +134 -0
- package/hooks/modules/audit/workflow_auditor.py +611 -0
- package/hooks/modules/audit/workflow_recorder.py +296 -0
- package/hooks/modules/context/__init__.py +11 -0
- package/hooks/modules/context/agentic_loop_detector.py +165 -0
- package/hooks/modules/context/anchor_tracker.py +317 -0
- package/hooks/modules/context/compact_context_builder.py +218 -0
- package/hooks/modules/context/context_freshness.py +145 -0
- package/hooks/modules/context/context_injector.py +558 -0
- package/hooks/modules/context/context_writer.py +530 -0
- package/hooks/modules/context/contracts_loader.py +161 -0
- package/hooks/modules/core/__init__.py +40 -0
- package/hooks/modules/core/hook_entry.py +78 -0
- package/hooks/modules/core/paths.py +160 -0
- package/hooks/modules/core/plugin_mode.py +149 -0
- package/hooks/modules/core/plugin_setup.py +577 -0
- package/hooks/modules/core/state.py +179 -0
- package/hooks/modules/core/stdin.py +24 -0
- package/hooks/modules/events/__init__.py +1 -0
- package/hooks/modules/events/event_writer.py +210 -0
- package/hooks/modules/evidence/__init__.py +34 -0
- package/hooks/modules/evidence/assertions.py +137 -0
- package/hooks/modules/evidence/index_writer.py +57 -0
- package/hooks/modules/evidence/loader.py +126 -0
- package/hooks/modules/evidence/runner.py +241 -0
- package/hooks/modules/memory/__init__.py +8 -0
- package/hooks/modules/memory/episode_writer.py +216 -0
- package/hooks/modules/orchestrator/__init__.py +1 -0
- package/hooks/modules/orchestrator/delegate_mode.py +122 -0
- package/hooks/modules/scanning/__init__.py +8 -0
- package/hooks/modules/scanning/scan_trigger.py +84 -0
- package/hooks/modules/security/__init__.py +120 -0
- package/hooks/modules/security/approval_cleanup.py +87 -0
- package/hooks/modules/security/approval_constants.py +23 -0
- package/hooks/modules/security/approval_grants.py +1638 -0
- package/hooks/modules/security/approval_messages.py +71 -0
- package/hooks/modules/security/approval_scopes.py +222 -0
- package/hooks/modules/security/blocked_commands.py +595 -0
- package/hooks/modules/security/blocked_message_formatter.py +87 -0
- package/hooks/modules/security/command_semantics.py +181 -0
- package/hooks/modules/security/composition_rules.py +547 -0
- package/hooks/modules/security/flag_classifiers.py +873 -0
- package/hooks/modules/security/gitops_validator.py +179 -0
- package/hooks/modules/security/mutative_verbs.py +1131 -0
- package/hooks/modules/security/network_hosts.py +481 -0
- package/hooks/modules/security/prompt_validator.py +40 -0
- package/hooks/modules/security/shell_unwrapper.py +165 -0
- package/hooks/modules/security/tiers.py +196 -0
- package/hooks/modules/session/__init__.py +10 -0
- package/hooks/modules/session/pending_scanner.py +174 -0
- package/hooks/modules/session/session_context_writer.py +100 -0
- package/hooks/modules/session/session_event_injector.py +160 -0
- package/hooks/modules/session/session_manager.py +31 -0
- package/hooks/modules/session/session_registry.py +232 -0
- package/hooks/modules/tools/__init__.py +29 -0
- package/hooks/modules/tools/bash_validator.py +1008 -0
- package/hooks/modules/tools/cloud_pipe_validator.py +231 -0
- package/hooks/modules/tools/hook_response.py +55 -0
- package/hooks/modules/tools/shell_parser.py +227 -0
- package/hooks/modules/tools/stage_decomposer.py +315 -0
- package/hooks/modules/tools/task_validator.py +294 -0
- package/hooks/modules/validation/__init__.py +23 -0
- package/hooks/modules/validation/commit_validator.py +380 -0
- package/hooks/post_compact.py +43 -0
- package/hooks/post_tool_use.py +54 -0
- package/hooks/pre_compact.py +60 -0
- package/hooks/pre_tool_use.py +413 -0
- package/hooks/session_start.py +81 -0
- package/hooks/stop_hook.py +82 -0
- package/hooks/subagent_start.py +71 -0
- package/hooks/subagent_stop.py +295 -0
- package/hooks/task_completed.py +70 -0
- package/hooks/user_prompt_submit.py +246 -0
- package/index.js +83 -0
- package/package.json +99 -0
- package/pyproject.toml +32 -0
- package/skills/README.md +154 -0
- package/skills/agent-protocol/SKILL.md +93 -0
- package/skills/agent-protocol/examples.md +223 -0
- package/skills/agent-response/SKILL.md +69 -0
- package/skills/agentic-loop/SKILL.md +80 -0
- package/skills/agentic-loop/reference.md +378 -0
- package/skills/blog-writing/SKILL.md +98 -0
- package/skills/blog-writing/reference.md +130 -0
- package/skills/brief-spec/SKILL.md +182 -0
- package/skills/command-execution/SKILL.md +64 -0
- package/skills/command-execution/reference.md +83 -0
- package/skills/context-updater/SKILL.md +87 -0
- package/skills/context-updater/examples.md +71 -0
- package/skills/developer-patterns/SKILL.md +50 -0
- package/skills/developer-patterns/reference.md +112 -0
- package/skills/execution/SKILL.md +99 -0
- package/skills/fast-queries/SKILL.md +43 -0
- package/skills/gaia-compact/SKILL.md +74 -0
- package/skills/gaia-patterns/SKILL.md +108 -0
- package/skills/gaia-patterns/reference.md +395 -0
- package/skills/gaia-planner/SKILL.md +37 -0
- package/skills/gaia-planner/reference.md +107 -0
- package/skills/gaia-release/SKILL.md +82 -0
- package/skills/gaia-release/reference.md +102 -0
- package/skills/gaia-self-check/SKILL.md +114 -0
- package/skills/gaia-self-check/reference.md +453 -0
- package/skills/gaia-verify/SKILL.md +77 -0
- package/skills/gaia-verify/reference.md +80 -0
- package/skills/git-conventions/SKILL.md +47 -0
- package/skills/gitops-patterns/SKILL.md +60 -0
- package/skills/gitops-patterns/reference.md +183 -0
- package/skills/gmail-policy/SKILL.md +200 -0
- package/skills/gmail-policy/reference.md +150 -0
- package/skills/gmail-triage/SKILL.md +100 -0
- package/skills/gws-setup/SKILL.md +99 -0
- package/skills/gws-setup/reference.md +73 -0
- package/skills/investigation/SKILL.md +100 -0
- package/skills/memory-curation/SKILL.md +83 -0
- package/skills/memory-search/SKILL.md +88 -0
- package/skills/orchestrator-approval/SKILL.md +160 -0
- package/skills/orchestrator-approval/reference.md +174 -0
- package/skills/pending-approvals/SKILL.md +72 -0
- package/skills/pending-approvals/reference.md +214 -0
- package/skills/readme-writing/SKILL.md +71 -0
- package/skills/readme-writing/reference.md +188 -0
- package/skills/reference.md +135 -0
- package/skills/request-approval/SKILL.md +140 -0
- package/skills/request-approval/examples.md +140 -0
- package/skills/request-approval/reference.md +57 -0
- package/skills/schedule-task/SKILL.md +64 -0
- package/skills/schedule-task/reference.md +233 -0
- package/skills/security-tiers/SKILL.md +141 -0
- package/skills/security-tiers/destructive-commands-reference.md +623 -0
- package/skills/security-tiers/reference.md +39 -0
- package/skills/skill-creation/SKILL.md +92 -0
- package/skills/skill-creation/reference.md +29 -0
- package/skills/terraform-patterns/SKILL.md +89 -0
- package/skills/terraform-patterns/reference.md +93 -0
- package/templates/README.md +69 -0
- package/templates/managed-settings.template.json +43 -0
- package/tools/__init__.py +9 -0
- package/tools/agentic-loop/decide-status.py +210 -0
- package/tools/agentic-loop/parse-metric.py +106 -0
- package/tools/agentic-loop/record-iteration.py +221 -0
- package/tools/context/README.md +132 -0
- package/tools/context/__init__.py +42 -0
- package/tools/context/_paths.py +20 -0
- package/tools/context/context_provider.py +721 -0
- package/tools/context/context_section_reader.py +342 -0
- package/tools/context/deep_merge.py +159 -0
- package/tools/context/pending_updates.py +760 -0
- package/tools/context/surface_router.py +278 -0
- package/tools/fast-queries/README.md +65 -0
- package/tools/fast-queries/__init__.py +30 -0
- package/tools/fast-queries/appservices/quicktriage_devops_developer.sh +75 -0
- package/tools/fast-queries/cloud/aws/quicktriage_aws_troubleshooter.sh +32 -0
- package/tools/fast-queries/cloud/gcp/quicktriage_gcp_troubleshooter.sh +88 -0
- package/tools/fast-queries/gitops/quicktriage_gitops_operator.sh +48 -0
- package/tools/fast-queries/run_triage.sh +59 -0
- package/tools/fast-queries/terraform/quicktriage_terraform_architect.sh +80 -0
- package/tools/gaia_simulator/__init__.py +33 -0
- package/tools/gaia_simulator/cli.py +354 -0
- package/tools/gaia_simulator/extractor.py +457 -0
- package/tools/gaia_simulator/reporter.py +258 -0
- package/tools/gaia_simulator/routing_simulator.py +334 -0
- package/tools/gaia_simulator/runner.py +539 -0
- package/tools/gaia_simulator/skills_mapper.py +264 -0
- package/tools/memory/README.md +0 -0
- package/tools/memory/__init__.py +20 -0
- package/tools/memory/backfill_fts5.py +107 -0
- package/tools/memory/conflict_detector.py +295 -0
- package/tools/memory/episodic.py +1210 -0
- package/tools/memory/git_invalidator.py +262 -0
- package/tools/memory/paths.py +102 -0
- package/tools/memory/scoring.py +193 -0
- package/tools/memory/search_store.py +360 -0
- package/tools/persist_transcript_analysis.py +85 -0
- package/tools/review/__init__.py +1 -0
- package/tools/review/review_engine.py +157 -0
- package/tools/scan/__init__.py +35 -0
- package/tools/scan/config.py +247 -0
- package/tools/scan/merge.py +212 -0
- package/tools/scan/orchestrator.py +549 -0
- package/tools/scan/registry.py +127 -0
- package/tools/scan/scanners/__init__.py +18 -0
- package/tools/scan/scanners/base.py +137 -0
- package/tools/scan/scanners/environment.py +349 -0
- package/tools/scan/scanners/git.py +570 -0
- package/tools/scan/scanners/infrastructure.py +875 -0
- package/tools/scan/scanners/orchestration.py +600 -0
- package/tools/scan/scanners/stack.py +1085 -0
- package/tools/scan/scanners/tools.py +260 -0
- package/tools/scan/setup.py +686 -0
- package/tools/scan/tests/__init__.py +1 -0
- package/tools/scan/tests/conftest.py +796 -0
- package/tools/scan/tests/test_environment.py +323 -0
- package/tools/scan/tests/test_git.py +419 -0
- package/tools/scan/tests/test_infrastructure.py +382 -0
- package/tools/scan/tests/test_integration.py +920 -0
- package/tools/scan/tests/test_merge.py +269 -0
- package/tools/scan/tests/test_orchestration.py +304 -0
- package/tools/scan/tests/test_stack.py +604 -0
- package/tools/scan/tests/test_tools.py +349 -0
- package/tools/scan/ui.py +624 -0
- package/tools/scan/verify.py +270 -0
- package/tools/scan/walk.py +118 -0
- package/tools/scan/workspace.py +85 -0
- package/tools/validation/README.md +244 -0
- package/tools/validation/__init__.py +17 -0
- package/tools/validation/approval_gate.py +321 -0
- package/tools/validation/validate_skills.py +189 -0
|
@@ -0,0 +1,1890 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Claude Code Adapter -- concrete HookAdapter for Claude Code v2.1+ hook protocol.
|
|
3
|
+
|
|
4
|
+
Translates between Claude Code's stdin JSON format and the normalized types
|
|
5
|
+
defined in adapters.types. Business logic modules never see Claude Code JSON
|
|
6
|
+
directly; they consume and produce normalized types.
|
|
7
|
+
|
|
8
|
+
Distribution channel detection:
|
|
9
|
+
- PLUGIN: CLAUDE_PLUGIN_ROOT env var is set
|
|
10
|
+
- NPM: default (symlink to node_modules or direct invocation)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Dict, List, Optional
|
|
22
|
+
|
|
23
|
+
from .base import HookAdapter
|
|
24
|
+
from .types import (
|
|
25
|
+
AgentCompletion,
|
|
26
|
+
BootstrapResult,
|
|
27
|
+
CompletionResult,
|
|
28
|
+
ContextResult,
|
|
29
|
+
DistributionChannel,
|
|
30
|
+
HookEvent,
|
|
31
|
+
HookEventType,
|
|
32
|
+
HookResponse,
|
|
33
|
+
PermissionDecision,
|
|
34
|
+
QualityResult,
|
|
35
|
+
ToolResult,
|
|
36
|
+
ValidationRequest,
|
|
37
|
+
ValidationResult,
|
|
38
|
+
VerificationResult,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ClaudeCodeAdapter(HookAdapter):
|
|
45
|
+
"""Concrete adapter for Claude Code v2.1+ hook protocol.
|
|
46
|
+
|
|
47
|
+
Claude Code sends JSON on stdin with these top-level fields:
|
|
48
|
+
- hook_event_name: str (e.g. "PreToolUse", "PostToolUse", "SubagentStop")
|
|
49
|
+
- session_id: str
|
|
50
|
+
- tool_name: str (PreToolUse / PostToolUse)
|
|
51
|
+
- tool_input: dict (PreToolUse / PostToolUse)
|
|
52
|
+
- tool_response: dict (PostToolUse only)
|
|
53
|
+
- agent_type: str (SubagentStop only)
|
|
54
|
+
- agent_id: str (SubagentStop only)
|
|
55
|
+
- agent_transcript_path: str (SubagentStop only)
|
|
56
|
+
- last_assistant_message: str (SubagentStop only)
|
|
57
|
+
- cwd: str (SubagentStop only)
|
|
58
|
+
|
|
59
|
+
Responses use hookSpecificOutput with permissionDecision for PreToolUse.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------------ #
|
|
63
|
+
# parse_event: stdin JSON -> HookEvent
|
|
64
|
+
# ------------------------------------------------------------------ #
|
|
65
|
+
|
|
66
|
+
def parse_event(self, stdin_data: str) -> HookEvent:
|
|
67
|
+
"""Parse raw stdin JSON into a normalized HookEvent.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
ValueError: If JSON is invalid, empty, or event type is unknown.
|
|
71
|
+
"""
|
|
72
|
+
if not stdin_data or not stdin_data.strip():
|
|
73
|
+
raise ValueError("Empty stdin data")
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
raw = json.loads(stdin_data)
|
|
77
|
+
except json.JSONDecodeError as exc:
|
|
78
|
+
raise ValueError(f"Invalid JSON from stdin: {exc}") from exc
|
|
79
|
+
|
|
80
|
+
if not isinstance(raw, dict):
|
|
81
|
+
raise ValueError(f"Expected JSON object, got {type(raw).__name__}")
|
|
82
|
+
|
|
83
|
+
# Map hook_event_name to HookEventType enum
|
|
84
|
+
event_name = raw.get("hook_event_name", "")
|
|
85
|
+
if not event_name:
|
|
86
|
+
raise ValueError("Missing required field: hook_event_name")
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
event_type = HookEventType(event_name)
|
|
90
|
+
except ValueError:
|
|
91
|
+
raise ValueError(f"Unknown hook event type: {event_name}")
|
|
92
|
+
|
|
93
|
+
session_id = raw.get("session_id", "")
|
|
94
|
+
|
|
95
|
+
channel = self.detect_channel()
|
|
96
|
+
plugin_root = self._get_plugin_root() if channel == DistributionChannel.PLUGIN else None
|
|
97
|
+
|
|
98
|
+
return HookEvent(
|
|
99
|
+
event_type=event_type,
|
|
100
|
+
session_id=session_id,
|
|
101
|
+
payload=raw,
|
|
102
|
+
channel=channel,
|
|
103
|
+
plugin_root=plugin_root,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# ------------------------------------------------------------------ #
|
|
107
|
+
# format_validation_response: ValidationResult -> HookResponse
|
|
108
|
+
# ------------------------------------------------------------------ #
|
|
109
|
+
|
|
110
|
+
def format_validation_response(self, result: ValidationResult) -> HookResponse:
|
|
111
|
+
"""Format a ValidationResult into Claude Code's hookSpecificOutput JSON.
|
|
112
|
+
|
|
113
|
+
Maps:
|
|
114
|
+
allowed=True -> permissionDecision: "allow", exit 0
|
|
115
|
+
allowed=False, nonce=None -> permissionDecision: "deny", exit 0
|
|
116
|
+
allowed=False, permanent -> permissionDecision: "deny", exit 2
|
|
117
|
+
nonce present -> include nonce in reason
|
|
118
|
+
|
|
119
|
+
When result.modified_input is set, includes updatedInput for Claude Code
|
|
120
|
+
to apply the modified parameters transparently.
|
|
121
|
+
"""
|
|
122
|
+
if result.allowed:
|
|
123
|
+
decision = PermissionDecision.ALLOW.value
|
|
124
|
+
else:
|
|
125
|
+
decision = PermissionDecision.DENY.value
|
|
126
|
+
|
|
127
|
+
output: Dict[str, Any] = {
|
|
128
|
+
"hookSpecificOutput": {
|
|
129
|
+
"hookEventName": "PreToolUse",
|
|
130
|
+
"permissionDecision": decision,
|
|
131
|
+
"permissionDecisionReason": result.reason,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
# Include updatedInput when the command was modified (e.g. footer stripping)
|
|
136
|
+
if result.modified_input is not None:
|
|
137
|
+
output["hookSpecificOutput"]["updatedInput"] = result.modified_input
|
|
138
|
+
|
|
139
|
+
# Exit code 2 = permanent block (blocked_commands.py), 0 = corrective deny
|
|
140
|
+
# Permanent blocks have no nonce and are not allowed
|
|
141
|
+
exit_code = 0
|
|
142
|
+
if not result.allowed and result.nonce is None and result.tier == "BLOCKED":
|
|
143
|
+
exit_code = 2
|
|
144
|
+
|
|
145
|
+
return HookResponse(output=output, exit_code=exit_code)
|
|
146
|
+
|
|
147
|
+
# ------------------------------------------------------------------ #
|
|
148
|
+
# format_completion_response: CompletionResult -> HookResponse
|
|
149
|
+
# ------------------------------------------------------------------ #
|
|
150
|
+
|
|
151
|
+
def format_completion_response(self, result: CompletionResult) -> HookResponse:
|
|
152
|
+
"""Format a CompletionResult for SubagentStop.
|
|
153
|
+
|
|
154
|
+
Success case: minimal response with contract status.
|
|
155
|
+
Repair needed: includes anomaly details for orchestrator.
|
|
156
|
+
Exit code is always 0 (SubagentStop never blocks).
|
|
157
|
+
"""
|
|
158
|
+
output: Dict[str, Any] = {
|
|
159
|
+
"contract_valid": result.contract_valid,
|
|
160
|
+
"anomalies_detected": len(result.anomalies),
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if result.episode_id:
|
|
164
|
+
output["episode_id"] = result.episode_id
|
|
165
|
+
|
|
166
|
+
if result.context_updated:
|
|
167
|
+
output["context_updated"] = True
|
|
168
|
+
|
|
169
|
+
if result.repair_needed:
|
|
170
|
+
output["repair_needed"] = True
|
|
171
|
+
output["anomalies"] = result.anomalies
|
|
172
|
+
|
|
173
|
+
return HookResponse(output=output, exit_code=0)
|
|
174
|
+
|
|
175
|
+
# ------------------------------------------------------------------ #
|
|
176
|
+
# format_context_response: ContextResult -> HookResponse
|
|
177
|
+
# ------------------------------------------------------------------ #
|
|
178
|
+
|
|
179
|
+
def format_context_response(self, result: ContextResult) -> HookResponse:
|
|
180
|
+
"""Format a ContextResult for SubagentStart context injection.
|
|
181
|
+
|
|
182
|
+
Claude Code expects SubagentStart hooks to return::
|
|
183
|
+
|
|
184
|
+
{"hookSpecificOutput": {"hookEventName": "SubagentStart",
|
|
185
|
+
"additionalContext": "..."}}
|
|
186
|
+
|
|
187
|
+
The additionalContext string is appended to the subagent's system prompt.
|
|
188
|
+
"""
|
|
189
|
+
hook_specific: Dict[str, Any] = {
|
|
190
|
+
"hookEventName": "SubagentStart",
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if result.context_injected and result.additional_context:
|
|
194
|
+
hook_specific["additionalContext"] = result.additional_context
|
|
195
|
+
|
|
196
|
+
output: Dict[str, Any] = {"hookSpecificOutput": hook_specific}
|
|
197
|
+
|
|
198
|
+
if result.sections_provided:
|
|
199
|
+
output["sections_provided"] = result.sections_provided
|
|
200
|
+
|
|
201
|
+
return HookResponse(output=output, exit_code=0)
|
|
202
|
+
|
|
203
|
+
# ------------------------------------------------------------------ #
|
|
204
|
+
# P1: adapt_session_start
|
|
205
|
+
# ------------------------------------------------------------------ #
|
|
206
|
+
|
|
207
|
+
def adapt_session_start(self, raw: dict) -> BootstrapResult:
|
|
208
|
+
"""Parse SessionStart event and return bootstrap actions.
|
|
209
|
+
|
|
210
|
+
SessionStart payload contains session_type which determines
|
|
211
|
+
what bootstrap actions to take:
|
|
212
|
+
- startup: full scan + refresh
|
|
213
|
+
- resume: refresh only (no scan)
|
|
214
|
+
- clear/compact: no scan, no refresh
|
|
215
|
+
"""
|
|
216
|
+
session_type = raw.get("session_type", "startup")
|
|
217
|
+
return BootstrapResult(
|
|
218
|
+
should_scan=session_type == "startup",
|
|
219
|
+
should_refresh=session_type in ("startup", "resume"),
|
|
220
|
+
session_type=session_type,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# ------------------------------------------------------------------ #
|
|
224
|
+
# P1: format_bootstrap_response
|
|
225
|
+
# ------------------------------------------------------------------ #
|
|
226
|
+
|
|
227
|
+
def format_bootstrap_response(self, result: BootstrapResult) -> HookResponse:
|
|
228
|
+
"""Format a BootstrapResult for SessionStart.
|
|
229
|
+
|
|
230
|
+
SessionStart hooks are informational -- exit code is always 0.
|
|
231
|
+
"""
|
|
232
|
+
output: Dict[str, Any] = {
|
|
233
|
+
"session_type": result.session_type,
|
|
234
|
+
"should_scan": result.should_scan,
|
|
235
|
+
"should_refresh": result.should_refresh,
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if result.project_scanned:
|
|
239
|
+
output["project_scanned"] = True
|
|
240
|
+
if result.context_path:
|
|
241
|
+
output["context_path"] = str(result.context_path)
|
|
242
|
+
if result.tools_detected:
|
|
243
|
+
output["tools_detected"] = result.tools_detected
|
|
244
|
+
|
|
245
|
+
return HookResponse(output=output, exit_code=0)
|
|
246
|
+
|
|
247
|
+
# ------------------------------------------------------------------ #
|
|
248
|
+
# detect_channel: determine NPM vs PLUGIN distribution
|
|
249
|
+
# ------------------------------------------------------------------ #
|
|
250
|
+
|
|
251
|
+
def detect_channel(self) -> DistributionChannel:
|
|
252
|
+
"""Detect distribution channel.
|
|
253
|
+
|
|
254
|
+
Priority:
|
|
255
|
+
1. CLAUDE_PLUGIN_ROOT env var set -> PLUGIN
|
|
256
|
+
2. Default -> NPM
|
|
257
|
+
"""
|
|
258
|
+
if os.environ.get("CLAUDE_PLUGIN_ROOT"):
|
|
259
|
+
return DistributionChannel.PLUGIN
|
|
260
|
+
return DistributionChannel.NPM
|
|
261
|
+
|
|
262
|
+
# ------------------------------------------------------------------ #
|
|
263
|
+
# Helper: get_plugin_root
|
|
264
|
+
# ------------------------------------------------------------------ #
|
|
265
|
+
|
|
266
|
+
def _get_plugin_root(self) -> Optional[Path]:
|
|
267
|
+
"""Resolve plugin root from CLAUDE_PLUGIN_ROOT env var."""
|
|
268
|
+
plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT")
|
|
269
|
+
if plugin_root:
|
|
270
|
+
return Path(plugin_root)
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
# ------------------------------------------------------------------ #
|
|
274
|
+
# T005: parse_pre_tool_use helper
|
|
275
|
+
# ------------------------------------------------------------------ #
|
|
276
|
+
|
|
277
|
+
def parse_pre_tool_use(self, raw: Dict[str, Any]) -> ValidationRequest:
|
|
278
|
+
"""Extract a ValidationRequest from a PreToolUse payload.
|
|
279
|
+
|
|
280
|
+
Extracts:
|
|
281
|
+
- tool_name: the tool being invoked (Bash, Task, Agent, etc.)
|
|
282
|
+
- command: for Bash, the command string; for Task/Agent, the prompt
|
|
283
|
+
- tool_input: the full tool_input dict
|
|
284
|
+
- session_id: session identifier
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
raw: The full stdin JSON dict (HookEvent.payload).
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
ValidationRequest with normalized fields.
|
|
291
|
+
"""
|
|
292
|
+
tool_name = raw.get("tool_name", "")
|
|
293
|
+
tool_input = raw.get("tool_input", {})
|
|
294
|
+
session_id = raw.get("session_id", "")
|
|
295
|
+
|
|
296
|
+
# Extract the primary command/prompt string based on tool type
|
|
297
|
+
if tool_name.lower() == "bash":
|
|
298
|
+
command = tool_input.get("command", "")
|
|
299
|
+
elif tool_name.lower() in ("task", "agent"):
|
|
300
|
+
command = tool_input.get("prompt", "")
|
|
301
|
+
else:
|
|
302
|
+
# For other tools, use the first string value or empty
|
|
303
|
+
command = tool_input.get("command", "") or tool_input.get("prompt", "")
|
|
304
|
+
|
|
305
|
+
return ValidationRequest(
|
|
306
|
+
tool_name=tool_name,
|
|
307
|
+
command=command,
|
|
308
|
+
tool_input=tool_input,
|
|
309
|
+
session_id=session_id,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# ------------------------------------------------------------------ #
|
|
313
|
+
# T006: parse_post_tool_use helper
|
|
314
|
+
# ------------------------------------------------------------------ #
|
|
315
|
+
|
|
316
|
+
def parse_post_tool_use(self, raw: Dict[str, Any]) -> ToolResult:
|
|
317
|
+
"""Extract a ToolResult from a PostToolUse payload.
|
|
318
|
+
|
|
319
|
+
Extracts:
|
|
320
|
+
- tool_name: the tool that was invoked
|
|
321
|
+
- command: the command that was run (from tool_input)
|
|
322
|
+
- output: tool execution output
|
|
323
|
+
- exit_code: execution exit code
|
|
324
|
+
- session_id: session identifier
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
raw: The full stdin JSON dict (HookEvent.payload).
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
ToolResult with execution data.
|
|
331
|
+
"""
|
|
332
|
+
tool_name = raw.get("tool_name", "")
|
|
333
|
+
tool_input = raw.get("tool_input", {})
|
|
334
|
+
tool_response = raw.get("tool_response", {})
|
|
335
|
+
session_id = raw.get("session_id", "")
|
|
336
|
+
|
|
337
|
+
command = tool_input.get("command", "")
|
|
338
|
+
output = tool_response.get("output", "")
|
|
339
|
+
exit_code = tool_response.get("exit_code", 0)
|
|
340
|
+
|
|
341
|
+
return ToolResult(
|
|
342
|
+
tool_name=tool_name,
|
|
343
|
+
command=command,
|
|
344
|
+
output=output,
|
|
345
|
+
exit_code=exit_code,
|
|
346
|
+
session_id=session_id,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# ------------------------------------------------------------------ #
|
|
350
|
+
# T007: parse_agent_completion helper
|
|
351
|
+
# ------------------------------------------------------------------ #
|
|
352
|
+
|
|
353
|
+
def parse_agent_completion(self, raw: Dict[str, Any]) -> AgentCompletion:
|
|
354
|
+
"""Extract an AgentCompletion from a SubagentStop payload.
|
|
355
|
+
|
|
356
|
+
Extracts:
|
|
357
|
+
- agent_type: the type/name of the agent (e.g. "cloud-troubleshooter")
|
|
358
|
+
- agent_id: unique agent instance identifier
|
|
359
|
+
- transcript_path: path to the agent's transcript JSONL
|
|
360
|
+
- last_message: the agent's final assistant message
|
|
361
|
+
- session_id: session identifier
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
raw: The full stdin JSON dict (HookEvent.payload).
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
AgentCompletion with agent data.
|
|
368
|
+
"""
|
|
369
|
+
return AgentCompletion(
|
|
370
|
+
agent_type=raw.get("agent_type", ""),
|
|
371
|
+
agent_id=raw.get("agent_id", ""),
|
|
372
|
+
transcript_path=raw.get("agent_transcript_path", ""),
|
|
373
|
+
last_message=raw.get("last_assistant_message", ""),
|
|
374
|
+
session_id=raw.get("session_id", ""),
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
# ------------------------------------------------------------------ #
|
|
378
|
+
# _get_gaia_agent_names: discover Gaia-managed agents from agents/ dir
|
|
379
|
+
# ------------------------------------------------------------------ #
|
|
380
|
+
|
|
381
|
+
def _get_gaia_agent_names(self) -> set:
|
|
382
|
+
"""Get names of Gaia-managed agents from the agents/ directory.
|
|
383
|
+
|
|
384
|
+
Returns a set of agent names (filenames without .md extension).
|
|
385
|
+
Native Claude Code agents (Explore, Plan, claude-code-guide) will
|
|
386
|
+
not appear in this set, enabling bypass of contract validation.
|
|
387
|
+
"""
|
|
388
|
+
agents_dir = Path(__file__).resolve().parent.parent.parent / "agents"
|
|
389
|
+
if not agents_dir.is_dir():
|
|
390
|
+
return set()
|
|
391
|
+
return {
|
|
392
|
+
f.stem
|
|
393
|
+
for f in agents_dir.iterdir()
|
|
394
|
+
if f.suffix == ".md" and f.is_file()
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
# ------------------------------------------------------------------ #
|
|
398
|
+
# format_ask_response: for interactive permission requests
|
|
399
|
+
# ------------------------------------------------------------------ #
|
|
400
|
+
|
|
401
|
+
def format_ask_response(
|
|
402
|
+
self, reason: str, updated_input: dict | None = None
|
|
403
|
+
) -> HookResponse:
|
|
404
|
+
"""Format an 'ask' permission response.
|
|
405
|
+
|
|
406
|
+
Used when the hook wants Claude Code to ask the user for permission.
|
|
407
|
+
This is distinct from deny (which silently blocks).
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
reason: Human-readable explanation forwarded to the agent.
|
|
411
|
+
updated_input: Optional modified tool input (e.g. footer-stripped
|
|
412
|
+
command) to include as ``updatedInput`` so the modification
|
|
413
|
+
survives the native permission dialog.
|
|
414
|
+
"""
|
|
415
|
+
output: Dict[str, Any] = {
|
|
416
|
+
"hookSpecificOutput": {
|
|
417
|
+
"hookEventName": "PreToolUse",
|
|
418
|
+
"permissionDecision": PermissionDecision.ASK.value,
|
|
419
|
+
"permissionDecisionReason": reason,
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if updated_input:
|
|
423
|
+
output["hookSpecificOutput"]["updatedInput"] = updated_input
|
|
424
|
+
return HookResponse(output=output, exit_code=0)
|
|
425
|
+
|
|
426
|
+
# ------------------------------------------------------------------ #
|
|
427
|
+
# adapt_pre_tool_use: full pre-tool-use lifecycle
|
|
428
|
+
# ------------------------------------------------------------------ #
|
|
429
|
+
|
|
430
|
+
def adapt_pre_tool_use(self, event: HookEvent) -> HookResponse:
|
|
431
|
+
"""Run all pre-tool-use business logic and return a formatted response.
|
|
432
|
+
|
|
433
|
+
Orchestrates: routing (bash vs task), validation, state management,
|
|
434
|
+
context injection, approval handling, and response formatting.
|
|
435
|
+
"""
|
|
436
|
+
from modules.core.state import create_pre_hook_state, save_hook_state
|
|
437
|
+
from modules.security.approval_grants import (
|
|
438
|
+
cleanup_expired_grants,
|
|
439
|
+
)
|
|
440
|
+
from modules.tools.bash_validator import BashValidator
|
|
441
|
+
from modules.tools.task_validator import TaskValidator, AVAILABLE_AGENTS, META_AGENTS
|
|
442
|
+
hook_data = event.payload
|
|
443
|
+
tool_name = hook_data.get("tool_name") or ""
|
|
444
|
+
tool_input = hook_data.get("tool_input", {})
|
|
445
|
+
|
|
446
|
+
logger.info("Hook invoked: tool=%s, params=%s", tool_name, json.dumps(tool_input)[:200])
|
|
447
|
+
|
|
448
|
+
try:
|
|
449
|
+
# ── Delegate mode gate ─────────────────────────────────
|
|
450
|
+
# Must run before any other logic. When enabled, the
|
|
451
|
+
# orchestrator (main session) is restricted to dispatch-only
|
|
452
|
+
# tools. Subagents are unaffected.
|
|
453
|
+
from modules.orchestrator.delegate_mode import check_delegate_mode
|
|
454
|
+
|
|
455
|
+
dm_result = check_delegate_mode(tool_name, hook_data)
|
|
456
|
+
if dm_result.blocked:
|
|
457
|
+
logger.warning(
|
|
458
|
+
"DELEGATE_MODE denied %s for orchestrator", tool_name,
|
|
459
|
+
)
|
|
460
|
+
return HookResponse(
|
|
461
|
+
output={
|
|
462
|
+
"hookSpecificOutput": {
|
|
463
|
+
"hookEventName": "PreToolUse",
|
|
464
|
+
"permissionDecision": "deny",
|
|
465
|
+
"permissionDecisionReason": dm_result.reason,
|
|
466
|
+
}
|
|
467
|
+
},
|
|
468
|
+
exit_code=0,
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
# Periodic cleanup of expired approval grants
|
|
472
|
+
cleanup_expired_grants()
|
|
473
|
+
|
|
474
|
+
if not isinstance(tool_name, str):
|
|
475
|
+
return HookResponse(output="Error: Invalid tool name", exit_code=2)
|
|
476
|
+
if not isinstance(tool_input, dict):
|
|
477
|
+
return HookResponse(output="Error: Invalid parameters", exit_code=2)
|
|
478
|
+
|
|
479
|
+
if tool_name.lower() == "bash":
|
|
480
|
+
return self._adapt_bash(tool_name, tool_input, hook_data=hook_data)
|
|
481
|
+
elif tool_name.lower() in ("task", "agent"):
|
|
482
|
+
hooks_dir = Path(__file__).parent.parent
|
|
483
|
+
project_agents = [a for a in AVAILABLE_AGENTS if a not in META_AGENTS]
|
|
484
|
+
return self._adapt_task(
|
|
485
|
+
tool_name, tool_input, project_agents, hooks_dir,
|
|
486
|
+
session_id=event.session_id,
|
|
487
|
+
)
|
|
488
|
+
elif tool_name.lower() == "sendmessage":
|
|
489
|
+
return self._adapt_send_message(tool_name, tool_input)
|
|
490
|
+
elif tool_name.lower() in ("write", "edit"):
|
|
491
|
+
is_subagent = bool(hook_data and hook_data.get("agent_id"))
|
|
492
|
+
session_id = (hook_data or {}).get("session_id", "")
|
|
493
|
+
return self._adapt_write_edit(
|
|
494
|
+
tool_name, tool_input,
|
|
495
|
+
session_id=session_id,
|
|
496
|
+
is_subagent=is_subagent,
|
|
497
|
+
)
|
|
498
|
+
else:
|
|
499
|
+
# Other tools pass through
|
|
500
|
+
return HookResponse(output={}, exit_code=0)
|
|
501
|
+
|
|
502
|
+
except Exception as e:
|
|
503
|
+
logger.error("Unexpected error in adapt_pre_tool_use: %s", e, exc_info=True)
|
|
504
|
+
return HookResponse(
|
|
505
|
+
output=f"Error during security validation: {str(e)}",
|
|
506
|
+
exit_code=2,
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
def _adapt_bash(
|
|
510
|
+
self,
|
|
511
|
+
tool_name: str,
|
|
512
|
+
parameters: dict,
|
|
513
|
+
hook_data: dict | None = None,
|
|
514
|
+
) -> HookResponse:
|
|
515
|
+
"""Handle Bash tool validation within the adapter.
|
|
516
|
+
|
|
517
|
+
Args:
|
|
518
|
+
tool_name: The tool name ("Bash").
|
|
519
|
+
parameters: The tool_input dict (contains "command").
|
|
520
|
+
hook_data: Full hook event payload -- used to detect subagent
|
|
521
|
+
context via the ``agent_id`` field.
|
|
522
|
+
"""
|
|
523
|
+
from modules.core.state import create_pre_hook_state, save_hook_state
|
|
524
|
+
from modules.tools.bash_validator import BashValidator
|
|
525
|
+
|
|
526
|
+
command = parameters.get("command", "")
|
|
527
|
+
if not command:
|
|
528
|
+
return HookResponse(output="Error: Bash tool requires a command", exit_code=2)
|
|
529
|
+
|
|
530
|
+
# Detect subagent context: if agent_id is present in the hook event,
|
|
531
|
+
# the command is running inside a subagent (not the orchestrator).
|
|
532
|
+
is_subagent = bool(hook_data and hook_data.get("agent_id"))
|
|
533
|
+
session_id = (hook_data or {}).get("session_id", "")
|
|
534
|
+
|
|
535
|
+
validator = BashValidator()
|
|
536
|
+
result = validator.validate(
|
|
537
|
+
command, is_subagent=is_subagent, session_id=session_id,
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
if not result.allowed:
|
|
541
|
+
from modules.core.plugin_mode import is_ops_mode
|
|
542
|
+
logger.warning("BLOCKED: %s - %s", command[:100], result.reason)
|
|
543
|
+
# Security-only mode: delegate T3 approval to native Claude Code dialog
|
|
544
|
+
# instead of blocking with nonce (which requires orchestrator + agents)
|
|
545
|
+
if not is_ops_mode():
|
|
546
|
+
reason_line = result.reason.split('\n')[0] if result.reason else f"T3 operation: {command[:80]}"
|
|
547
|
+
# Permanently blocked commands (rm -rf, kubectl delete namespace, etc.)
|
|
548
|
+
# are denied even in security mode — user cannot override
|
|
549
|
+
is_permanently_blocked = "blocked by security policy" in (result.reason or "").lower()
|
|
550
|
+
if is_permanently_blocked:
|
|
551
|
+
logger.info("SECURITY MODE: permanently denied: %s", command[:80])
|
|
552
|
+
output = {
|
|
553
|
+
"hookSpecificOutput": {
|
|
554
|
+
"hookEventName": "PreToolUse",
|
|
555
|
+
"permissionDecision": "deny",
|
|
556
|
+
"permissionDecisionReason": f"[BLOCKED] {reason_line}",
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return HookResponse(output=output, exit_code=2)
|
|
560
|
+
# Mutative commands (git commit, terraform apply, etc.) → ask user
|
|
561
|
+
logger.info("SECURITY MODE: returning 'ask' for T3: %s", command[:80])
|
|
562
|
+
output = {
|
|
563
|
+
"hookSpecificOutput": {
|
|
564
|
+
"hookEventName": "PreToolUse",
|
|
565
|
+
"permissionDecision": "ask",
|
|
566
|
+
"permissionDecisionReason": f"[{result.tier}] {reason_line}",
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return HookResponse(output=output, exit_code=0)
|
|
570
|
+
# Ops mode: block with nonce for orchestrator approval flow
|
|
571
|
+
if result.block_response is not None:
|
|
572
|
+
return HookResponse(output=result.block_response, exit_code=0)
|
|
573
|
+
return HookResponse(
|
|
574
|
+
output=self._format_blocked_message(result),
|
|
575
|
+
exit_code=2,
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
# Save state for post-hook
|
|
579
|
+
effective_command = result.modified_input.get("command", command) if result.modified_input else command
|
|
580
|
+
state = create_pre_hook_state(
|
|
581
|
+
tool_name=tool_name,
|
|
582
|
+
command=effective_command,
|
|
583
|
+
tier=str(result.tier),
|
|
584
|
+
allowed=True,
|
|
585
|
+
)
|
|
586
|
+
save_hook_state(state)
|
|
587
|
+
|
|
588
|
+
if result.modified_input:
|
|
589
|
+
logger.info("MODIFIED: %s -> stripped footer - tier=%s", command[:80], result.tier)
|
|
590
|
+
output = {
|
|
591
|
+
"hookSpecificOutput": {
|
|
592
|
+
"hookEventName": "PreToolUse",
|
|
593
|
+
"permissionDecision": "allow",
|
|
594
|
+
"permissionDecisionReason": result.reason,
|
|
595
|
+
"updatedInput": result.modified_input,
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return HookResponse(output=output, exit_code=0)
|
|
599
|
+
|
|
600
|
+
logger.info("ALLOWED: %s - tier=%s", command[:100], result.tier)
|
|
601
|
+
return HookResponse(output={}, exit_code=0)
|
|
602
|
+
|
|
603
|
+
def _adapt_task(
|
|
604
|
+
self,
|
|
605
|
+
tool_name: str,
|
|
606
|
+
parameters: dict,
|
|
607
|
+
project_agents: list,
|
|
608
|
+
hooks_dir: Path,
|
|
609
|
+
session_id: str = "",
|
|
610
|
+
) -> HookResponse:
|
|
611
|
+
"""Handle Task/Agent tool validation within the adapter.
|
|
612
|
+
|
|
613
|
+
Builds project context and caches it for SubagentStart to forward.
|
|
614
|
+
PreToolUse no longer returns additionalContext directly -- that would
|
|
615
|
+
inject it into the orchestrator instead of the subagent.
|
|
616
|
+
"""
|
|
617
|
+
from modules.core.state import create_pre_hook_state, save_hook_state
|
|
618
|
+
from modules.tools.task_validator import TaskValidator
|
|
619
|
+
from modules.context.context_injector import build_project_context
|
|
620
|
+
from modules.session.session_event_injector import build_session_events
|
|
621
|
+
|
|
622
|
+
context_text, _telemetry = build_project_context(parameters, project_agents, hooks_dir)
|
|
623
|
+
events_text = build_session_events(parameters, project_agents)
|
|
624
|
+
|
|
625
|
+
# Standard task validation (runs against ORIGINAL prompt -- no workaround needed)
|
|
626
|
+
validator = TaskValidator()
|
|
627
|
+
result = validator.validate(parameters)
|
|
628
|
+
|
|
629
|
+
if not result.allowed:
|
|
630
|
+
logger.warning("BLOCKED Task: %s - %s", result.agent_name, result.reason)
|
|
631
|
+
return HookResponse(output=result.reason, exit_code=2)
|
|
632
|
+
|
|
633
|
+
state = create_pre_hook_state(
|
|
634
|
+
tool_name=tool_name,
|
|
635
|
+
command=f"Task:{result.agent_name}",
|
|
636
|
+
tier=str(result.tier),
|
|
637
|
+
allowed=True,
|
|
638
|
+
is_t3=result.is_t3_operation,
|
|
639
|
+
)
|
|
640
|
+
save_hook_state(state)
|
|
641
|
+
|
|
642
|
+
logger.info("ALLOWED Task: %s", result.agent_name)
|
|
643
|
+
|
|
644
|
+
# Cache context for SubagentStart to pick up and forward to the subagent.
|
|
645
|
+
# PreToolUse:Agent additionalContext goes to the orchestrator (wrong target).
|
|
646
|
+
additional = "\n".join(filter(None, [context_text, events_text]))
|
|
647
|
+
|
|
648
|
+
# Fallback: if build_project_context returned None because the
|
|
649
|
+
# orchestrator already embedded context in the prompt (dedup guard),
|
|
650
|
+
# extract the embedded context so SubagentStart can still inject it
|
|
651
|
+
# via additionalContext.
|
|
652
|
+
if not additional:
|
|
653
|
+
prompt = parameters.get("prompt", "")
|
|
654
|
+
marker = "# Project Context"
|
|
655
|
+
if marker in prompt:
|
|
656
|
+
# Extract everything from the marker onwards as context.
|
|
657
|
+
# The orchestrator copied its own injected context into the
|
|
658
|
+
# Agent tool prompt; we forward it to SubagentStart instead.
|
|
659
|
+
idx = prompt.index(marker)
|
|
660
|
+
additional = prompt[idx:]
|
|
661
|
+
logger.info(
|
|
662
|
+
"Extracted embedded context from prompt for caching "
|
|
663
|
+
"(len=%d, agent=%s)",
|
|
664
|
+
len(additional), result.agent_name,
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
if additional:
|
|
668
|
+
effective_session_id = session_id or "unknown"
|
|
669
|
+
agent_type = result.agent_name or "unknown"
|
|
670
|
+
self._cache_context_for_subagent(effective_session_id, agent_type, additional)
|
|
671
|
+
logger.info(
|
|
672
|
+
"Cached context for SubagentStart: agent=%s, session=%s",
|
|
673
|
+
agent_type, effective_session_id,
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
# Write AGENT_DISPATCH event (non-blocking)
|
|
677
|
+
try:
|
|
678
|
+
from modules.events.event_writer import EventWriter, AGENT_DISPATCH
|
|
679
|
+
prompt = parameters.get("prompt", "")
|
|
680
|
+
EventWriter().write_event(
|
|
681
|
+
AGENT_DISPATCH, "hook", result.agent_name or "unknown",
|
|
682
|
+
f"dispatched for: {prompt[:100]}",
|
|
683
|
+
)
|
|
684
|
+
except Exception:
|
|
685
|
+
pass # Events are non-critical
|
|
686
|
+
|
|
687
|
+
return HookResponse(output={}, exit_code=0)
|
|
688
|
+
|
|
689
|
+
def _adapt_send_message(
|
|
690
|
+
self, tool_name: str, parameters: dict,
|
|
691
|
+
) -> HookResponse:
|
|
692
|
+
"""Handle SendMessage tool validation for agent resumption.
|
|
693
|
+
|
|
694
|
+
Validates agent ID format and message content. Does NOT inject
|
|
695
|
+
project context (it's a resume). Nonce relay is no longer processed
|
|
696
|
+
here -- approval grants are activated by the UserPromptSubmit hook.
|
|
697
|
+
"""
|
|
698
|
+
from modules.core.state import create_pre_hook_state, save_hook_state
|
|
699
|
+
|
|
700
|
+
agent_id = parameters.get("to", "")
|
|
701
|
+
message = parameters.get("message", "")
|
|
702
|
+
|
|
703
|
+
# Validate agentId format
|
|
704
|
+
if not agent_id or not re.match(r'^a[0-9a-f]{5,}$', agent_id):
|
|
705
|
+
logger.warning("BLOCKED SendMessage: Invalid agentId format '%s'", agent_id)
|
|
706
|
+
msg = (
|
|
707
|
+
f"[ERROR] Invalid agent ID format: '{agent_id}'\n\n"
|
|
708
|
+
"Agent ID should be 'a' followed by hex characters.\n"
|
|
709
|
+
"Example: a12345f or a51a0cbbf6afb831d\n\n"
|
|
710
|
+
"The agent ID is returned at the end of agent responses.\n"
|
|
711
|
+
"Look for: 'agentId: a...' in the previous agent output."
|
|
712
|
+
)
|
|
713
|
+
return HookResponse(output=msg, exit_code=2)
|
|
714
|
+
|
|
715
|
+
if not message or not message.strip():
|
|
716
|
+
logger.warning("BLOCKED SendMessage: Missing message for agent %s", agent_id)
|
|
717
|
+
msg = (
|
|
718
|
+
"[ERROR] SendMessage requires a message\n\n"
|
|
719
|
+
"When resuming an agent, you must provide a message:\n\n"
|
|
720
|
+
"SendMessage(\n"
|
|
721
|
+
" to=\"a12345\",\n"
|
|
722
|
+
" message=\"Continue with the latest user instruction.\"\n"
|
|
723
|
+
")\n\n"
|
|
724
|
+
"The message tells the agent what to do next."
|
|
725
|
+
)
|
|
726
|
+
return HookResponse(output=msg, exit_code=2)
|
|
727
|
+
|
|
728
|
+
logger.info("SENDMESSAGE: Resuming agent %s", agent_id)
|
|
729
|
+
|
|
730
|
+
state = create_pre_hook_state(
|
|
731
|
+
tool_name=tool_name,
|
|
732
|
+
command=f"SendMessage:{agent_id}",
|
|
733
|
+
tier="T0",
|
|
734
|
+
allowed=True,
|
|
735
|
+
is_t3=False,
|
|
736
|
+
has_approval=False,
|
|
737
|
+
)
|
|
738
|
+
save_hook_state(state)
|
|
739
|
+
|
|
740
|
+
logger.info("ALLOWED SendMessage: agent %s - message length: %d", agent_id, len(message))
|
|
741
|
+
return HookResponse(output={}, exit_code=0)
|
|
742
|
+
|
|
743
|
+
def _adapt_write_edit(
|
|
744
|
+
self,
|
|
745
|
+
tool_name: str,
|
|
746
|
+
parameters: dict,
|
|
747
|
+
session_id: str = "",
|
|
748
|
+
is_subagent: bool = False,
|
|
749
|
+
) -> HookResponse:
|
|
750
|
+
"""Handle Write and Edit tool path protection.
|
|
751
|
+
|
|
752
|
+
Blocks modifications to Gaia hooks, settings, and security config
|
|
753
|
+
by requiring user approval for any path that matches protected path
|
|
754
|
+
patterns.
|
|
755
|
+
|
|
756
|
+
Foreground (orchestrator) flow: returns permissionDecision "ask" so
|
|
757
|
+
the native Claude Code dialog handles approval.
|
|
758
|
+
|
|
759
|
+
Subagent flow: mirrors the bash_validator nonce-based pattern.
|
|
760
|
+
- Checks for an existing pending approval (retry guard).
|
|
761
|
+
- If found, returns deny with the existing approval_id.
|
|
762
|
+
- If not found, writes a pending approval and returns deny with a
|
|
763
|
+
new approval_id so the orchestrator can ask the user and activate
|
|
764
|
+
the grant via the ElicitationResult hook.
|
|
765
|
+
- On retry, if an active grant exists for this path, allows through.
|
|
766
|
+
|
|
767
|
+
Protected paths:
|
|
768
|
+
- Any path that resolves within the gaia-ops hooks directory (Path.resolve().relative_to(hooks_dir)), EXCEPT .md files — documentation does not execute code and is exempt
|
|
769
|
+
- .claude/settings.json and .claude/settings.local.json
|
|
770
|
+
"""
|
|
771
|
+
from modules.security.approval_grants import (
|
|
772
|
+
check_approval_grant_for_file,
|
|
773
|
+
find_pending_for_file,
|
|
774
|
+
generate_nonce,
|
|
775
|
+
write_pending_approval_for_file,
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
file_path = parameters.get("file_path", "")
|
|
779
|
+
if not file_path:
|
|
780
|
+
return HookResponse(output={}, exit_code=0)
|
|
781
|
+
|
|
782
|
+
hooks_dir = Path(__file__).parent.parent.resolve()
|
|
783
|
+
|
|
784
|
+
def _is_protected(path_str):
|
|
785
|
+
p = Path(path_str)
|
|
786
|
+
try:
|
|
787
|
+
rp = p.resolve()
|
|
788
|
+
except Exception:
|
|
789
|
+
rp = p
|
|
790
|
+
try:
|
|
791
|
+
rp.relative_to(hooks_dir)
|
|
792
|
+
if rp.suffix == ".md":
|
|
793
|
+
return False # docs don't execute code; exempt from protection
|
|
794
|
+
return True
|
|
795
|
+
except ValueError:
|
|
796
|
+
pass
|
|
797
|
+
if p.name in ("settings.json", "settings.local.json"):
|
|
798
|
+
for part in rp.parts:
|
|
799
|
+
if part == ".claude":
|
|
800
|
+
return True
|
|
801
|
+
return False
|
|
802
|
+
|
|
803
|
+
if not _is_protected(file_path):
|
|
804
|
+
return HookResponse(output={}, exit_code=0)
|
|
805
|
+
|
|
806
|
+
logger.warning(
|
|
807
|
+
"PROTECTED_PATH: %s attempted to modify %s (subagent=%s)",
|
|
808
|
+
tool_name, file_path, is_subagent,
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
if not is_subagent:
|
|
812
|
+
# Foreground / orchestrator context: use native approval dialog.
|
|
813
|
+
reason = (
|
|
814
|
+
"[PROTECTED_PATH] Modifications to Gaia hooks and security config "
|
|
815
|
+
"require approval."
|
|
816
|
+
)
|
|
817
|
+
return HookResponse(
|
|
818
|
+
output={
|
|
819
|
+
"hookSpecificOutput": {
|
|
820
|
+
"hookEventName": "PreToolUse",
|
|
821
|
+
"permissionDecision": "ask",
|
|
822
|
+
"permissionDecisionReason": reason,
|
|
823
|
+
}
|
|
824
|
+
},
|
|
825
|
+
exit_code=0,
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
# Subagent context: nonce-based pending approval flow.
|
|
829
|
+
|
|
830
|
+
# 1. Check if a grant has already been activated for this path (retry
|
|
831
|
+
# after user approved).
|
|
832
|
+
existing_grant = check_approval_grant_for_file(file_path, session_id or None)
|
|
833
|
+
if existing_grant:
|
|
834
|
+
logger.info(
|
|
835
|
+
"File-path grant active, allowing %s through: %s",
|
|
836
|
+
tool_name, file_path,
|
|
837
|
+
)
|
|
838
|
+
return HookResponse(output={}, exit_code=0)
|
|
839
|
+
|
|
840
|
+
# 2. Check if a pending approval already exists (guard against infinite
|
|
841
|
+
# approval_id generation while the user is still reviewing).
|
|
842
|
+
existing_nonce = find_pending_for_file(session_id or "", file_path)
|
|
843
|
+
if existing_nonce:
|
|
844
|
+
approval_id = existing_nonce
|
|
845
|
+
logger.info(
|
|
846
|
+
"Reusing pending approval_id=%s for retry: %s",
|
|
847
|
+
approval_id, file_path,
|
|
848
|
+
)
|
|
849
|
+
else:
|
|
850
|
+
# 3. No existing pending -- generate a new nonce.
|
|
851
|
+
approval_id = generate_nonce()
|
|
852
|
+
pending_path = write_pending_approval_for_file(
|
|
853
|
+
nonce=approval_id,
|
|
854
|
+
file_path=file_path,
|
|
855
|
+
session_id=session_id or None,
|
|
856
|
+
)
|
|
857
|
+
if pending_path is None:
|
|
858
|
+
# Persistence failure -- fall back to native ask dialog.
|
|
859
|
+
logger.warning(
|
|
860
|
+
"Failed to persist pending file-path approval for subagent; "
|
|
861
|
+
"falling back to ask: %s",
|
|
862
|
+
file_path,
|
|
863
|
+
)
|
|
864
|
+
reason = (
|
|
865
|
+
"[PROTECTED_PATH] Modifications to Gaia hooks and security config "
|
|
866
|
+
"require approval. (Pending approval persistence failed; "
|
|
867
|
+
"native dialog fallback.)"
|
|
868
|
+
)
|
|
869
|
+
return HookResponse(
|
|
870
|
+
output={
|
|
871
|
+
"hookSpecificOutput": {
|
|
872
|
+
"hookEventName": "PreToolUse",
|
|
873
|
+
"permissionDecision": "ask",
|
|
874
|
+
"permissionDecisionReason": reason,
|
|
875
|
+
}
|
|
876
|
+
},
|
|
877
|
+
exit_code=0,
|
|
878
|
+
)
|
|
879
|
+
|
|
880
|
+
reason = (
|
|
881
|
+
f"[T3_BLOCKED] This file modification requires user approval.\n"
|
|
882
|
+
f"Do NOT retry this operation. Report APPROVAL_REQUEST with this approval_id "
|
|
883
|
+
f"in your json:contract.\n"
|
|
884
|
+
f"File: {file_path}\n"
|
|
885
|
+
f"Tool: {tool_name}\n"
|
|
886
|
+
f"approval_id: {approval_id}"
|
|
887
|
+
)
|
|
888
|
+
return HookResponse(
|
|
889
|
+
output={
|
|
890
|
+
"hookSpecificOutput": {
|
|
891
|
+
"hookEventName": "PreToolUse",
|
|
892
|
+
"permissionDecision": "deny",
|
|
893
|
+
"permissionDecisionReason": reason,
|
|
894
|
+
}
|
|
895
|
+
},
|
|
896
|
+
exit_code=0,
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
@staticmethod
|
|
900
|
+
def _format_blocked_message(result) -> str:
|
|
901
|
+
"""Format blocked command message. Delegates to blocked_message_formatter."""
|
|
902
|
+
from modules.security.blocked_message_formatter import format_blocked_message
|
|
903
|
+
return format_blocked_message(result)
|
|
904
|
+
|
|
905
|
+
# ------------------------------------------------------------------ #
|
|
906
|
+
# adapt_post_tool_use: full post-tool-use lifecycle
|
|
907
|
+
# ------------------------------------------------------------------ #
|
|
908
|
+
|
|
909
|
+
def adapt_post_tool_use(self, event: HookEvent) -> HookResponse:
|
|
910
|
+
"""Run all post-tool-use business logic and return a formatted response.
|
|
911
|
+
|
|
912
|
+
Orchestrates: state retrieval, duration computation, audit logging,
|
|
913
|
+
T3 grant confirmation, critical event detection, session context
|
|
914
|
+
writing, state cleanup, and AskUserQuestion grant activation.
|
|
915
|
+
"""
|
|
916
|
+
from modules.core.state import get_hook_state, clear_hook_state
|
|
917
|
+
from modules.audit.logger import log_execution
|
|
918
|
+
from modules.audit.event_detector import detect_critical_event
|
|
919
|
+
from modules.session.session_context_writer import SessionContextWriter
|
|
920
|
+
from modules.security.approval_grants import check_approval_grant, confirm_grant
|
|
921
|
+
|
|
922
|
+
hook_data = event.payload
|
|
923
|
+
tool_result_data = self.parse_post_tool_use(hook_data)
|
|
924
|
+
logger.info("Post-hook event: %s", hook_data.get("hook_event_name"))
|
|
925
|
+
|
|
926
|
+
raw_tool_response = hook_data.get("tool_response", {})
|
|
927
|
+
tool_name = tool_result_data.tool_name
|
|
928
|
+
parameters = hook_data.get("tool_input", {})
|
|
929
|
+
output = tool_result_data.output
|
|
930
|
+
duration = raw_tool_response.get("duration_ms", 0) / 1000.0
|
|
931
|
+
success = tool_result_data.exit_code == 0
|
|
932
|
+
|
|
933
|
+
# ------------------------------------------------------------- #
|
|
934
|
+
# AskUserQuestion: check if user approved a pending T3 grant
|
|
935
|
+
# ------------------------------------------------------------- #
|
|
936
|
+
if tool_name == "AskUserQuestion":
|
|
937
|
+
self._handle_ask_user_question_result(hook_data)
|
|
938
|
+
return HookResponse(output={}, exit_code=0)
|
|
939
|
+
|
|
940
|
+
try:
|
|
941
|
+
pre_state = get_hook_state()
|
|
942
|
+
tier = pre_state.tier if pre_state else "unknown"
|
|
943
|
+
|
|
944
|
+
# Prefer wall-clock duration from pre-hook timestamp
|
|
945
|
+
computed_duration = duration
|
|
946
|
+
if pre_state and pre_state.start_time_epoch > 0:
|
|
947
|
+
computed_duration = time.time() - pre_state.start_time_epoch
|
|
948
|
+
|
|
949
|
+
log_execution(
|
|
950
|
+
tool_name=tool_name,
|
|
951
|
+
parameters=parameters,
|
|
952
|
+
result=output,
|
|
953
|
+
duration=computed_duration,
|
|
954
|
+
exit_code=0 if success else 1,
|
|
955
|
+
tier=tier,
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
# Confirm unconfirmed T3 grants after successful Bash execution.
|
|
959
|
+
# Grants are consumed later at SubagentStop, not here -- the grant
|
|
960
|
+
# lives for the full subagent session so retries work naturally.
|
|
961
|
+
if tool_name == "Bash" and success:
|
|
962
|
+
command = parameters.get("command", "")
|
|
963
|
+
session_id = hook_data.get("session_id", "")
|
|
964
|
+
if command:
|
|
965
|
+
grant = check_approval_grant(command, session_id=session_id)
|
|
966
|
+
if grant is not None and not grant.confirmed:
|
|
967
|
+
confirm_grant(command, session_id=session_id)
|
|
968
|
+
logger.info(
|
|
969
|
+
"T3 grant confirmed (will be consumed at SubagentStop): %s", command[:80],
|
|
970
|
+
)
|
|
971
|
+
|
|
972
|
+
events = detect_critical_event(tool_name, parameters, output, success)
|
|
973
|
+
if events:
|
|
974
|
+
writer = SessionContextWriter()
|
|
975
|
+
for evt in events:
|
|
976
|
+
writer.update_context(evt.to_dict())
|
|
977
|
+
|
|
978
|
+
# Write COMMAND_EXECUTED event for T2+ Bash commands only (non-blocking)
|
|
979
|
+
if tool_name == "Bash" and tier in ("T2", "T3"):
|
|
980
|
+
try:
|
|
981
|
+
from modules.events.event_writer import EventWriter, COMMAND_EXECUTED
|
|
982
|
+
cmd = parameters.get("command", "")
|
|
983
|
+
EventWriter().write_event(
|
|
984
|
+
COMMAND_EXECUTED, "hook", "",
|
|
985
|
+
f"{'ok' if success else 'error'}: {cmd[:120]}",
|
|
986
|
+
severity="info" if success else "warning",
|
|
987
|
+
meta={"tier": tier},
|
|
988
|
+
)
|
|
989
|
+
except Exception:
|
|
990
|
+
pass # Events are non-critical
|
|
991
|
+
|
|
992
|
+
clear_hook_state()
|
|
993
|
+
logger.debug("Post-hook completed for %s", tool_name)
|
|
994
|
+
|
|
995
|
+
except Exception as e:
|
|
996
|
+
logger.error("Error in adapt_post_tool_use: %s", e, exc_info=True)
|
|
997
|
+
|
|
998
|
+
return HookResponse(output={}, exit_code=0)
|
|
999
|
+
|
|
1000
|
+
# ------------------------------------------------------------------ #
|
|
1001
|
+
# _handle_ask_user_question_result: grant activation from user answer
|
|
1002
|
+
# ------------------------------------------------------------------ #
|
|
1003
|
+
|
|
1004
|
+
def _handle_ask_user_question_result(self, hook_data: Dict[str, Any]) -> None:
|
|
1005
|
+
"""Conditionally activate pending grants based on user's answer.
|
|
1006
|
+
|
|
1007
|
+
Uses nonce-targeted activation when the approved answer contains a
|
|
1008
|
+
``[P-<hex>]`` tag (the nonce prefix). This works identically for
|
|
1009
|
+
same-session and cross-session approvals:
|
|
1010
|
+
1. Extract the nonce prefix from the approved label.
|
|
1011
|
+
2. Load the specific pending file by prefix (any session).
|
|
1012
|
+
3. Activate the grant under the CURRENT session.
|
|
1013
|
+
|
|
1014
|
+
Falls back to session-wide activation when no nonce is present in
|
|
1015
|
+
the answer (backward compatibility with older approval labels).
|
|
1016
|
+
|
|
1017
|
+
When the answer also contains "batch", a SCOPE_VERB_FAMILY multi-use
|
|
1018
|
+
grant is created alongside the normal semantic grants. This allows
|
|
1019
|
+
batch operations (e.g., modifying hundreds of emails) to proceed
|
|
1020
|
+
without per-command approval.
|
|
1021
|
+
|
|
1022
|
+
Never blocks (no exceptions raised to caller).
|
|
1023
|
+
"""
|
|
1024
|
+
from modules.security.approval_grants import (
|
|
1025
|
+
activate_cross_session_pending,
|
|
1026
|
+
activate_grants_for_session,
|
|
1027
|
+
activate_pending_approval,
|
|
1028
|
+
create_verb_family_grant,
|
|
1029
|
+
extract_nonce_from_label,
|
|
1030
|
+
get_pending_approvals_for_session,
|
|
1031
|
+
load_pending_by_nonce_prefix,
|
|
1032
|
+
)
|
|
1033
|
+
|
|
1034
|
+
session_id = hook_data.get("session_id", "") or os.environ.get("CLAUDE_SESSION_ID", "")
|
|
1035
|
+
|
|
1036
|
+
# Extract answers from tool_response first, then tool_input as fallback
|
|
1037
|
+
tool_response = hook_data.get("tool_response", {})
|
|
1038
|
+
answers = {}
|
|
1039
|
+
if isinstance(tool_response, dict):
|
|
1040
|
+
answers = tool_response.get("answers", {})
|
|
1041
|
+
if not answers and isinstance(hook_data.get("tool_input", {}), dict):
|
|
1042
|
+
answers = hook_data.get("tool_input", {}).get("answers", {})
|
|
1043
|
+
|
|
1044
|
+
if not answers:
|
|
1045
|
+
logger.info("AskUserQuestion: no answers found in payload, skipping grant activation")
|
|
1046
|
+
return
|
|
1047
|
+
|
|
1048
|
+
user_approved = any("approve" in str(v).lower() for v in answers.values())
|
|
1049
|
+
|
|
1050
|
+
if not user_approved:
|
|
1051
|
+
logger.info(
|
|
1052
|
+
"AskUserQuestion: user did not approve (answers: %s), skipping grant activation",
|
|
1053
|
+
{k: v for k, v in answers.items()},
|
|
1054
|
+
)
|
|
1055
|
+
return
|
|
1056
|
+
|
|
1057
|
+
# Detect batch intent: answer contains "batch" alongside "approve"
|
|
1058
|
+
is_batch = any("batch" in str(v).lower() for v in answers.values())
|
|
1059
|
+
|
|
1060
|
+
# User approved -- activate grants
|
|
1061
|
+
logger.info("AskUserQuestion: user approved, activating grants for session %s", session_id[:12])
|
|
1062
|
+
|
|
1063
|
+
try:
|
|
1064
|
+
if not session_id:
|
|
1065
|
+
logger.info("AskUserQuestion: no session_id available, skipping grant activation")
|
|
1066
|
+
return
|
|
1067
|
+
|
|
1068
|
+
# Try nonce-targeted activation first: extract nonce from answer labels
|
|
1069
|
+
nonce_prefix = None
|
|
1070
|
+
for v in answers.values():
|
|
1071
|
+
nonce_prefix = extract_nonce_from_label(str(v))
|
|
1072
|
+
if nonce_prefix:
|
|
1073
|
+
break
|
|
1074
|
+
|
|
1075
|
+
activated_pending_data = None # Track for batch grant creation
|
|
1076
|
+
|
|
1077
|
+
if nonce_prefix:
|
|
1078
|
+
# Nonce-targeted: load this specific pending regardless of session
|
|
1079
|
+
pending_data = load_pending_by_nonce_prefix(nonce_prefix)
|
|
1080
|
+
if pending_data:
|
|
1081
|
+
pending_session = pending_data.get("session_id", "")
|
|
1082
|
+
full_nonce = pending_data.get("nonce", "")
|
|
1083
|
+
|
|
1084
|
+
if pending_session == session_id:
|
|
1085
|
+
# Same session -- use standard activation
|
|
1086
|
+
result = activate_pending_approval(
|
|
1087
|
+
nonce=full_nonce,
|
|
1088
|
+
session_id=session_id,
|
|
1089
|
+
)
|
|
1090
|
+
else:
|
|
1091
|
+
# Cross session -- activate under current session
|
|
1092
|
+
result = activate_cross_session_pending(
|
|
1093
|
+
pending_data,
|
|
1094
|
+
session_id=session_id,
|
|
1095
|
+
)
|
|
1096
|
+
|
|
1097
|
+
if result.success:
|
|
1098
|
+
logger.info(
|
|
1099
|
+
"AskUserQuestion nonce-targeted activation: prefix=%s, "
|
|
1100
|
+
"pending_session=%s, current_session=%s, status=%s",
|
|
1101
|
+
nonce_prefix, pending_session[:12], session_id[:12],
|
|
1102
|
+
getattr(result.status, "value", str(result.status)),
|
|
1103
|
+
)
|
|
1104
|
+
activated_pending_data = pending_data
|
|
1105
|
+
else:
|
|
1106
|
+
logger.warning(
|
|
1107
|
+
"AskUserQuestion nonce-targeted activation failed: "
|
|
1108
|
+
"prefix=%s, status=%s, reason=%s",
|
|
1109
|
+
nonce_prefix,
|
|
1110
|
+
getattr(result.status, "value", str(result.status)),
|
|
1111
|
+
result.reason,
|
|
1112
|
+
)
|
|
1113
|
+
else:
|
|
1114
|
+
logger.warning(
|
|
1115
|
+
"AskUserQuestion: nonce prefix %s found in label but no "
|
|
1116
|
+
"matching pending file -- falling back to session-wide",
|
|
1117
|
+
nonce_prefix,
|
|
1118
|
+
)
|
|
1119
|
+
# Fall through to session-wide activation below
|
|
1120
|
+
nonce_prefix = None
|
|
1121
|
+
|
|
1122
|
+
if not nonce_prefix:
|
|
1123
|
+
# No nonce in label (or prefix lookup failed) -- fall back to
|
|
1124
|
+
# session-wide activation for backward compatibility
|
|
1125
|
+
pending = get_pending_approvals_for_session(session_id)
|
|
1126
|
+
if not pending:
|
|
1127
|
+
logger.info("AskUserQuestion: no pending grants for session %s", session_id)
|
|
1128
|
+
return
|
|
1129
|
+
|
|
1130
|
+
results = activate_grants_for_session(session_id)
|
|
1131
|
+
activated = sum(1 for r in results if r.success)
|
|
1132
|
+
logger.info(
|
|
1133
|
+
"AskUserQuestion session-wide activation: %d/%d pending grants for session %s",
|
|
1134
|
+
activated, len(results), session_id,
|
|
1135
|
+
)
|
|
1136
|
+
# Use the pending list for batch grant creation
|
|
1137
|
+
if is_batch:
|
|
1138
|
+
activated_pending_data = pending # List for batch iteration
|
|
1139
|
+
|
|
1140
|
+
# Batch approval: create a verb-family grant for each activated
|
|
1141
|
+
# pending's base_cmd + verb, so future commands with different
|
|
1142
|
+
# arguments are covered without per-command approval.
|
|
1143
|
+
if is_batch and activated_pending_data:
|
|
1144
|
+
from modules.security.approval_grants import DEFAULT_BATCH_TTL_MINUTES
|
|
1145
|
+
from modules.security.approval_scopes import ApprovalSignature
|
|
1146
|
+
|
|
1147
|
+
# Normalize to list: nonce-targeted gives a single dict,
|
|
1148
|
+
# session-wide gives a list
|
|
1149
|
+
pending_list = (
|
|
1150
|
+
activated_pending_data
|
|
1151
|
+
if isinstance(activated_pending_data, list)
|
|
1152
|
+
else [activated_pending_data]
|
|
1153
|
+
)
|
|
1154
|
+
|
|
1155
|
+
for pd in pending_list:
|
|
1156
|
+
sig_data = pd.get("scope_signature")
|
|
1157
|
+
if not sig_data:
|
|
1158
|
+
continue
|
|
1159
|
+
try:
|
|
1160
|
+
sig = ApprovalSignature.from_dict(sig_data)
|
|
1161
|
+
if sig.base_cmd and sig.verb:
|
|
1162
|
+
batch_path = create_verb_family_grant(
|
|
1163
|
+
session_id=session_id,
|
|
1164
|
+
base_cmd=sig.base_cmd,
|
|
1165
|
+
verb=sig.verb,
|
|
1166
|
+
danger_category=sig.danger_category,
|
|
1167
|
+
ttl_minutes=DEFAULT_BATCH_TTL_MINUTES,
|
|
1168
|
+
)
|
|
1169
|
+
if batch_path:
|
|
1170
|
+
logger.info(
|
|
1171
|
+
"Batch verb-family grant created: %s %s -> %s",
|
|
1172
|
+
sig.base_cmd, sig.verb, batch_path.name,
|
|
1173
|
+
)
|
|
1174
|
+
except Exception as e:
|
|
1175
|
+
logger.warning(
|
|
1176
|
+
"Failed to create batch grant from pending: %s", e,
|
|
1177
|
+
)
|
|
1178
|
+
|
|
1179
|
+
except Exception as e:
|
|
1180
|
+
logger.error("Error in _handle_ask_user_question_result: %s", e, exc_info=True)
|
|
1181
|
+
|
|
1182
|
+
# ------------------------------------------------------------------ #
|
|
1183
|
+
# adapt_subagent_stop: full subagent-stop lifecycle
|
|
1184
|
+
# ------------------------------------------------------------------ #
|
|
1185
|
+
|
|
1186
|
+
def adapt_subagent_stop(self, event: HookEvent) -> HookResponse:
|
|
1187
|
+
"""Run all subagent-stop business logic and return a formatted response.
|
|
1188
|
+
|
|
1189
|
+
Orchestrates: contract parsing/validation, approval cleanup,
|
|
1190
|
+
context updates, workflow recording, response contract validation,
|
|
1191
|
+
anomaly detection, episodic memory, and result assembly.
|
|
1192
|
+
"""
|
|
1193
|
+
from modules.agents.contract_validator import (
|
|
1194
|
+
extract_commands_from_evidence,
|
|
1195
|
+
parse_contract,
|
|
1196
|
+
requires_consolidation_report,
|
|
1197
|
+
validate as validate_contract,
|
|
1198
|
+
validate_approval_request,
|
|
1199
|
+
validate_verbatim_outputs_consistency,
|
|
1200
|
+
)
|
|
1201
|
+
from modules.agents.response_contract import (
|
|
1202
|
+
save_validation_result,
|
|
1203
|
+
validate_response_contract,
|
|
1204
|
+
resolve_agent_id,
|
|
1205
|
+
)
|
|
1206
|
+
from modules.agents.task_info_builder import build_task_info_from_hook_data
|
|
1207
|
+
from modules.agents.transcript_reader import read_transcript
|
|
1208
|
+
from modules.audit.workflow_auditor import audit as audit_workflow, signal_gaia_analysis
|
|
1209
|
+
from modules.audit.workflow_recorder import record as record_workflow
|
|
1210
|
+
from modules.context.context_writer import process_context_updates
|
|
1211
|
+
from modules.memory.episode_writer import write as write_episode
|
|
1212
|
+
from modules.security.approval_cleanup import cleanup as cleanup_approval
|
|
1213
|
+
from modules.session.session_manager import get_or_create_session_id
|
|
1214
|
+
|
|
1215
|
+
hook_data = event.payload
|
|
1216
|
+
logger.info(
|
|
1217
|
+
"Hook event: %s, agent: %s",
|
|
1218
|
+
hook_data.get("hook_event_name"),
|
|
1219
|
+
hook_data.get("agent_type", "unknown"),
|
|
1220
|
+
)
|
|
1221
|
+
|
|
1222
|
+
# Parse agent completion data
|
|
1223
|
+
completion = self.parse_agent_completion(hook_data)
|
|
1224
|
+
|
|
1225
|
+
# ----------------------------------------------------------
|
|
1226
|
+
# Transcript analysis (T011)
|
|
1227
|
+
# ----------------------------------------------------------
|
|
1228
|
+
transcript_analysis = None
|
|
1229
|
+
try:
|
|
1230
|
+
from modules.agents.transcript_analyzer import analyze as analyze_transcript
|
|
1231
|
+
if completion.transcript_path:
|
|
1232
|
+
transcript_analysis = analyze_transcript(completion.transcript_path)
|
|
1233
|
+
logger.info(
|
|
1234
|
+
"Transcript analysis: %d tool calls, %d API calls, model=%s",
|
|
1235
|
+
transcript_analysis.tool_call_count,
|
|
1236
|
+
transcript_analysis.api_call_count,
|
|
1237
|
+
transcript_analysis.model,
|
|
1238
|
+
)
|
|
1239
|
+
except Exception as exc:
|
|
1240
|
+
logger.debug("Transcript analysis failed (non-fatal): %s", exc)
|
|
1241
|
+
|
|
1242
|
+
# Resolve agent output: prefer last_assistant_message, fall back to transcript
|
|
1243
|
+
agent_output = completion.last_message
|
|
1244
|
+
if not agent_output:
|
|
1245
|
+
transcript_path = completion.transcript_path
|
|
1246
|
+
agent_output = read_transcript(transcript_path) if transcript_path else ""
|
|
1247
|
+
logger.info("Agent output: %d chars from transcript (fallback)", len(agent_output))
|
|
1248
|
+
else:
|
|
1249
|
+
logger.info("Agent output: %d chars from last_assistant_message", len(agent_output))
|
|
1250
|
+
|
|
1251
|
+
task_info = build_task_info_from_hook_data(hook_data, agent_output)
|
|
1252
|
+
|
|
1253
|
+
# ----------------------------------------------------------
|
|
1254
|
+
# Native agent bypass: agents not defined in agents/ dir
|
|
1255
|
+
# (e.g. claude-code-guide, Explore, Plan) do not emit
|
|
1256
|
+
# json:contract. Skip contract validation to avoid an
|
|
1257
|
+
# infinite retry loop (exit_code=2 -> retry -> no contract).
|
|
1258
|
+
# ----------------------------------------------------------
|
|
1259
|
+
_native_agent_type = task_info.get("agent", "unknown")
|
|
1260
|
+
_gaia_agents = self._get_gaia_agent_names()
|
|
1261
|
+
if _native_agent_type not in _gaia_agents:
|
|
1262
|
+
logger.info(
|
|
1263
|
+
"Native agent '%s' — skipping contract validation (gaia agents: %s)",
|
|
1264
|
+
_native_agent_type, _gaia_agents,
|
|
1265
|
+
)
|
|
1266
|
+
return HookResponse(
|
|
1267
|
+
output={"success": True, "native_agent": True, "agent": _native_agent_type},
|
|
1268
|
+
exit_code=0,
|
|
1269
|
+
)
|
|
1270
|
+
|
|
1271
|
+
# Run the main processing chain
|
|
1272
|
+
try:
|
|
1273
|
+
from datetime import datetime as _dt
|
|
1274
|
+
session_id = get_or_create_session_id()
|
|
1275
|
+
agent_type = task_info.get("agent", "unknown")
|
|
1276
|
+
|
|
1277
|
+
parsed_contract = parse_contract(agent_output)
|
|
1278
|
+
|
|
1279
|
+
contract_result = validate_contract(agent_output, task_info)
|
|
1280
|
+
if not contract_result.is_valid:
|
|
1281
|
+
logger.warning(
|
|
1282
|
+
"Contract validation failed for %s: %s",
|
|
1283
|
+
agent_type, contract_result.error_message,
|
|
1284
|
+
)
|
|
1285
|
+
|
|
1286
|
+
cleanup_approval(agent_type)
|
|
1287
|
+
|
|
1288
|
+
# Consume all confirmed grants for this session -- the subagent
|
|
1289
|
+
# is done, so grants should not survive past its lifetime.
|
|
1290
|
+
try:
|
|
1291
|
+
from modules.security.approval_grants import consume_session_grants
|
|
1292
|
+
consumed = consume_session_grants(session_id)
|
|
1293
|
+
if consumed:
|
|
1294
|
+
logger.info(
|
|
1295
|
+
"SubagentStop consumed %d grant(s) for session %s",
|
|
1296
|
+
consumed, session_id[:12],
|
|
1297
|
+
)
|
|
1298
|
+
except Exception as exc:
|
|
1299
|
+
logger.debug("Grant consumption at SubagentStop failed (non-fatal): %s", exc)
|
|
1300
|
+
|
|
1301
|
+
commands_executed = extract_commands_from_evidence(agent_output)
|
|
1302
|
+
context_update_result = process_context_updates(agent_output, task_info)
|
|
1303
|
+
|
|
1304
|
+
# Compute context anchor hit tracking
|
|
1305
|
+
anchor_hits = None
|
|
1306
|
+
try:
|
|
1307
|
+
from modules.context.anchor_tracker import (
|
|
1308
|
+
cleanup_anchors,
|
|
1309
|
+
compute_anchor_hits,
|
|
1310
|
+
extract_tool_calls_from_transcript,
|
|
1311
|
+
load_anchors,
|
|
1312
|
+
)
|
|
1313
|
+
transcript_path = task_info.get("agent_transcript_path", "")
|
|
1314
|
+
anchors = load_anchors(session_id, agent_type)
|
|
1315
|
+
if anchors and transcript_path:
|
|
1316
|
+
tool_calls = extract_tool_calls_from_transcript(transcript_path)
|
|
1317
|
+
anchor_hits = compute_anchor_hits(tool_calls, anchors)
|
|
1318
|
+
logger.info(
|
|
1319
|
+
"Anchor hits for %s: %d/%d (%.0f%%)",
|
|
1320
|
+
agent_type,
|
|
1321
|
+
anchor_hits.get("hits", 0),
|
|
1322
|
+
anchor_hits.get("total_checked", 0),
|
|
1323
|
+
anchor_hits.get("hit_rate", 0) * 100,
|
|
1324
|
+
)
|
|
1325
|
+
cleanup_anchors(session_id, agent_type)
|
|
1326
|
+
except Exception as exc:
|
|
1327
|
+
logger.debug("Anchor hit tracking failed (non-fatal): %s", exc)
|
|
1328
|
+
|
|
1329
|
+
session_context = {
|
|
1330
|
+
"timestamp": _dt.now().isoformat(),
|
|
1331
|
+
"session_id": session_id,
|
|
1332
|
+
"task_id": task_info.get("task_id", "unknown"),
|
|
1333
|
+
"agent_id": task_info.get("agent_id", "unknown"),
|
|
1334
|
+
"agent": agent_type,
|
|
1335
|
+
}
|
|
1336
|
+
workflow_metrics = record_workflow(
|
|
1337
|
+
task_info,
|
|
1338
|
+
agent_output,
|
|
1339
|
+
session_context,
|
|
1340
|
+
commands_executed=commands_executed,
|
|
1341
|
+
context_update_result=context_update_result,
|
|
1342
|
+
anchor_hits=anchor_hits,
|
|
1343
|
+
transcript_analysis=transcript_analysis,
|
|
1344
|
+
)
|
|
1345
|
+
|
|
1346
|
+
response_contract = validate_response_contract(
|
|
1347
|
+
agent_output,
|
|
1348
|
+
task_agent_id=resolve_agent_id(task_info),
|
|
1349
|
+
consolidation_required=requires_consolidation_report(task_info),
|
|
1350
|
+
parsed_contract=parsed_contract,
|
|
1351
|
+
)
|
|
1352
|
+
save_validation_result(task_info, response_contract)
|
|
1353
|
+
|
|
1354
|
+
anomalies = audit_workflow(
|
|
1355
|
+
workflow_metrics,
|
|
1356
|
+
agent_output,
|
|
1357
|
+
task_info,
|
|
1358
|
+
rejected_sections=(context_update_result or {}).get("rejected", []),
|
|
1359
|
+
transcript_analysis=transcript_analysis,
|
|
1360
|
+
)
|
|
1361
|
+
if not response_contract.valid:
|
|
1362
|
+
missing = ", ".join(response_contract.missing) or "none"
|
|
1363
|
+
invalid = ", ".join(response_contract.invalid) or "none"
|
|
1364
|
+
anomalies.append({
|
|
1365
|
+
"type": "response_contract_violation",
|
|
1366
|
+
"severity": "critical",
|
|
1367
|
+
"message": (
|
|
1368
|
+
f"Agent response contract invalid for {task_info.get('agent', 'unknown')}: "
|
|
1369
|
+
f"missing=[{missing}] invalid=[{invalid}]"
|
|
1370
|
+
),
|
|
1371
|
+
})
|
|
1372
|
+
|
|
1373
|
+
# ----------------------------------------------------------
|
|
1374
|
+
# Compliance score (T011)
|
|
1375
|
+
# Computed after audit so anomalies are available for
|
|
1376
|
+
# has_scope_escalation detection.
|
|
1377
|
+
# ----------------------------------------------------------
|
|
1378
|
+
compliance_result = None
|
|
1379
|
+
try:
|
|
1380
|
+
from modules.agents.transcript_analyzer import compute_compliance_score
|
|
1381
|
+
if transcript_analysis is not None:
|
|
1382
|
+
_contract_valid = contract_result.is_valid
|
|
1383
|
+
_has_scope_escalation = any(
|
|
1384
|
+
a.get("type") == "scope_escalation"
|
|
1385
|
+
for a in anomalies
|
|
1386
|
+
) if anomalies else False
|
|
1387
|
+
_anchor_hit_rate = (
|
|
1388
|
+
anchor_hits.get("hit_rate", 0.0)
|
|
1389
|
+
if anchor_hits else 0.0
|
|
1390
|
+
)
|
|
1391
|
+
compliance_result = compute_compliance_score(
|
|
1392
|
+
transcript_analysis,
|
|
1393
|
+
contract_valid=_contract_valid,
|
|
1394
|
+
has_scope_escalation=_has_scope_escalation,
|
|
1395
|
+
anchor_hit_rate=_anchor_hit_rate,
|
|
1396
|
+
)
|
|
1397
|
+
logger.info(
|
|
1398
|
+
"Compliance score for %s: %d (%s)",
|
|
1399
|
+
agent_type, compliance_result.total, compliance_result.grade,
|
|
1400
|
+
)
|
|
1401
|
+
workflow_metrics["compliance_score"] = {
|
|
1402
|
+
"total": compliance_result.total,
|
|
1403
|
+
"grade": compliance_result.grade,
|
|
1404
|
+
"factors": compliance_result.factors,
|
|
1405
|
+
"deductions": compliance_result.deductions,
|
|
1406
|
+
}
|
|
1407
|
+
except Exception as exc:
|
|
1408
|
+
logger.debug("Compliance score computation failed (non-fatal): %s", exc)
|
|
1409
|
+
|
|
1410
|
+
if anomalies:
|
|
1411
|
+
logger.warning("%d anomalies detected in workflow", len(anomalies))
|
|
1412
|
+
signal_gaia_analysis(anomalies, workflow_metrics)
|
|
1413
|
+
|
|
1414
|
+
workflow_metrics["anomalies_detected"] = len(anomalies)
|
|
1415
|
+
workflow_metrics["anomaly_types"] = [a.get("type", "") for a in anomalies]
|
|
1416
|
+
|
|
1417
|
+
episode_id = write_episode(
|
|
1418
|
+
workflow_metrics,
|
|
1419
|
+
anomalies=anomalies if anomalies else None,
|
|
1420
|
+
commands_executed=commands_executed,
|
|
1421
|
+
)
|
|
1422
|
+
|
|
1423
|
+
# Write AGENT_COMPLETE event (non-blocking)
|
|
1424
|
+
try:
|
|
1425
|
+
from modules.events.event_writer import EventWriter, AGENT_COMPLETE
|
|
1426
|
+
_plan = ""
|
|
1427
|
+
if parsed_contract and isinstance(parsed_contract.get("agent_status"), dict):
|
|
1428
|
+
_plan = str(parsed_contract["agent_status"].get("plan_status", ""))
|
|
1429
|
+
_key_outputs = []
|
|
1430
|
+
if parsed_contract and isinstance(parsed_contract.get("evidence_report"), dict):
|
|
1431
|
+
_key_outputs = parsed_contract["evidence_report"].get("key_outputs", [])
|
|
1432
|
+
_summary = "; ".join(str(o) for o in _key_outputs[:2]) if _key_outputs else ""
|
|
1433
|
+
EventWriter().write_event(
|
|
1434
|
+
AGENT_COMPLETE, "hook", agent_type,
|
|
1435
|
+
_plan or "completed",
|
|
1436
|
+
meta={"episode_id": episode_id, "summary": _summary[:200]},
|
|
1437
|
+
)
|
|
1438
|
+
except Exception:
|
|
1439
|
+
pass # Events are non-critical
|
|
1440
|
+
|
|
1441
|
+
contract_attempts = 0
|
|
1442
|
+
if not response_contract.valid:
|
|
1443
|
+
try:
|
|
1444
|
+
repair_data = response_contract.to_dict()
|
|
1445
|
+
contract_attempts = int(repair_data.get("repair_attempts", 0))
|
|
1446
|
+
except Exception:
|
|
1447
|
+
contract_attempts = 0
|
|
1448
|
+
|
|
1449
|
+
# ----------------------------------------------------------
|
|
1450
|
+
# Option D: Cross-field validation for verbatim_outputs
|
|
1451
|
+
# Advisory only -- adds to anomalies but never blocks.
|
|
1452
|
+
# ----------------------------------------------------------
|
|
1453
|
+
verbatim_check = validate_verbatim_outputs_consistency(parsed_contract)
|
|
1454
|
+
if verbatim_check:
|
|
1455
|
+
anomalies.append(verbatim_check)
|
|
1456
|
+
logger.info(
|
|
1457
|
+
"Verbatim outputs consistency warning for %s: %s",
|
|
1458
|
+
agent_type, verbatim_check.get("message", ""),
|
|
1459
|
+
)
|
|
1460
|
+
|
|
1461
|
+
# ----------------------------------------------------------
|
|
1462
|
+
# Extract plan_status for downstream checks
|
|
1463
|
+
# ----------------------------------------------------------
|
|
1464
|
+
_plan_status = ""
|
|
1465
|
+
if parsed_contract and isinstance(parsed_contract.get("agent_status"), dict):
|
|
1466
|
+
_plan_status = str(parsed_contract["agent_status"].get("plan_status", ""))
|
|
1467
|
+
|
|
1468
|
+
# ----------------------------------------------------------
|
|
1469
|
+
# State transition tracking
|
|
1470
|
+
# Validates that agent state transitions follow the state
|
|
1471
|
+
# machine (e.g., no IN_PROGRESS -> COMPLETE without REVIEW
|
|
1472
|
+
# when T3 is involved). Advisory warnings, hard reject only
|
|
1473
|
+
# for illegal transitions.
|
|
1474
|
+
# ----------------------------------------------------------
|
|
1475
|
+
try:
|
|
1476
|
+
from modules.agents.state_tracker import track_transition
|
|
1477
|
+
_agent_id = resolve_agent_id(task_info)
|
|
1478
|
+
if _plan_status and _agent_id:
|
|
1479
|
+
transition_result = track_transition(
|
|
1480
|
+
_agent_id,
|
|
1481
|
+
_plan_status,
|
|
1482
|
+
has_review_phase=False, # Conservative: no T3 detection yet
|
|
1483
|
+
)
|
|
1484
|
+
if not transition_result.valid:
|
|
1485
|
+
anomalies.append({
|
|
1486
|
+
"type": "illegal_state_transition",
|
|
1487
|
+
"severity": "warning",
|
|
1488
|
+
"message": transition_result.error,
|
|
1489
|
+
})
|
|
1490
|
+
logger.warning(
|
|
1491
|
+
"State transition rejected for %s: %s",
|
|
1492
|
+
agent_type, transition_result.error,
|
|
1493
|
+
)
|
|
1494
|
+
elif transition_result.warning:
|
|
1495
|
+
anomalies.append({
|
|
1496
|
+
"type": "state_transition_warning",
|
|
1497
|
+
"severity": "info",
|
|
1498
|
+
"message": transition_result.warning,
|
|
1499
|
+
})
|
|
1500
|
+
logger.info(
|
|
1501
|
+
"State transition warning for %s: %s",
|
|
1502
|
+
agent_type, transition_result.warning,
|
|
1503
|
+
)
|
|
1504
|
+
except Exception as exc:
|
|
1505
|
+
logger.debug("State transition tracking failed (non-fatal): %s", exc)
|
|
1506
|
+
|
|
1507
|
+
# ----------------------------------------------------------
|
|
1508
|
+
# Approval request validation
|
|
1509
|
+
# Advisory only -- adds to anomalies but never blocks.
|
|
1510
|
+
# ----------------------------------------------------------
|
|
1511
|
+
if parsed_contract is not None:
|
|
1512
|
+
approval_check = validate_approval_request(parsed_contract, _plan_status)
|
|
1513
|
+
if approval_check:
|
|
1514
|
+
anomalies.append(approval_check)
|
|
1515
|
+
logger.info(
|
|
1516
|
+
"Approval request validation for %s: %s",
|
|
1517
|
+
agent_type, approval_check.get("detail", ""),
|
|
1518
|
+
)
|
|
1519
|
+
|
|
1520
|
+
# ----------------------------------------------------------
|
|
1521
|
+
# Skill injection verification
|
|
1522
|
+
# Advisory only -- adds to anomalies but never blocks.
|
|
1523
|
+
# ----------------------------------------------------------
|
|
1524
|
+
try:
|
|
1525
|
+
from modules.agents.skill_injection_verifier import verify_skill_injection
|
|
1526
|
+
from modules.audit.workflow_recorder import load_agent_runtime_profile
|
|
1527
|
+
agent_profile = load_agent_runtime_profile(agent_type)
|
|
1528
|
+
declared_skills = agent_profile.get("skills", [])
|
|
1529
|
+
if declared_skills and agent_output:
|
|
1530
|
+
skill_check = verify_skill_injection(
|
|
1531
|
+
agent_type, agent_output, declared_skills,
|
|
1532
|
+
)
|
|
1533
|
+
if skill_check:
|
|
1534
|
+
anomalies.append(skill_check)
|
|
1535
|
+
logger.info(
|
|
1536
|
+
"Skill injection gap for %s: %s",
|
|
1537
|
+
agent_type, skill_check.get("message", ""),
|
|
1538
|
+
)
|
|
1539
|
+
except Exception as exc:
|
|
1540
|
+
logger.debug("Skill injection verification failed (non-fatal): %s", exc)
|
|
1541
|
+
|
|
1542
|
+
# ----------------------------------------------------------
|
|
1543
|
+
# Option B: Selective enforcement for critical structural failures.
|
|
1544
|
+
# Only 3 cases set contract_rejected=True:
|
|
1545
|
+
# 1. json:contract block completely missing
|
|
1546
|
+
# 2. plan_status missing or not one of the 8 valid statuses
|
|
1547
|
+
# 3. agent_status block missing entirely
|
|
1548
|
+
# ----------------------------------------------------------
|
|
1549
|
+
contract_rejected = False
|
|
1550
|
+
contract_rejection_reason = ""
|
|
1551
|
+
|
|
1552
|
+
if parsed_contract is None:
|
|
1553
|
+
contract_rejected = True
|
|
1554
|
+
contract_rejection_reason = (
|
|
1555
|
+
"[CONTRACT REJECTED] No json:contract block found in agent response.\n"
|
|
1556
|
+
"The agent must end its response with a ```json:contract``` fenced block.\n"
|
|
1557
|
+
"Reissue the response with a complete json:contract block."
|
|
1558
|
+
)
|
|
1559
|
+
elif not parsed_contract.get("agent_status") or not isinstance(
|
|
1560
|
+
parsed_contract.get("agent_status"), dict
|
|
1561
|
+
):
|
|
1562
|
+
contract_rejected = True
|
|
1563
|
+
contract_rejection_reason = (
|
|
1564
|
+
"[CONTRACT REJECTED] agent_status block missing from json:contract.\n"
|
|
1565
|
+
"The json:contract block must include an agent_status object with "
|
|
1566
|
+
"plan_status, agent_id, pending_steps, and next_action."
|
|
1567
|
+
)
|
|
1568
|
+
else:
|
|
1569
|
+
from modules.agents.response_contract import VALID_PLAN_STATUSES
|
|
1570
|
+
raw_plan_status = parsed_contract["agent_status"].get("plan_status", "")
|
|
1571
|
+
normalized = str(raw_plan_status).upper().rstrip(".,;") if raw_plan_status else ""
|
|
1572
|
+
if not normalized or normalized not in VALID_PLAN_STATUSES:
|
|
1573
|
+
contract_rejected = True
|
|
1574
|
+
valid_list = ", ".join(sorted(VALID_PLAN_STATUSES))
|
|
1575
|
+
contract_rejection_reason = (
|
|
1576
|
+
f"[CONTRACT REJECTED] plan_status is missing or invalid: "
|
|
1577
|
+
f"'{raw_plan_status}'.\n"
|
|
1578
|
+
f"Valid statuses: {valid_list}.\n"
|
|
1579
|
+
f"Set plan_status to one of these values in agent_status."
|
|
1580
|
+
)
|
|
1581
|
+
|
|
1582
|
+
result = {
|
|
1583
|
+
"success": True,
|
|
1584
|
+
"session_id": session_id,
|
|
1585
|
+
"status": "metrics_captured",
|
|
1586
|
+
"metrics_captured": True,
|
|
1587
|
+
"anomalies_detected": len(anomalies) if anomalies else 0,
|
|
1588
|
+
"episode_id": episode_id,
|
|
1589
|
+
"context_updated": context_update_result.get("updated", False) if context_update_result else False,
|
|
1590
|
+
"response_contract": response_contract.to_dict(),
|
|
1591
|
+
"contract_validated": contract_result.is_valid,
|
|
1592
|
+
"contract_attempts": contract_attempts,
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
if contract_rejected:
|
|
1596
|
+
result["contract_rejected"] = True
|
|
1597
|
+
result["contract_rejection_reason"] = contract_rejection_reason
|
|
1598
|
+
logger.warning(
|
|
1599
|
+
"Contract rejected for %s: %s",
|
|
1600
|
+
agent_type, contract_rejection_reason.split("\n")[0],
|
|
1601
|
+
)
|
|
1602
|
+
|
|
1603
|
+
except Exception as e:
|
|
1604
|
+
logger.error("Error in adapt_subagent_stop: %s", e, exc_info=True)
|
|
1605
|
+
result = {
|
|
1606
|
+
"success": False,
|
|
1607
|
+
"error": str(e),
|
|
1608
|
+
"status": "partial_update",
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
if result.get("contract_rejected"):
|
|
1612
|
+
logger.warning("Returning exit_code=2 due to contract rejection")
|
|
1613
|
+
return HookResponse(output=result, exit_code=2)
|
|
1614
|
+
|
|
1615
|
+
return HookResponse(output=result, exit_code=0)
|
|
1616
|
+
|
|
1617
|
+
# ------------------------------------------------------------------ #
|
|
1618
|
+
# P2: adapt_stop
|
|
1619
|
+
# ------------------------------------------------------------------ #
|
|
1620
|
+
|
|
1621
|
+
def adapt_stop(self, raw: dict) -> QualityResult:
|
|
1622
|
+
"""Parse Stop event and assess response quality.
|
|
1623
|
+
|
|
1624
|
+
Extracts the response content from the Stop payload and evaluates
|
|
1625
|
+
whether the output meets evidence quality thresholds.
|
|
1626
|
+
|
|
1627
|
+
Returns:
|
|
1628
|
+
QualityResult with quality assessment.
|
|
1629
|
+
Default: quality_sufficient=True (passthrough until business logic wired).
|
|
1630
|
+
"""
|
|
1631
|
+
# Write SESSION_END event (non-blocking)
|
|
1632
|
+
try:
|
|
1633
|
+
from modules.events.event_writer import EventWriter, SESSION_END
|
|
1634
|
+
stop_reason = raw.get("stop_reason", "unknown")
|
|
1635
|
+
EventWriter().write_event(
|
|
1636
|
+
SESSION_END, "hook", "",
|
|
1637
|
+
f"session ended: {stop_reason}",
|
|
1638
|
+
)
|
|
1639
|
+
except Exception:
|
|
1640
|
+
pass # Events are non-critical
|
|
1641
|
+
|
|
1642
|
+
return QualityResult(
|
|
1643
|
+
quality_sufficient=True,
|
|
1644
|
+
score=1.0,
|
|
1645
|
+
missing_elements=[],
|
|
1646
|
+
recommendation="continue",
|
|
1647
|
+
)
|
|
1648
|
+
|
|
1649
|
+
# ------------------------------------------------------------------ #
|
|
1650
|
+
# P2: adapt_task_completed
|
|
1651
|
+
# ------------------------------------------------------------------ #
|
|
1652
|
+
|
|
1653
|
+
def adapt_task_completed(self, raw: dict) -> VerificationResult:
|
|
1654
|
+
"""Parse TaskCompleted event and verify completion criteria.
|
|
1655
|
+
|
|
1656
|
+
Extracts task output and metadata from the TaskCompleted payload.
|
|
1657
|
+
Checks if the task's acceptance criteria are met.
|
|
1658
|
+
|
|
1659
|
+
Returns:
|
|
1660
|
+
VerificationResult with criteria assessment.
|
|
1661
|
+
Default: criteria_met=True (passthrough until business logic wired).
|
|
1662
|
+
"""
|
|
1663
|
+
return VerificationResult(
|
|
1664
|
+
criteria_met=True,
|
|
1665
|
+
verified_items=[],
|
|
1666
|
+
failed_items=[],
|
|
1667
|
+
block_completion=False,
|
|
1668
|
+
)
|
|
1669
|
+
|
|
1670
|
+
# ------------------------------------------------------------------ #
|
|
1671
|
+
# Context cache: PreToolUse -> SubagentStart bridge
|
|
1672
|
+
# ------------------------------------------------------------------ #
|
|
1673
|
+
|
|
1674
|
+
CONTEXT_CACHE_DIR = Path("/tmp/gaia-context-cache")
|
|
1675
|
+
CONTEXT_CACHE_TTL_SECONDS = 60 # Cache entries older than this are stale
|
|
1676
|
+
|
|
1677
|
+
def _cache_context_for_subagent(
|
|
1678
|
+
self, session_id: str, agent_type: str, context: str,
|
|
1679
|
+
) -> Path:
|
|
1680
|
+
"""Write built context to a cache file for SubagentStart consumption.
|
|
1681
|
+
|
|
1682
|
+
Returns the path to the cache file.
|
|
1683
|
+
"""
|
|
1684
|
+
self.CONTEXT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
1685
|
+
timestamp = int(time.time() * 1000)
|
|
1686
|
+
cache_file = self.CONTEXT_CACHE_DIR / f"{session_id}-{timestamp}.json"
|
|
1687
|
+
payload = {
|
|
1688
|
+
"context": context,
|
|
1689
|
+
"agent_type": agent_type,
|
|
1690
|
+
"session_id": session_id,
|
|
1691
|
+
"created_at": time.time(),
|
|
1692
|
+
}
|
|
1693
|
+
cache_file.write_text(json.dumps(payload))
|
|
1694
|
+
logger.debug("Context cache written: %s", cache_file)
|
|
1695
|
+
return cache_file
|
|
1696
|
+
|
|
1697
|
+
def _read_cached_context(self, session_id: str) -> Optional[Dict[str, Any]]:
|
|
1698
|
+
"""Read and consume the most recent cached context for a session.
|
|
1699
|
+
|
|
1700
|
+
Finds the newest cache file matching the session_id, reads it,
|
|
1701
|
+
deletes it (one-shot consumption), and cleans up stale entries.
|
|
1702
|
+
|
|
1703
|
+
Returns None if no cache is found.
|
|
1704
|
+
"""
|
|
1705
|
+
if not self.CONTEXT_CACHE_DIR.exists():
|
|
1706
|
+
return None
|
|
1707
|
+
|
|
1708
|
+
# Find all cache files for this session, sorted newest-first
|
|
1709
|
+
candidates: List[Path] = sorted(
|
|
1710
|
+
self.CONTEXT_CACHE_DIR.glob(f"{session_id}-*.json"),
|
|
1711
|
+
key=lambda p: p.stat().st_mtime,
|
|
1712
|
+
reverse=True,
|
|
1713
|
+
)
|
|
1714
|
+
|
|
1715
|
+
if not candidates:
|
|
1716
|
+
# Fallback: try to find the most recent cache file regardless of
|
|
1717
|
+
# session_id, since the orchestrator session_id and the subagent
|
|
1718
|
+
# session_id may differ.
|
|
1719
|
+
all_files = sorted(
|
|
1720
|
+
self.CONTEXT_CACHE_DIR.glob("*.json"),
|
|
1721
|
+
key=lambda p: p.stat().st_mtime,
|
|
1722
|
+
reverse=True,
|
|
1723
|
+
)
|
|
1724
|
+
candidates = all_files
|
|
1725
|
+
|
|
1726
|
+
now = time.time()
|
|
1727
|
+
result = None
|
|
1728
|
+
|
|
1729
|
+
for cache_file in candidates:
|
|
1730
|
+
try:
|
|
1731
|
+
data = json.loads(cache_file.read_text())
|
|
1732
|
+
age = now - data.get("created_at", 0)
|
|
1733
|
+
|
|
1734
|
+
if age > self.CONTEXT_CACHE_TTL_SECONDS:
|
|
1735
|
+
# Stale entry -- clean up
|
|
1736
|
+
cache_file.unlink(missing_ok=True)
|
|
1737
|
+
logger.debug("Cleaned stale context cache: %s (age=%.1fs)", cache_file.name, age)
|
|
1738
|
+
continue
|
|
1739
|
+
|
|
1740
|
+
# Found a valid entry -- consume it
|
|
1741
|
+
result = data
|
|
1742
|
+
cache_file.unlink(missing_ok=True)
|
|
1743
|
+
logger.debug("Consumed context cache: %s (age=%.1fs)", cache_file.name, age)
|
|
1744
|
+
break
|
|
1745
|
+
|
|
1746
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
1747
|
+
logger.warning("Failed to read context cache %s: %s", cache_file, exc)
|
|
1748
|
+
cache_file.unlink(missing_ok=True)
|
|
1749
|
+
continue
|
|
1750
|
+
|
|
1751
|
+
# Clean up any remaining stale files (background hygiene)
|
|
1752
|
+
self._cleanup_stale_cache(now)
|
|
1753
|
+
|
|
1754
|
+
return result
|
|
1755
|
+
|
|
1756
|
+
def _cleanup_stale_cache(self, now: float) -> None:
|
|
1757
|
+
"""Remove cache files older than TTL."""
|
|
1758
|
+
if not self.CONTEXT_CACHE_DIR.exists():
|
|
1759
|
+
return
|
|
1760
|
+
for f in self.CONTEXT_CACHE_DIR.glob("*.json"):
|
|
1761
|
+
try:
|
|
1762
|
+
data = json.loads(f.read_text())
|
|
1763
|
+
if now - data.get("created_at", 0) > self.CONTEXT_CACHE_TTL_SECONDS:
|
|
1764
|
+
f.unlink(missing_ok=True)
|
|
1765
|
+
except (json.JSONDecodeError, OSError):
|
|
1766
|
+
f.unlink(missing_ok=True)
|
|
1767
|
+
|
|
1768
|
+
# ------------------------------------------------------------------ #
|
|
1769
|
+
# P2: adapt_subagent_start
|
|
1770
|
+
# ------------------------------------------------------------------ #
|
|
1771
|
+
|
|
1772
|
+
def adapt_subagent_start(self, raw: dict) -> ContextResult:
|
|
1773
|
+
"""Parse SubagentStart event and forward cached context to the subagent.
|
|
1774
|
+
|
|
1775
|
+
Two paths:
|
|
1776
|
+
1. Cache hit (normal start via Task/Agent tool): PreToolUse:Agent
|
|
1777
|
+
caches context, this method reads and forwards it.
|
|
1778
|
+
2. Cache miss (resume via SendMessage): No PreToolUse:Agent fires,
|
|
1779
|
+
so no cache exists. If agent_type is present in the payload and
|
|
1780
|
+
is a known project agent, rebuild context on-demand.
|
|
1781
|
+
"""
|
|
1782
|
+
session_id = raw.get("session_id", "")
|
|
1783
|
+
|
|
1784
|
+
cached = self._read_cached_context(session_id)
|
|
1785
|
+
if cached:
|
|
1786
|
+
logger.info(
|
|
1787
|
+
"SubagentStart: forwarding cached context for agent=%s (session=%s)",
|
|
1788
|
+
cached.get("agent_type", "unknown"),
|
|
1789
|
+
session_id,
|
|
1790
|
+
)
|
|
1791
|
+
return ContextResult(
|
|
1792
|
+
context_injected=True,
|
|
1793
|
+
additional_context=cached["context"],
|
|
1794
|
+
sections_provided=[],
|
|
1795
|
+
)
|
|
1796
|
+
|
|
1797
|
+
# Resume path: SendMessage skips PreToolUse:Agent so no cache is
|
|
1798
|
+
# written. If agent_type is present in the payload, rebuild context
|
|
1799
|
+
# on-demand so the resumed agent has its project context and tools.
|
|
1800
|
+
agent_type = raw.get("agent_type", "")
|
|
1801
|
+
if agent_type:
|
|
1802
|
+
try:
|
|
1803
|
+
from modules.context.context_injector import build_project_context
|
|
1804
|
+
from modules.session.session_event_injector import build_session_events
|
|
1805
|
+
from modules.tools.task_validator import AVAILABLE_AGENTS, META_AGENTS
|
|
1806
|
+
|
|
1807
|
+
project_agents = [a for a in AVAILABLE_AGENTS if a not in META_AGENTS]
|
|
1808
|
+
|
|
1809
|
+
if agent_type in project_agents:
|
|
1810
|
+
hooks_dir = Path(__file__).parent.parent
|
|
1811
|
+
task_description = raw.get("task_description", "")
|
|
1812
|
+
parameters = {
|
|
1813
|
+
"subagent_type": agent_type,
|
|
1814
|
+
"prompt": task_description or f"resume {agent_type}",
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
context_text, _telemetry = build_project_context(
|
|
1818
|
+
parameters, project_agents, hooks_dir,
|
|
1819
|
+
)
|
|
1820
|
+
events_text = build_session_events(parameters, project_agents)
|
|
1821
|
+
additional = "\n".join(filter(None, [context_text, events_text]))
|
|
1822
|
+
|
|
1823
|
+
if additional:
|
|
1824
|
+
logger.info(
|
|
1825
|
+
"SubagentStart: rebuilt context on resume for "
|
|
1826
|
+
"agent=%s (session=%s)",
|
|
1827
|
+
agent_type, session_id,
|
|
1828
|
+
)
|
|
1829
|
+
return ContextResult(
|
|
1830
|
+
context_injected=True,
|
|
1831
|
+
additional_context=additional,
|
|
1832
|
+
sections_provided=[],
|
|
1833
|
+
)
|
|
1834
|
+
except Exception as exc:
|
|
1835
|
+
logger.warning(
|
|
1836
|
+
"SubagentStart: resume context rebuild failed for "
|
|
1837
|
+
"agent=%s: %s", agent_type, exc,
|
|
1838
|
+
)
|
|
1839
|
+
|
|
1840
|
+
logger.info(
|
|
1841
|
+
"SubagentStart: no cached context found for session=%s "
|
|
1842
|
+
"agent=%s (passthrough)",
|
|
1843
|
+
session_id, agent_type or "unknown",
|
|
1844
|
+
)
|
|
1845
|
+
return ContextResult(
|
|
1846
|
+
context_injected=False,
|
|
1847
|
+
additional_context=None,
|
|
1848
|
+
sections_provided=[],
|
|
1849
|
+
)
|
|
1850
|
+
|
|
1851
|
+
# ------------------------------------------------------------------ #
|
|
1852
|
+
# P2: format_quality_response
|
|
1853
|
+
# ------------------------------------------------------------------ #
|
|
1854
|
+
|
|
1855
|
+
def format_quality_response(self, result: QualityResult) -> HookResponse:
|
|
1856
|
+
"""Format a QualityResult for CLI consumption.
|
|
1857
|
+
|
|
1858
|
+
Stop events are informational -- exit code is always 0.
|
|
1859
|
+
"""
|
|
1860
|
+
output: Dict[str, Any] = {
|
|
1861
|
+
"quality_sufficient": result.quality_sufficient,
|
|
1862
|
+
"score": result.score,
|
|
1863
|
+
"recommendation": result.recommendation,
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
if result.missing_elements:
|
|
1867
|
+
output["missing_elements"] = result.missing_elements
|
|
1868
|
+
|
|
1869
|
+
return HookResponse(output=output, exit_code=0)
|
|
1870
|
+
|
|
1871
|
+
# ------------------------------------------------------------------ #
|
|
1872
|
+
# P2: format_verification_response
|
|
1873
|
+
# ------------------------------------------------------------------ #
|
|
1874
|
+
|
|
1875
|
+
def format_verification_response(self, result: VerificationResult) -> HookResponse:
|
|
1876
|
+
"""Format a VerificationResult for CLI consumption.
|
|
1877
|
+
|
|
1878
|
+
TaskCompleted events are informational -- exit code is always 0.
|
|
1879
|
+
"""
|
|
1880
|
+
output: Dict[str, Any] = {
|
|
1881
|
+
"criteria_met": result.criteria_met,
|
|
1882
|
+
"block_completion": result.block_completion,
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
if result.verified_items:
|
|
1886
|
+
output["verified_items"] = result.verified_items
|
|
1887
|
+
if result.failed_items:
|
|
1888
|
+
output["failed_items"] = result.failed_items
|
|
1889
|
+
|
|
1890
|
+
return HookResponse(output=output, exit_code=0)
|