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