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