alysis-code 0.13.0__py3-none-any.whl
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.
- alysis_code/__init__.py +3 -0
- alysis_code/__main__.py +4 -0
- alysis_code/_build_info.py +14 -0
- alysis_code/account_login.py +468 -0
- alysis_code/agent/README.md +35 -0
- alysis_code/agent/__init__.py +11 -0
- alysis_code/agent/acceptance_contract.py +2217 -0
- alysis_code/agent/blast_radius.py +1403 -0
- alysis_code/agent/cache_keepalive.py +227 -0
- alysis_code/agent/completion_certificate.py +366 -0
- alysis_code/agent/completion_gate.py +306 -0
- alysis_code/agent/empty_response_stall.py +403 -0
- alysis_code/agent/errors.py +28 -0
- alysis_code/agent/llm_calls.py +475 -0
- alysis_code/agent/mutation_classification.py +227 -0
- alysis_code/agent/prompt_context.py +2508 -0
- alysis_code/agent/read_ledger.py +253 -0
- alysis_code/agent/regression_baseline.py +642 -0
- alysis_code/agent/reproduction_first.py +610 -0
- alysis_code/agent/sensitive_output.py +629 -0
- alysis_code/agent/session.py +3218 -0
- alysis_code/agent/steering.py +191 -0
- alysis_code/agent/subagent_execution.py +5177 -0
- alysis_code/agent/subagent_workspace.py +666 -0
- alysis_code/agent/tools_assembly.py +4728 -0
- alysis_code/agent/turn/__init__.py +101 -0
- alysis_code/agent/turn/core.py +8483 -0
- alysis_code/agent/turn/events.py +113 -0
- alysis_code/agent/turn/exploration.py +590 -0
- alysis_code/agent/turn/interventions.py +65 -0
- alysis_code/agent/turn/read_cache.py +420 -0
- alysis_code/agent/turn/snapshot.py +179 -0
- alysis_code/agent/turn_contract.py +661 -0
- alysis_code/agent/turn_path.py +129 -0
- alysis_code/agent/verification.py +2885 -0
- alysis_code/agent/verification_commands.py +512 -0
- alysis_code/agent/verification_evidence.py +738 -0
- alysis_code/agent_loop.py +693 -0
- alysis_code/agent_runtimes/__init__.py +51 -0
- alysis_code/agent_runtimes/base.py +114 -0
- alysis_code/agent_runtimes/builtins.py +129 -0
- alysis_code/agent_runtimes/codex_cli.py +664 -0
- alysis_code/agent_runtimes/host.py +263 -0
- alysis_code/agent_runtimes/registry.py +64 -0
- alysis_code/agent_runtimes/service.py +150 -0
- alysis_code/agentbox_client.py +416 -0
- alysis_code/agentbox_integration.py +310 -0
- alysis_code/alysis_cloud.py +152 -0
- alysis_code/approval_scope.py +276 -0
- alysis_code/assets/README.md +33 -0
- alysis_code/assets/__init__.py +126 -0
- alysis_code/assets/asset_read_core.py +281 -0
- alysis_code/assets/budget_allocator.py +456 -0
- alysis_code/assets/comprehender.py +759 -0
- alysis_code/assets/index.py +654 -0
- alysis_code/assets/ingestion.py +275 -0
- alysis_code/assets/legacy_migration.py +413 -0
- alysis_code/assets/models.py +263 -0
- alysis_code/assets/ocr.py +239 -0
- alysis_code/assets/owl/ascii/f-000.txt +13 -0
- alysis_code/assets/owl/ascii/f-001.txt +13 -0
- alysis_code/assets/owl/ascii/f-002.txt +13 -0
- alysis_code/assets/owl/ascii/f-003.txt +13 -0
- alysis_code/assets/owl/ascii/f-004.txt +13 -0
- alysis_code/assets/owl/ascii/f-005.txt +13 -0
- alysis_code/assets/owl/ascii/f-006.txt +13 -0
- alysis_code/assets/owl/ascii/f-007.txt +13 -0
- alysis_code/assets/owl/ascii/f-008.txt +13 -0
- alysis_code/assets/owl/ascii/f-009.txt +13 -0
- alysis_code/assets/owl/ascii/f-010.txt +13 -0
- alysis_code/assets/owl/ascii/f-011.txt +13 -0
- alysis_code/assets/owl/ascii/f-012.txt +13 -0
- alysis_code/assets/owl/ascii/f-013.txt +13 -0
- alysis_code/assets/owl/ascii/f-014.txt +13 -0
- alysis_code/assets/owl/ascii/f-015.txt +13 -0
- alysis_code/assets/owl/ascii/f-016.txt +13 -0
- alysis_code/assets/owl/ascii/f-017.txt +13 -0
- alysis_code/assets/owl/ascii/f-018.txt +13 -0
- alysis_code/assets/owl/ascii/f-019.txt +13 -0
- alysis_code/assets/owl/ascii/f-020.txt +13 -0
- alysis_code/assets/owl/index.html +98 -0
- alysis_code/assets/owl/show-owl.sh +761 -0
- alysis_code/assets/paths.py +49 -0
- alysis_code/assets/plan_binding.py +326 -0
- alysis_code/assets/planner_context.py +466 -0
- alysis_code/assets/planner_tools.py +184 -0
- alysis_code/assets/prompts.py +101 -0
- alysis_code/assets/replanner_context.py +239 -0
- alysis_code/assets/surface.py +521 -0
- alysis_code/assets/untrusted_content.py +48 -0
- alysis_code/assets/usage_logger.py +94 -0
- alysis_code/assets/worker_mirror.py +428 -0
- alysis_code/assets/worker_section.py +303 -0
- alysis_code/assets/worker_tools.py +468 -0
- alysis_code/atomic_io.py +83 -0
- alysis_code/auth_diagnostics.py +272 -0
- alysis_code/background_runner.py +366 -0
- alysis_code/branding.py +270 -0
- alysis_code/budget_policy.py +390 -0
- alysis_code/build_identity.py +465 -0
- alysis_code/builtin_hooks/__init__.py +7 -0
- alysis_code/builtin_hooks/notify_done_windows.py +65 -0
- alysis_code/bwrap_etc.py +76 -0
- alysis_code/cancellation.py +41 -0
- alysis_code/capabilities.py +137 -0
- alysis_code/chatgpt_codex_static_provider.py +133 -0
- alysis_code/cli.py +51 -0
- alysis_code/cli_impl/__init__.py +1 -0
- alysis_code/cli_impl/assets_cli.py +537 -0
- alysis_code/cli_impl/assets_modal.py +412 -0
- alysis_code/cli_impl/chat/__init__.py +156 -0
- alysis_code/cli_impl/chat/commands.py +2616 -0
- alysis_code/cli_impl/chat/loop.py +4508 -0
- alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
- alysis_code/cli_impl/chat/rendering.py +444 -0
- alysis_code/cli_impl/chat/state.py +124 -0
- alysis_code/cli_impl/chat_resume.py +830 -0
- alysis_code/cli_impl/chat_slash_completer.py +258 -0
- alysis_code/cli_impl/commands/__init__.py +11 -0
- alysis_code/cli_impl/commands/_shared.py +89 -0
- alysis_code/cli_impl/commands/auth.py +623 -0
- alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
- alysis_code/cli_impl/commands/chat_state.py +158 -0
- alysis_code/cli_impl/commands/chat_status.py +1248 -0
- alysis_code/cli_impl/commands/chat_terminal.py +942 -0
- alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
- alysis_code/cli_impl/commands/cli_common.py +1223 -0
- alysis_code/cli_impl/commands/cli_surface.py +77 -0
- alysis_code/cli_impl/commands/config.py +131 -0
- alysis_code/cli_impl/commands/conventions.py +85 -0
- alysis_code/cli_impl/commands/execution_helpers.py +350 -0
- alysis_code/cli_impl/commands/extensions.py +401 -0
- alysis_code/cli_impl/commands/forge.py +1282 -0
- alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
- alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
- alysis_code/cli_impl/commands/hooks.py +737 -0
- alysis_code/cli_impl/commands/ide_bridge.py +31 -0
- alysis_code/cli_impl/commands/mcp.py +700 -0
- alysis_code/cli_impl/commands/profile.py +453 -0
- alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
- alysis_code/cli_impl/commands/report.py +88 -0
- alysis_code/cli_impl/commands/root.py +1118 -0
- alysis_code/cli_impl/commands/sandbox.py +184 -0
- alysis_code/cli_impl/commands/server.py +54 -0
- alysis_code/cli_impl/commands/sessions.py +252 -0
- alysis_code/cli_impl/commands/skills.py +404 -0
- alysis_code/cli_impl/commands/startup.py +946 -0
- alysis_code/cli_impl/commands/tools.py +335 -0
- alysis_code/cli_impl/commands/update.py +364 -0
- alysis_code/cli_impl/commands/welcome.py +972 -0
- alysis_code/cli_impl/config_menu.py +3882 -0
- alysis_code/cli_impl/forge.py +4509 -0
- alysis_code/cli_impl/forge_recovery.py +485 -0
- alysis_code/cli_impl/setup_wizard.py +2409 -0
- alysis_code/cli_impl/tui/__init__.py +58 -0
- alysis_code/cli_impl/tui/app.py +4551 -0
- alysis_code/cli_impl/tui/config.py +32 -0
- alysis_code/cli_impl/tui/config_flow.py +2754 -0
- alysis_code/cli_impl/tui/config_overlay.py +566 -0
- alysis_code/cli_impl/tui/content.py +78 -0
- alysis_code/cli_impl/tui/footer.py +218 -0
- alysis_code/cli_impl/tui/forge_status.py +136 -0
- alysis_code/cli_impl/tui/markdown.py +244 -0
- alysis_code/cli_impl/tui/owl.py +109 -0
- alysis_code/cli_impl/tui/plan_meta.py +477 -0
- alysis_code/cli_impl/tui/setup_app.py +519 -0
- alysis_code/cli_impl/tui/setup_flow.py +1622 -0
- alysis_code/cli_impl/tui/state.py +101 -0
- alysis_code/cli_impl/tui/subagent_identity.py +66 -0
- alysis_code/cli_impl/tui/subagent_panel.py +186 -0
- alysis_code/cli_impl/tui/surface.py +796 -0
- alysis_code/cli_impl/tui/transcript.py +514 -0
- alysis_code/cli_impl/tui/update_prompt.py +79 -0
- alysis_code/cli_impl/tui/workspace_guard.py +384 -0
- alysis_code/clipboard.py +172 -0
- alysis_code/code_review.py +1211 -0
- alysis_code/compaction/__init__.py +28 -0
- alysis_code/compaction/conversation_compactor.py +2932 -0
- alysis_code/compaction/importance.py +177 -0
- alysis_code/compaction/settings.py +297 -0
- alysis_code/compaction/tool_output_offload.py +447 -0
- alysis_code/config.py +3509 -0
- alysis_code/conflict_auto_resolver.py +895 -0
- alysis_code/context/__init__.py +1 -0
- alysis_code/context/tool_schema_budgeter.py +220 -0
- alysis_code/crash_diagnostics.py +282 -0
- alysis_code/custom_tools/README.md +34 -0
- alysis_code/custom_tools/__init__.py +43 -0
- alysis_code/custom_tools/discovery.py +903 -0
- alysis_code/custom_tools/runtime.py +1516 -0
- alysis_code/custom_tools/session.py +227 -0
- alysis_code/custom_tools/trust.py +232 -0
- alysis_code/diff_paths.py +113 -0
- alysis_code/direction_change.py +293 -0
- alysis_code/dispatch_timing.py +306 -0
- alysis_code/durable_service_manager.py +1236 -0
- alysis_code/edit_discipline.py +659 -0
- alysis_code/error_text.py +73 -0
- alysis_code/execution_budget.py +411 -0
- alysis_code/execution_context.py +915 -0
- alysis_code/execution_deadline.py +1065 -0
- alysis_code/execution_shared.py +1904 -0
- alysis_code/extensions/README.md +30 -0
- alysis_code/extensions/__init__.py +93 -0
- alysis_code/extensions/activation.py +138 -0
- alysis_code/extensions/install.py +1436 -0
- alysis_code/extensions/manifest.py +487 -0
- alysis_code/extensions/models.py +74 -0
- alysis_code/extensions/paths.py +56 -0
- alysis_code/extensions/registry.json +4 -0
- alysis_code/extensions/registry.py +52 -0
- alysis_code/extensions/state.py +83 -0
- alysis_code/extensions/workspace_trust.py +101 -0
- alysis_code/failed_task_evidence.py +369 -0
- alysis_code/failure_category.py +315 -0
- alysis_code/feedback_report.py +1647 -0
- alysis_code/file_classification.py +485 -0
- alysis_code/forge.py +2064 -0
- alysis_code/forge_completion.py +362 -0
- alysis_code/forge_events.py +475 -0
- alysis_code/frontmatter_utils.py +95 -0
- alysis_code/git_evidence.py +1181 -0
- alysis_code/git_ops.py +560 -0
- alysis_code/git_safe.py +62 -0
- alysis_code/git_worktrees.py +190 -0
- alysis_code/hooks/README.md +33 -0
- alysis_code/hooks/__init__.py +67 -0
- alysis_code/hooks/audit.py +171 -0
- alysis_code/hooks/config.py +225 -0
- alysis_code/hooks/dispatcher.py +1110 -0
- alysis_code/hooks/models.py +447 -0
- alysis_code/hooks/trust.py +202 -0
- alysis_code/host_actions.py +543 -0
- alysis_code/host_browser.py +103 -0
- alysis_code/ide/__init__.py +5 -0
- alysis_code/ide/activity_events.py +399 -0
- alysis_code/ide/approvals.py +337 -0
- alysis_code/ide/artifacts.py +153 -0
- alysis_code/ide/browser_egress_proxy.py +1076 -0
- alysis_code/ide/cdp_websocket_transport.py +1192 -0
- alysis_code/ide/change_ledger.py +1721 -0
- alysis_code/ide/context_blocks.py +979 -0
- alysis_code/ide/event_stream.py +531 -0
- alysis_code/ide/forge_protocol.py +3112 -0
- alysis_code/ide/forge_request_ledger.py +737 -0
- alysis_code/ide/health.py +965 -0
- alysis_code/ide/managed_browser.py +2251 -0
- alysis_code/ide/management_protocol.py +3414 -0
- alysis_code/ide/mcp_oauth_coordinator.py +744 -0
- alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
- alysis_code/ide/prompt_queue.py +1070 -0
- alysis_code/ide/protocol.py +191 -0
- alysis_code/ide/resumable_swarm.py +1543 -0
- alysis_code/ide/session_search.py +295 -0
- alysis_code/ide/stdio_bridge.py +9935 -0
- alysis_code/ide/structured_state.py +1579 -0
- alysis_code/ide/swarm_protocol.py +816 -0
- alysis_code/integration_gate.py +506 -0
- alysis_code/interactive_input_guard.py +39 -0
- alysis_code/interactive_plan_mode.py +26 -0
- alysis_code/internal_artifacts.py +179 -0
- alysis_code/knowledge_base.py +1409 -0
- alysis_code/knowledge_capture.py +1190 -0
- alysis_code/knowledge_librarian.py +605 -0
- alysis_code/language_policy.py +34 -0
- alysis_code/litellm_static_provider.py +535 -0
- alysis_code/llm/__init__.py +1 -0
- alysis_code/llm/anthropic_messages.py +2288 -0
- alysis_code/llm/base.py +71 -0
- alysis_code/llm/cache_capabilities.py +985 -0
- alysis_code/llm/cache_control_blocks.py +244 -0
- alysis_code/llm/cache_policy.py +388 -0
- alysis_code/llm/factory.py +373 -0
- alysis_code/llm/gemini_generate_content.py +2652 -0
- alysis_code/llm/gemini_interactions.py +739 -0
- alysis_code/llm/metadata.py +450 -0
- alysis_code/llm/openai_compat.py +2947 -0
- alysis_code/llm/openai_responses.py +2604 -0
- alysis_code/llm/protocols.py +609 -0
- alysis_code/llm/provider_limits.py +525 -0
- alysis_code/llm/request_plan.py +389 -0
- alysis_code/llm/request_shape.py +238 -0
- alysis_code/llm/streaming.py +108 -0
- alysis_code/llm/temperature_compat.py +78 -0
- alysis_code/llm/types.py +195 -0
- alysis_code/llm/usage_normalization.py +222 -0
- alysis_code/llm_error_display.py +315 -0
- alysis_code/logging_redaction.py +326 -0
- alysis_code/managed_host_deadline.py +196 -0
- alysis_code/mcp/README.md +33 -0
- alysis_code/mcp/__init__.py +24 -0
- alysis_code/mcp/client.py +1137 -0
- alysis_code/mcp/config.py +597 -0
- alysis_code/mcp/errors.py +113 -0
- alysis_code/mcp/forge_scope.py +154 -0
- alysis_code/mcp/jsonrpc.py +214 -0
- alysis_code/mcp/manager.py +2308 -0
- alysis_code/mcp/models.py +666 -0
- alysis_code/mcp/oauth.py +972 -0
- alysis_code/mcp/oauth_runtime.py +310 -0
- alysis_code/mcp/oauth_store.py +276 -0
- alysis_code/mcp/prompts.py +329 -0
- alysis_code/mcp/resources.py +295 -0
- alysis_code/mcp/roots.py +106 -0
- alysis_code/mcp/server_requests.py +75 -0
- alysis_code/mcp/token_store.py +859 -0
- alysis_code/mcp/transport_http.py +1338 -0
- alysis_code/mcp/transport_stdio.py +1267 -0
- alysis_code/mcp/untrusted_content.py +119 -0
- alysis_code/merge_conflict_reviewer.py +729 -0
- alysis_code/model_catalog/__init__.py +1 -0
- alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
- alysis_code/model_metadata_policy.py +223 -0
- alysis_code/model_metadata_utils.py +103 -0
- alysis_code/model_registry.py +1420 -0
- alysis_code/model_router.py +147 -0
- alysis_code/permission_policy.py +1016 -0
- alysis_code/personas.py +451 -0
- alysis_code/pipeline_facts.py +233 -0
- alysis_code/plan_assistant.py +4763 -0
- alysis_code/plan_mode.py +393 -0
- alysis_code/plan_reconciliation.py +1228 -0
- alysis_code/plan_repair.py +652 -0
- alysis_code/plan_validation.py +1099 -0
- alysis_code/planning_constraints.py +904 -0
- alysis_code/policy.py +95 -0
- alysis_code/preview_server.py +457 -0
- alysis_code/process_reaping.py +566 -0
- alysis_code/profile_presets.py +1834 -0
- alysis_code/profiles.py +666 -0
- alysis_code/provider_auth/__init__.py +29 -0
- alysis_code/provider_auth/base.py +99 -0
- alysis_code/provider_auth/openai_codex.py +951 -0
- alysis_code/provider_auth/registry.py +76 -0
- alysis_code/provider_auth/store.py +125 -0
- alysis_code/provider_diagnostics.py +1209 -0
- alysis_code/provider_model_catalog.py +685 -0
- alysis_code/provider_telemetry.py +1699 -0
- alysis_code/provider_url.py +75 -0
- alysis_code/reasoning_contracts.py +911 -0
- alysis_code/remote_sync.py +350 -0
- alysis_code/replanning.py +1195 -0
- alysis_code/repo_scan.py +1152 -0
- alysis_code/request_estimation.py +296 -0
- alysis_code/review_gate.py +617 -0
- alysis_code/run_lock.py +1141 -0
- alysis_code/run_outcome.py +58 -0
- alysis_code/run_provenance.py +774 -0
- alysis_code/run_state.py +445 -0
- alysis_code/runtime_artifacts.py +116 -0
- alysis_code/runtime_context_features.py +78 -0
- alysis_code/runtime_kind.py +52 -0
- alysis_code/safety/__init__.py +11 -0
- alysis_code/safety/mcp_sanitize.py +29 -0
- alysis_code/safety/safe_http.py +297 -0
- alysis_code/safety/subagent_report.py +184 -0
- alysis_code/sandbox_doctor.py +682 -0
- alysis_code/sandbox_runner.py +1025 -0
- alysis_code/sandbox_settings.py +423 -0
- alysis_code/serialized_paths.py +355 -0
- alysis_code/server/__init__.py +3 -0
- alysis_code/server/app.py +367 -0
- alysis_code/server/auth.py +34 -0
- alysis_code/server/job_config.py +30 -0
- alysis_code/server/settings.py +215 -0
- alysis_code/server/store.py +193 -0
- alysis_code/server/worker_runner.py +657 -0
- alysis_code/service_persistence.py +355 -0
- alysis_code/session_artifacts.py +108 -0
- alysis_code/session_metrics.py +331 -0
- alysis_code/session_store.py +624 -0
- alysis_code/skills/README.md +34 -0
- alysis_code/skills/__init__.py +104 -0
- alysis_code/skills/conventions.py +84 -0
- alysis_code/skills/discovery.py +176 -0
- alysis_code/skills/eval_models.py +232 -0
- alysis_code/skills/eval_runner.py +372 -0
- alysis_code/skills/evals.py +1344 -0
- alysis_code/skills/install.py +293 -0
- alysis_code/skills/loader.py +118 -0
- alysis_code/skills/matching.py +103 -0
- alysis_code/skills/models.py +71 -0
- alysis_code/skills/paths.py +56 -0
- alysis_code/skills/prompting.py +500 -0
- alysis_code/skills/scaffold.py +142 -0
- alysis_code/skills/state.py +441 -0
- alysis_code/skills/transactions.py +125 -0
- alysis_code/skills/validation.py +304 -0
- alysis_code/step_budget.py +238 -0
- alysis_code/subagent_labels.py +49 -0
- alysis_code/subagents.py +1072 -0
- alysis_code/surface/__init__.py +80 -0
- alysis_code/surface/base.py +305 -0
- alysis_code/surface/console.py +387 -0
- alysis_code/surface/events.py +372 -0
- alysis_code/surface/hidden_surface.py +529 -0
- alysis_code/surface/noop_surface.py +219 -0
- alysis_code/surface/rich_surface.py +1555 -0
- alysis_code/surface/styles.py +67 -0
- alysis_code/surface/theme.py +455 -0
- alysis_code/surface/types.py +100 -0
- alysis_code/swarm_backend.py +926 -0
- alysis_code/swarm_orchestrator.py +4020 -0
- alysis_code/swarm_scheduler.py +441 -0
- alysis_code/swarm_trace.py +429 -0
- alysis_code/swarm_worker.py +2119 -0
- alysis_code/swarm_write_guard.py +348 -0
- alysis_code/task_dependencies.py +170 -0
- alysis_code/task_readiness.py +992 -0
- alysis_code/task_scope.py +2148 -0
- alysis_code/terminal_manager.py +762 -0
- alysis_code/terminal_ownership.py +460 -0
- alysis_code/text_normalization.py +30 -0
- alysis_code/token_budget.py +97 -0
- alysis_code/tools/README.md +34 -0
- alysis_code/tools/__init__.py +1 -0
- alysis_code/tools/artifacts.py +127 -0
- alysis_code/tools/availability.py +188 -0
- alysis_code/tools/fs.py +1456 -0
- alysis_code/tools/git.py +461 -0
- alysis_code/tools/history.py +229 -0
- alysis_code/tools/http_timeout.py +78 -0
- alysis_code/tools/image_generation.py +552 -0
- alysis_code/tools/registry.py +2936 -0
- alysis_code/tools/repo_map.py +476 -0
- alysis_code/tools/search.py +563 -0
- alysis_code/tools/shell.py +135 -0
- alysis_code/tools/symbols.py +1350 -0
- alysis_code/tools/test_discovery.py +643 -0
- alysis_code/tools/web.py +482 -0
- alysis_code/tools/web_search.py +2012 -0
- alysis_code/tools/web_search_dashscope.py +557 -0
- alysis_code/tools/web_search_ddgs.py +221 -0
- alysis_code/tools/web_search_provider_adapters.py +1429 -0
- alysis_code/tools/web_search_tavily.py +194 -0
- alysis_code/updates.py +933 -0
- alysis_code/usage_tracker.py +1990 -0
- alysis_code/verification_command_analysis.py +1004 -0
- alysis_code/verification_contract.py +574 -0
- alysis_code/verification_failure_summary.py +273 -0
- alysis_code/verification_repair.py +385 -0
- alysis_code/verify_gate.py +3129 -0
- alysis_code/web_research.py +1872 -0
- alysis_code/web_search_adapters.py +66 -0
- alysis_code/web_search_policy.py +27 -0
- alysis_code/workspace_binding.py +389 -0
- alysis_code/workspace_binding_ui.py +408 -0
- alysis_code/workspace_context.py +273 -0
- alysis_code/workspace_isolation.py +138 -0
- alysis_code/workspace_provisioning.py +455 -0
- alysis_code-0.13.0.dist-info/METADATA +507 -0
- alysis_code-0.13.0.dist-info/RECORD +458 -0
- alysis_code-0.13.0.dist-info/WHEEL +4 -0
- alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
- alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
- alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,1016 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import ntpath
|
|
7
|
+
import os
|
|
8
|
+
import posixpath
|
|
9
|
+
import re
|
|
10
|
+
import threading
|
|
11
|
+
import uuid
|
|
12
|
+
from collections.abc import Mapping, Sequence
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from io import BufferedRandom
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Literal
|
|
19
|
+
|
|
20
|
+
from .approval_scope import SUPPORTED_APPROVAL_SCOPES, ApprovalSessionScope
|
|
21
|
+
from .atomic_io import atomic_write_json
|
|
22
|
+
from .branding import canonical_user_config_dir, env_get
|
|
23
|
+
|
|
24
|
+
PERMISSION_POLICY_SCHEMA_VERSION = 1
|
|
25
|
+
_MAX_RULES = 1_000
|
|
26
|
+
_MAX_SELECTOR_LENGTH = 4_096
|
|
27
|
+
_MAX_SOURCE_LENGTH = 128
|
|
28
|
+
_RULE_ID_RE = re.compile(r"^pr_[a-f0-9]{32}$")
|
|
29
|
+
_SESSION_GRANT_ID_RE = re.compile(r"^sg_[a-f0-9]{32}$")
|
|
30
|
+
_WINDOWS_ABSOLUTE_RE = re.compile(r"^(?:[a-zA-Z]:[\\/]|[\\/]{2})")
|
|
31
|
+
_CONTROL_CHARACTER_RE = re.compile(r"[\x00-\x1f\x7f]")
|
|
32
|
+
_SHA256_RE = re.compile(r"^[a-f0-9]{64}$")
|
|
33
|
+
_POLICY_STORE_LOCK = threading.RLock()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def default_permission_policy_path() -> Path:
|
|
37
|
+
"""Return the user-scoped policy path, honoring the standard config override."""
|
|
38
|
+
|
|
39
|
+
override = env_get("ALYSIS_CONFIG_DIR")
|
|
40
|
+
root = Path(override).expanduser() if override else canonical_user_config_dir()
|
|
41
|
+
return root / "permission_policy.json"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
if os.name == "nt": # pragma: no cover - exercised on Windows
|
|
45
|
+
import msvcrt
|
|
46
|
+
else: # pragma: no cover - exercised on POSIX
|
|
47
|
+
import fcntl
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PolicyEffect(str, Enum):
|
|
51
|
+
ALLOW = "allow"
|
|
52
|
+
ASK = "ask"
|
|
53
|
+
DENY = "deny"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PermissionPolicyError(RuntimeError):
|
|
57
|
+
"""Base error that deliberately does not include policy selector contents."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PermissionPolicyValidationError(PermissionPolicyError):
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class PermissionPolicyCorruptError(PermissionPolicyError):
|
|
65
|
+
def __init__(self) -> None:
|
|
66
|
+
super().__init__("Permission policy is invalid or unreadable; access is denied.")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class PermissionRule:
|
|
71
|
+
id: str
|
|
72
|
+
effect: PolicyEffect
|
|
73
|
+
order: int
|
|
74
|
+
source: str
|
|
75
|
+
tool_pattern: str | None = None
|
|
76
|
+
path_pattern: str | None = None
|
|
77
|
+
command_pattern: str | None = field(default=None, repr=False)
|
|
78
|
+
|
|
79
|
+
def to_public_dict(self, *, reveal_command_pattern: bool = False) -> dict[str, Any]:
|
|
80
|
+
result: dict[str, Any] = {
|
|
81
|
+
"id": self.id,
|
|
82
|
+
"effect": self.effect.value,
|
|
83
|
+
"order": self.order,
|
|
84
|
+
"source": self.source,
|
|
85
|
+
"tool_pattern": self.tool_pattern,
|
|
86
|
+
"path_pattern": self.path_pattern,
|
|
87
|
+
"has_command_pattern": self.command_pattern is not None,
|
|
88
|
+
}
|
|
89
|
+
if reveal_command_pattern and self.command_pattern is not None:
|
|
90
|
+
result["command_pattern"] = self.command_pattern
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
def _to_storage_dict(self) -> dict[str, Any]:
|
|
94
|
+
return {
|
|
95
|
+
"id": self.id,
|
|
96
|
+
"effect": self.effect.value,
|
|
97
|
+
"order": self.order,
|
|
98
|
+
"source": self.source,
|
|
99
|
+
"tool_pattern": self.tool_pattern,
|
|
100
|
+
"path_pattern": self.path_pattern,
|
|
101
|
+
"command_pattern": self.command_pattern,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True, slots=True)
|
|
106
|
+
class PermissionRequest:
|
|
107
|
+
tool_name: str
|
|
108
|
+
paths: tuple[str, ...] = ()
|
|
109
|
+
command: str | None = field(default=None, repr=False)
|
|
110
|
+
workspace_root: str | None = None
|
|
111
|
+
platform: Literal["auto", "windows", "posix"] = "auto"
|
|
112
|
+
sensitive: bool = False
|
|
113
|
+
external_directory: bool = False
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def create(
|
|
117
|
+
cls,
|
|
118
|
+
tool_name: str,
|
|
119
|
+
*,
|
|
120
|
+
path: str | None = None,
|
|
121
|
+
paths: Sequence[str] = (),
|
|
122
|
+
command: str | None = None,
|
|
123
|
+
workspace_root: str | os.PathLike[str] | None = None,
|
|
124
|
+
platform: Literal["auto", "windows", "posix"] = "auto",
|
|
125
|
+
sensitive: bool = False,
|
|
126
|
+
external_directory: bool = False,
|
|
127
|
+
) -> PermissionRequest:
|
|
128
|
+
combined_paths = ((path,) if path is not None else ()) + tuple(paths)
|
|
129
|
+
return cls(
|
|
130
|
+
tool_name=tool_name,
|
|
131
|
+
paths=combined_paths,
|
|
132
|
+
command=command,
|
|
133
|
+
workspace_root=os.fspath(workspace_root) if workspace_root is not None else None,
|
|
134
|
+
platform=platform,
|
|
135
|
+
sensitive=sensitive,
|
|
136
|
+
external_directory=external_directory,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True, slots=True)
|
|
141
|
+
class PermissionEvaluation:
|
|
142
|
+
decision: PolicyEffect
|
|
143
|
+
reason: str
|
|
144
|
+
matched_rule_id: str | None
|
|
145
|
+
matched_rule_source: str | None
|
|
146
|
+
specificity: int
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def allowed(self) -> bool:
|
|
150
|
+
return self.decision is PolicyEffect.ALLOW
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(frozen=True, slots=True)
|
|
154
|
+
class _NormalizedRequest:
|
|
155
|
+
tool_name: str
|
|
156
|
+
paths: tuple[str, ...]
|
|
157
|
+
command: str | None = field(repr=False)
|
|
158
|
+
platform: Literal["windows", "posix"]
|
|
159
|
+
sensitive: bool
|
|
160
|
+
external_directory: bool
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class PermissionPolicy:
|
|
164
|
+
"""Ordered, explainable permission rules with non-bypassable safety overrides."""
|
|
165
|
+
|
|
166
|
+
def __init__(
|
|
167
|
+
self,
|
|
168
|
+
rules: Sequence[PermissionRule] = (),
|
|
169
|
+
*,
|
|
170
|
+
sensitive_override: PolicyEffect | str = PolicyEffect.ASK,
|
|
171
|
+
external_directory_override: PolicyEffect | str = PolicyEffect.ASK,
|
|
172
|
+
) -> None:
|
|
173
|
+
self._lock = threading.RLock()
|
|
174
|
+
self._rules = list(rules)
|
|
175
|
+
self.sensitive_override = _validate_safety_override(sensitive_override)
|
|
176
|
+
self.external_directory_override = _validate_safety_override(external_directory_override)
|
|
177
|
+
_validate_rule_collection(self._rules)
|
|
178
|
+
|
|
179
|
+
def list_rules(self) -> tuple[PermissionRule, ...]:
|
|
180
|
+
with self._lock:
|
|
181
|
+
return tuple(sorted(self._rules, key=lambda rule: rule.order))
|
|
182
|
+
|
|
183
|
+
def grant(
|
|
184
|
+
self,
|
|
185
|
+
effect: PolicyEffect | str,
|
|
186
|
+
*,
|
|
187
|
+
tool_pattern: str | None = None,
|
|
188
|
+
path_pattern: str | None = None,
|
|
189
|
+
command_pattern: str | None = None,
|
|
190
|
+
source: str = "user",
|
|
191
|
+
) -> PermissionRule:
|
|
192
|
+
normalized_effect = _parse_effect(effect)
|
|
193
|
+
normalized_tool = _normalize_optional_tool_pattern(tool_pattern)
|
|
194
|
+
normalized_path = _normalize_optional_path_pattern(path_pattern)
|
|
195
|
+
normalized_command = _normalize_optional_command_pattern(command_pattern)
|
|
196
|
+
normalized_source = _validate_source(source)
|
|
197
|
+
if normalized_tool is None and normalized_path is None and normalized_command is None:
|
|
198
|
+
raise PermissionPolicyValidationError("A permission rule requires a selector.")
|
|
199
|
+
with self._lock:
|
|
200
|
+
if len(self._rules) >= _MAX_RULES:
|
|
201
|
+
raise PermissionPolicyValidationError("Permission policy contains too many rules.")
|
|
202
|
+
next_order = max((rule.order for rule in self._rules), default=-1) + 1
|
|
203
|
+
rule = PermissionRule(
|
|
204
|
+
id=f"pr_{uuid.uuid4().hex}",
|
|
205
|
+
effect=normalized_effect,
|
|
206
|
+
order=next_order,
|
|
207
|
+
source=normalized_source,
|
|
208
|
+
tool_pattern=normalized_tool,
|
|
209
|
+
path_pattern=normalized_path,
|
|
210
|
+
command_pattern=normalized_command,
|
|
211
|
+
)
|
|
212
|
+
self._rules.append(rule)
|
|
213
|
+
return rule
|
|
214
|
+
|
|
215
|
+
def revoke(self, rule_id: str) -> bool:
|
|
216
|
+
_validate_rule_id(rule_id)
|
|
217
|
+
with self._lock:
|
|
218
|
+
for index, rule in enumerate(self._rules):
|
|
219
|
+
if rule.id == rule_id:
|
|
220
|
+
del self._rules[index]
|
|
221
|
+
return True
|
|
222
|
+
return False
|
|
223
|
+
|
|
224
|
+
def set_safety_overrides(
|
|
225
|
+
self,
|
|
226
|
+
*,
|
|
227
|
+
sensitive: PolicyEffect | str | None = None,
|
|
228
|
+
external_directory: PolicyEffect | str | None = None,
|
|
229
|
+
) -> None:
|
|
230
|
+
with self._lock:
|
|
231
|
+
if sensitive is not None:
|
|
232
|
+
self.sensitive_override = _validate_safety_override(sensitive)
|
|
233
|
+
if external_directory is not None:
|
|
234
|
+
self.external_directory_override = _validate_safety_override(external_directory)
|
|
235
|
+
|
|
236
|
+
def evaluate(self, request: PermissionRequest) -> PermissionEvaluation:
|
|
237
|
+
normalized = _normalize_request(request)
|
|
238
|
+
safety = self._evaluate_safety_overrides(normalized)
|
|
239
|
+
if safety is not None:
|
|
240
|
+
return safety
|
|
241
|
+
|
|
242
|
+
matches: list[tuple[int, int, int, PermissionRule]] = []
|
|
243
|
+
for rule in self.list_rules():
|
|
244
|
+
if not _rule_matches(rule, normalized):
|
|
245
|
+
continue
|
|
246
|
+
specificity = _rule_specificity(rule)
|
|
247
|
+
# At equal specificity, fail closed: deny, then ask, then allow.
|
|
248
|
+
effect_precedence = {
|
|
249
|
+
PolicyEffect.ALLOW: 0,
|
|
250
|
+
PolicyEffect.ASK: 1,
|
|
251
|
+
PolicyEffect.DENY: 2,
|
|
252
|
+
}[rule.effect]
|
|
253
|
+
# Earlier rules win only after specificity and safety precedence tie.
|
|
254
|
+
matches.append((specificity, effect_precedence, -rule.order, rule))
|
|
255
|
+
if not matches:
|
|
256
|
+
return PermissionEvaluation(
|
|
257
|
+
decision=PolicyEffect.ASK,
|
|
258
|
+
reason="no_matching_rule",
|
|
259
|
+
matched_rule_id=None,
|
|
260
|
+
matched_rule_source=None,
|
|
261
|
+
specificity=0,
|
|
262
|
+
)
|
|
263
|
+
specificity, _, _, winner = max(matches, key=lambda item: item[:3])
|
|
264
|
+
return PermissionEvaluation(
|
|
265
|
+
decision=winner.effect,
|
|
266
|
+
reason="matched_rule",
|
|
267
|
+
matched_rule_id=winner.id,
|
|
268
|
+
matched_rule_source=winner.source,
|
|
269
|
+
specificity=specificity,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
def _evaluate_safety_overrides(
|
|
273
|
+
self, request: _NormalizedRequest
|
|
274
|
+
) -> PermissionEvaluation | None:
|
|
275
|
+
candidates: list[tuple[PolicyEffect, str, str]] = []
|
|
276
|
+
if request.sensitive:
|
|
277
|
+
candidates.append(
|
|
278
|
+
(
|
|
279
|
+
self.sensitive_override,
|
|
280
|
+
"sensitive_resource_requires_approval",
|
|
281
|
+
"override:sensitive",
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
if request.external_directory:
|
|
285
|
+
candidates.append(
|
|
286
|
+
(
|
|
287
|
+
self.external_directory_override,
|
|
288
|
+
"external_directory_requires_approval",
|
|
289
|
+
"override:external_directory",
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
if not candidates:
|
|
293
|
+
return None
|
|
294
|
+
effect, reason, rule_id = max(
|
|
295
|
+
candidates,
|
|
296
|
+
key=lambda item: (item[0] is PolicyEffect.DENY, item[2] == "override:sensitive"),
|
|
297
|
+
)
|
|
298
|
+
return PermissionEvaluation(
|
|
299
|
+
decision=effect,
|
|
300
|
+
reason=reason,
|
|
301
|
+
matched_rule_id=rule_id,
|
|
302
|
+
matched_rule_source="builtin_safety",
|
|
303
|
+
specificity=2_147_483_647,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
def _to_storage_payload(self) -> dict[str, Any]:
|
|
307
|
+
return {
|
|
308
|
+
"schema_version": PERMISSION_POLICY_SCHEMA_VERSION,
|
|
309
|
+
"safety_overrides": {
|
|
310
|
+
"sensitive": self.sensitive_override.value,
|
|
311
|
+
"external_directory": self.external_directory_override.value,
|
|
312
|
+
},
|
|
313
|
+
"rules": [rule._to_storage_dict() for rule in self.list_rules()],
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class PermissionPolicyStore:
|
|
318
|
+
"""Atomic JSON-backed policy store. Corruption always evaluates to deny."""
|
|
319
|
+
|
|
320
|
+
def __init__(self, path: Path) -> None:
|
|
321
|
+
self.path = Path(path)
|
|
322
|
+
|
|
323
|
+
def load(self) -> PermissionPolicy:
|
|
324
|
+
with _POLICY_STORE_LOCK:
|
|
325
|
+
return _load_policy_from_path(self.path)
|
|
326
|
+
|
|
327
|
+
def save(self, policy: PermissionPolicy) -> None:
|
|
328
|
+
with _locked_policy_state(self.path):
|
|
329
|
+
_save_policy_to_path(self.path, policy)
|
|
330
|
+
|
|
331
|
+
def list_rules(self) -> tuple[PermissionRule, ...]:
|
|
332
|
+
return self.load().list_rules()
|
|
333
|
+
|
|
334
|
+
def grant(
|
|
335
|
+
self,
|
|
336
|
+
effect: PolicyEffect | str,
|
|
337
|
+
*,
|
|
338
|
+
tool_pattern: str | None = None,
|
|
339
|
+
path_pattern: str | None = None,
|
|
340
|
+
command_pattern: str | None = None,
|
|
341
|
+
source: str = "user",
|
|
342
|
+
) -> PermissionRule:
|
|
343
|
+
with _locked_policy_state(self.path):
|
|
344
|
+
policy = _load_policy_from_path(self.path)
|
|
345
|
+
rule = policy.grant(
|
|
346
|
+
effect,
|
|
347
|
+
tool_pattern=tool_pattern,
|
|
348
|
+
path_pattern=path_pattern,
|
|
349
|
+
command_pattern=command_pattern,
|
|
350
|
+
source=source,
|
|
351
|
+
)
|
|
352
|
+
_save_policy_to_path(self.path, policy)
|
|
353
|
+
return rule
|
|
354
|
+
|
|
355
|
+
def revoke(self, rule_id: str) -> bool:
|
|
356
|
+
with _locked_policy_state(self.path):
|
|
357
|
+
policy = _load_policy_from_path(self.path)
|
|
358
|
+
removed = policy.revoke(rule_id)
|
|
359
|
+
if removed:
|
|
360
|
+
_save_policy_to_path(self.path, policy)
|
|
361
|
+
return removed
|
|
362
|
+
|
|
363
|
+
def set_safety_overrides(
|
|
364
|
+
self,
|
|
365
|
+
*,
|
|
366
|
+
sensitive: PolicyEffect | str | None = None,
|
|
367
|
+
external_directory: PolicyEffect | str | None = None,
|
|
368
|
+
) -> None:
|
|
369
|
+
with _locked_policy_state(self.path):
|
|
370
|
+
policy = _load_policy_from_path(self.path)
|
|
371
|
+
policy.set_safety_overrides(
|
|
372
|
+
sensitive=sensitive,
|
|
373
|
+
external_directory=external_directory,
|
|
374
|
+
)
|
|
375
|
+
_save_policy_to_path(self.path, policy)
|
|
376
|
+
|
|
377
|
+
def evaluate(self, request: PermissionRequest) -> PermissionEvaluation:
|
|
378
|
+
try:
|
|
379
|
+
return self.load().evaluate(request)
|
|
380
|
+
except PermissionPolicyCorruptError:
|
|
381
|
+
return PermissionEvaluation(
|
|
382
|
+
decision=PolicyEffect.DENY,
|
|
383
|
+
reason="permission_policy_corrupt",
|
|
384
|
+
matched_rule_id="override:corrupt_policy",
|
|
385
|
+
matched_rule_source="builtin_safety",
|
|
386
|
+
specificity=2_147_483_647,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@dataclass(frozen=True, slots=True)
|
|
391
|
+
class SessionPermissionGrant:
|
|
392
|
+
id: str
|
|
393
|
+
kind: str
|
|
394
|
+
scope_type: str
|
|
395
|
+
source: str
|
|
396
|
+
_scope_fingerprint: str = field(repr=False)
|
|
397
|
+
|
|
398
|
+
def to_public_dict(self) -> dict[str, str]:
|
|
399
|
+
return {
|
|
400
|
+
"id": self.id,
|
|
401
|
+
"kind": self.kind,
|
|
402
|
+
"scope_type": self.scope_type,
|
|
403
|
+
"source": self.source,
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
class InMemorySessionGrantAdapter:
|
|
408
|
+
"""Inspectable opaque IDs around existing exact approval-session scopes."""
|
|
409
|
+
|
|
410
|
+
def __init__(self) -> None:
|
|
411
|
+
self._lock = threading.RLock()
|
|
412
|
+
self._grants: list[SessionPermissionGrant] = []
|
|
413
|
+
|
|
414
|
+
def grant(
|
|
415
|
+
self,
|
|
416
|
+
*,
|
|
417
|
+
kind: str,
|
|
418
|
+
scope: Mapping[str, Any],
|
|
419
|
+
source: str = "session",
|
|
420
|
+
) -> SessionPermissionGrant:
|
|
421
|
+
normalized_kind = _validate_kind(kind)
|
|
422
|
+
scope_type, fingerprint = _validated_scope_fingerprint(scope)
|
|
423
|
+
declared_kind = str(scope.get("kind") or "").strip().casefold()
|
|
424
|
+
if declared_kind and declared_kind != normalized_kind:
|
|
425
|
+
raise PermissionPolicyValidationError("Approval session scope kind does not match.")
|
|
426
|
+
normalized_source = _validate_source(source)
|
|
427
|
+
with self._lock:
|
|
428
|
+
for grant in self._grants:
|
|
429
|
+
if grant.kind == normalized_kind and grant._scope_fingerprint == fingerprint:
|
|
430
|
+
return grant
|
|
431
|
+
grant = SessionPermissionGrant(
|
|
432
|
+
id=f"sg_{uuid.uuid4().hex}",
|
|
433
|
+
kind=normalized_kind,
|
|
434
|
+
scope_type=scope_type,
|
|
435
|
+
source=normalized_source,
|
|
436
|
+
_scope_fingerprint=fingerprint,
|
|
437
|
+
)
|
|
438
|
+
self._grants.append(grant)
|
|
439
|
+
return grant
|
|
440
|
+
|
|
441
|
+
def grant_approval_scope(
|
|
442
|
+
self,
|
|
443
|
+
approval_scope: ApprovalSessionScope,
|
|
444
|
+
*,
|
|
445
|
+
kind: str | None = None,
|
|
446
|
+
source: str = "session",
|
|
447
|
+
) -> SessionPermissionGrant:
|
|
448
|
+
if not approval_scope.supported or approval_scope.scope is None:
|
|
449
|
+
raise PermissionPolicyValidationError("Approval session scope is not safe to grant.")
|
|
450
|
+
resolved_kind = kind or str(
|
|
451
|
+
approval_scope.scope.get("kind") or approval_scope.scope.get("safe_kind") or ""
|
|
452
|
+
)
|
|
453
|
+
return self.grant(kind=resolved_kind, scope=approval_scope.scope, source=source)
|
|
454
|
+
|
|
455
|
+
def list_grants(self) -> tuple[SessionPermissionGrant, ...]:
|
|
456
|
+
with self._lock:
|
|
457
|
+
return tuple(self._grants)
|
|
458
|
+
|
|
459
|
+
def revoke(self, grant_id: str) -> bool:
|
|
460
|
+
if _SESSION_GRANT_ID_RE.fullmatch(str(grant_id)) is None:
|
|
461
|
+
raise PermissionPolicyValidationError("Session grant id is invalid.")
|
|
462
|
+
with self._lock:
|
|
463
|
+
for index, grant in enumerate(self._grants):
|
|
464
|
+
if grant.id == grant_id:
|
|
465
|
+
del self._grants[index]
|
|
466
|
+
return True
|
|
467
|
+
return False
|
|
468
|
+
|
|
469
|
+
def evaluate(
|
|
470
|
+
self,
|
|
471
|
+
*,
|
|
472
|
+
kind: str,
|
|
473
|
+
scope: Mapping[str, Any],
|
|
474
|
+
sensitive: bool = False,
|
|
475
|
+
external_directory: bool = False,
|
|
476
|
+
) -> PermissionEvaluation:
|
|
477
|
+
if sensitive:
|
|
478
|
+
return _session_safety_evaluation(
|
|
479
|
+
"sensitive_resource_requires_approval", "override:sensitive"
|
|
480
|
+
)
|
|
481
|
+
if external_directory:
|
|
482
|
+
return _session_safety_evaluation(
|
|
483
|
+
"external_directory_requires_approval", "override:external_directory"
|
|
484
|
+
)
|
|
485
|
+
normalized_kind = _validate_kind(kind)
|
|
486
|
+
_, fingerprint = _validated_scope_fingerprint(scope)
|
|
487
|
+
with self._lock:
|
|
488
|
+
for grant in self._grants:
|
|
489
|
+
if grant.kind == normalized_kind and grant._scope_fingerprint == fingerprint:
|
|
490
|
+
return PermissionEvaluation(
|
|
491
|
+
decision=PolicyEffect.ALLOW,
|
|
492
|
+
reason="session_exact_grant",
|
|
493
|
+
matched_rule_id=grant.id,
|
|
494
|
+
matched_rule_source=grant.source,
|
|
495
|
+
specificity=2_000_000_000,
|
|
496
|
+
)
|
|
497
|
+
return PermissionEvaluation(
|
|
498
|
+
decision=PolicyEffect.ASK,
|
|
499
|
+
reason="session_grant_not_found",
|
|
500
|
+
matched_rule_id=None,
|
|
501
|
+
matched_rule_source=None,
|
|
502
|
+
specificity=0,
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def normalize_path_pattern(pattern: str) -> str:
|
|
507
|
+
return _normalize_path_pattern(pattern)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def normalize_command_pattern(
|
|
511
|
+
pattern: str, *, platform: Literal["windows", "posix"] = "posix"
|
|
512
|
+
) -> str:
|
|
513
|
+
normalized = _normalize_command_text(pattern)
|
|
514
|
+
return normalized.casefold() if platform == "windows" else normalized
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def normalize_workspace_path(
|
|
518
|
+
path: str,
|
|
519
|
+
*,
|
|
520
|
+
workspace_root: str | None = None,
|
|
521
|
+
platform: Literal["auto", "windows", "posix"] = "auto",
|
|
522
|
+
) -> tuple[str | None, bool]:
|
|
523
|
+
selected_platform = _select_platform(platform, path, workspace_root)
|
|
524
|
+
return _normalize_workspace_path(path, workspace_root, selected_platform)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _load_policy_from_path(path: Path) -> PermissionPolicy:
|
|
528
|
+
if not path.exists():
|
|
529
|
+
return PermissionPolicy()
|
|
530
|
+
try:
|
|
531
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
532
|
+
return _policy_from_payload(raw)
|
|
533
|
+
except PermissionPolicyCorruptError:
|
|
534
|
+
raise
|
|
535
|
+
except Exception: # noqa: BLE001 - persistent policy must fail closed
|
|
536
|
+
raise PermissionPolicyCorruptError() from None
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _save_policy_to_path(path: Path, policy: PermissionPolicy) -> None:
|
|
540
|
+
if not isinstance(policy, PermissionPolicy):
|
|
541
|
+
raise PermissionPolicyValidationError("Permission policy is invalid.")
|
|
542
|
+
atomic_write_json(path, policy._to_storage_payload(), ensure_ascii=True)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
@contextmanager
|
|
546
|
+
def _locked_policy_state(path: Path):
|
|
547
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
548
|
+
lock_path = path.with_name(f"{path.name}.lock")
|
|
549
|
+
with _POLICY_STORE_LOCK:
|
|
550
|
+
with lock_path.open("a+b") as handle:
|
|
551
|
+
_acquire_file_lock(handle)
|
|
552
|
+
try:
|
|
553
|
+
yield
|
|
554
|
+
finally:
|
|
555
|
+
_release_file_lock(handle)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _acquire_file_lock(handle: BufferedRandom) -> None:
|
|
559
|
+
if os.name == "nt": # pragma: no cover - exercised on Windows
|
|
560
|
+
handle.seek(0, os.SEEK_END)
|
|
561
|
+
if handle.tell() == 0:
|
|
562
|
+
handle.write(b"\0")
|
|
563
|
+
handle.flush()
|
|
564
|
+
handle.seek(0)
|
|
565
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
|
|
566
|
+
return
|
|
567
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _release_file_lock(handle: BufferedRandom) -> None:
|
|
571
|
+
if os.name == "nt": # pragma: no cover - exercised on Windows
|
|
572
|
+
handle.seek(0)
|
|
573
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
574
|
+
return
|
|
575
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _policy_from_payload(raw: Any) -> PermissionPolicy:
|
|
579
|
+
try:
|
|
580
|
+
if not isinstance(raw, dict) or set(raw) != {
|
|
581
|
+
"schema_version",
|
|
582
|
+
"safety_overrides",
|
|
583
|
+
"rules",
|
|
584
|
+
}:
|
|
585
|
+
raise ValueError
|
|
586
|
+
if (
|
|
587
|
+
isinstance(raw["schema_version"], bool)
|
|
588
|
+
or raw["schema_version"] != PERMISSION_POLICY_SCHEMA_VERSION
|
|
589
|
+
):
|
|
590
|
+
raise ValueError
|
|
591
|
+
overrides = raw["safety_overrides"]
|
|
592
|
+
if not isinstance(overrides, dict) or set(overrides) != {
|
|
593
|
+
"sensitive",
|
|
594
|
+
"external_directory",
|
|
595
|
+
}:
|
|
596
|
+
raise ValueError
|
|
597
|
+
rules_raw = raw["rules"]
|
|
598
|
+
if not isinstance(rules_raw, list) or len(rules_raw) > _MAX_RULES:
|
|
599
|
+
raise ValueError
|
|
600
|
+
rules = tuple(_rule_from_payload(item) for item in rules_raw)
|
|
601
|
+
return PermissionPolicy(
|
|
602
|
+
rules,
|
|
603
|
+
sensitive_override=overrides["sensitive"],
|
|
604
|
+
external_directory_override=overrides["external_directory"],
|
|
605
|
+
)
|
|
606
|
+
except (PermissionPolicyValidationError, TypeError, ValueError):
|
|
607
|
+
raise PermissionPolicyCorruptError() from None
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _rule_from_payload(raw: Any) -> PermissionRule:
|
|
611
|
+
if not isinstance(raw, dict) or set(raw) != {
|
|
612
|
+
"id",
|
|
613
|
+
"effect",
|
|
614
|
+
"order",
|
|
615
|
+
"source",
|
|
616
|
+
"tool_pattern",
|
|
617
|
+
"path_pattern",
|
|
618
|
+
"command_pattern",
|
|
619
|
+
}:
|
|
620
|
+
raise ValueError
|
|
621
|
+
_validate_rule_id(raw["id"])
|
|
622
|
+
if isinstance(raw["order"], bool) or not isinstance(raw["order"], int) or raw["order"] < 0:
|
|
623
|
+
raise ValueError
|
|
624
|
+
rule = PermissionRule(
|
|
625
|
+
id=raw["id"],
|
|
626
|
+
effect=_parse_effect(raw["effect"]),
|
|
627
|
+
order=raw["order"],
|
|
628
|
+
source=_validate_source(raw["source"]),
|
|
629
|
+
tool_pattern=_normalize_optional_tool_pattern(raw["tool_pattern"]),
|
|
630
|
+
path_pattern=_normalize_optional_path_pattern(raw["path_pattern"]),
|
|
631
|
+
command_pattern=_normalize_optional_command_pattern(raw["command_pattern"]),
|
|
632
|
+
)
|
|
633
|
+
if rule.tool_pattern is None and rule.path_pattern is None and rule.command_pattern is None:
|
|
634
|
+
raise ValueError
|
|
635
|
+
return rule
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _validate_rule_collection(rules: Sequence[PermissionRule]) -> None:
|
|
639
|
+
if len(rules) > _MAX_RULES:
|
|
640
|
+
raise PermissionPolicyValidationError("Permission policy contains too many rules.")
|
|
641
|
+
if any(not isinstance(rule, PermissionRule) for rule in rules):
|
|
642
|
+
raise PermissionPolicyValidationError("Permission policy rule is invalid.")
|
|
643
|
+
ids = [rule.id for rule in rules]
|
|
644
|
+
orders = [rule.order for rule in rules]
|
|
645
|
+
if len(ids) != len(set(ids)) or len(orders) != len(set(orders)):
|
|
646
|
+
raise PermissionPolicyValidationError("Permission policy rule identity is invalid.")
|
|
647
|
+
for rule in rules:
|
|
648
|
+
_validate_rule_id(rule.id)
|
|
649
|
+
if isinstance(rule.order, bool) or not isinstance(rule.order, int) or rule.order < 0:
|
|
650
|
+
raise PermissionPolicyValidationError("Permission policy rule order is invalid.")
|
|
651
|
+
_parse_effect(rule.effect)
|
|
652
|
+
_validate_source(rule.source)
|
|
653
|
+
tool_pattern = _normalize_optional_tool_pattern(rule.tool_pattern)
|
|
654
|
+
path_pattern = _normalize_optional_path_pattern(rule.path_pattern)
|
|
655
|
+
command_pattern = _normalize_optional_command_pattern(rule.command_pattern)
|
|
656
|
+
if tool_pattern is None and path_pattern is None and command_pattern is None:
|
|
657
|
+
raise PermissionPolicyValidationError("A permission rule requires a selector.")
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _validate_rule_id(rule_id: Any) -> None:
|
|
661
|
+
if not isinstance(rule_id, str) or _RULE_ID_RE.fullmatch(rule_id) is None:
|
|
662
|
+
raise PermissionPolicyValidationError("Permission rule id is invalid.")
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _parse_effect(effect: PolicyEffect | str) -> PolicyEffect:
|
|
666
|
+
try:
|
|
667
|
+
return effect if isinstance(effect, PolicyEffect) else PolicyEffect(effect)
|
|
668
|
+
except (TypeError, ValueError):
|
|
669
|
+
raise PermissionPolicyValidationError("Permission rule effect is invalid.") from None
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _validate_safety_override(effect: PolicyEffect | str) -> PolicyEffect:
|
|
673
|
+
parsed = _parse_effect(effect)
|
|
674
|
+
if parsed is PolicyEffect.ALLOW:
|
|
675
|
+
raise PermissionPolicyValidationError("Safety overrides cannot allow access.")
|
|
676
|
+
return parsed
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _validate_source(source: Any) -> str:
|
|
680
|
+
if not isinstance(source, str):
|
|
681
|
+
raise PermissionPolicyValidationError("Permission rule source is invalid.")
|
|
682
|
+
normalized = source.strip()
|
|
683
|
+
if (
|
|
684
|
+
not normalized
|
|
685
|
+
or len(normalized) > _MAX_SOURCE_LENGTH
|
|
686
|
+
or _CONTROL_CHARACTER_RE.search(normalized)
|
|
687
|
+
):
|
|
688
|
+
raise PermissionPolicyValidationError("Permission rule source is invalid.")
|
|
689
|
+
return normalized
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _validate_kind(kind: Any) -> str:
|
|
693
|
+
if not isinstance(kind, str):
|
|
694
|
+
raise PermissionPolicyValidationError("Permission scope kind is invalid.")
|
|
695
|
+
normalized = kind.strip().casefold()
|
|
696
|
+
if (
|
|
697
|
+
not normalized
|
|
698
|
+
or len(normalized) > _MAX_SOURCE_LENGTH
|
|
699
|
+
or _CONTROL_CHARACTER_RE.search(normalized)
|
|
700
|
+
):
|
|
701
|
+
raise PermissionPolicyValidationError("Permission scope kind is invalid.")
|
|
702
|
+
return normalized
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _normalize_optional_tool_pattern(value: Any) -> str | None:
|
|
706
|
+
if value is None:
|
|
707
|
+
return None
|
|
708
|
+
if not isinstance(value, str):
|
|
709
|
+
raise PermissionPolicyValidationError("Tool selector is invalid.")
|
|
710
|
+
normalized = value.strip().casefold()
|
|
711
|
+
_validate_selector(normalized, "Tool selector is invalid.")
|
|
712
|
+
if "/" in normalized or "\\" in normalized:
|
|
713
|
+
raise PermissionPolicyValidationError("Tool selector is invalid.")
|
|
714
|
+
return normalized
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _normalize_optional_path_pattern(value: Any) -> str | None:
|
|
718
|
+
if value is None:
|
|
719
|
+
return None
|
|
720
|
+
if not isinstance(value, str):
|
|
721
|
+
raise PermissionPolicyValidationError("Path selector is invalid.")
|
|
722
|
+
return _normalize_path_pattern(value)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _normalize_path_pattern(value: str) -> str:
|
|
726
|
+
normalized = value.strip().replace("\\", "/")
|
|
727
|
+
while "//" in normalized:
|
|
728
|
+
normalized = normalized.replace("//", "/")
|
|
729
|
+
while normalized.startswith("./"):
|
|
730
|
+
normalized = normalized[2:]
|
|
731
|
+
_validate_selector(normalized, "Path selector is invalid.")
|
|
732
|
+
if normalized.startswith("/") or _WINDOWS_ABSOLUTE_RE.match(normalized):
|
|
733
|
+
raise PermissionPolicyValidationError("Path selector must be workspace-relative.")
|
|
734
|
+
if any(part == ".." for part in normalized.split("/")):
|
|
735
|
+
raise PermissionPolicyValidationError("Path selector must be workspace-relative.")
|
|
736
|
+
return normalized
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _normalize_optional_command_pattern(value: Any) -> str | None:
|
|
740
|
+
if value is None:
|
|
741
|
+
return None
|
|
742
|
+
if not isinstance(value, str):
|
|
743
|
+
raise PermissionPolicyValidationError("Command selector is invalid.")
|
|
744
|
+
normalized = _normalize_command_text(value)
|
|
745
|
+
_validate_selector(normalized, "Command selector is invalid.")
|
|
746
|
+
return normalized
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _validate_selector(value: str, message: str) -> None:
|
|
750
|
+
if not value or len(value) > _MAX_SELECTOR_LENGTH or "\x00" in value:
|
|
751
|
+
raise PermissionPolicyValidationError(message)
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _normalize_command_text(value: str) -> str:
|
|
755
|
+
# Collapse separators without changing whitespace inside quoted shell arguments.
|
|
756
|
+
normalized: list[str] = []
|
|
757
|
+
quote: str | None = None
|
|
758
|
+
escaped = False
|
|
759
|
+
separator_pending = False
|
|
760
|
+
for char in value.replace("\r\n", "\n").replace("\r", "\n"):
|
|
761
|
+
if quote is not None:
|
|
762
|
+
normalized.append(char)
|
|
763
|
+
if escaped:
|
|
764
|
+
escaped = False
|
|
765
|
+
elif char == "\\" and quote == '"':
|
|
766
|
+
escaped = True
|
|
767
|
+
elif char == quote:
|
|
768
|
+
quote = None
|
|
769
|
+
continue
|
|
770
|
+
if char.isspace():
|
|
771
|
+
separator_pending = True
|
|
772
|
+
continue
|
|
773
|
+
if separator_pending and normalized:
|
|
774
|
+
normalized.append(" ")
|
|
775
|
+
separator_pending = False
|
|
776
|
+
normalized.append(char)
|
|
777
|
+
if char in {"'", '"'}:
|
|
778
|
+
quote = char
|
|
779
|
+
return "".join(normalized)
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _normalize_request(request: PermissionRequest) -> _NormalizedRequest:
|
|
783
|
+
if not isinstance(request, PermissionRequest):
|
|
784
|
+
raise PermissionPolicyValidationError("Permission request is invalid.")
|
|
785
|
+
tool_name = _validate_kind(request.tool_name)
|
|
786
|
+
selected_platform = _select_platform(
|
|
787
|
+
request.platform,
|
|
788
|
+
*(request.paths[:1]),
|
|
789
|
+
request.workspace_root,
|
|
790
|
+
)
|
|
791
|
+
normalized_paths: list[str] = []
|
|
792
|
+
detected_external = False
|
|
793
|
+
for path in request.paths:
|
|
794
|
+
normalized, external = _normalize_workspace_path(
|
|
795
|
+
path,
|
|
796
|
+
request.workspace_root,
|
|
797
|
+
selected_platform,
|
|
798
|
+
)
|
|
799
|
+
detected_external = detected_external or external
|
|
800
|
+
if normalized is not None:
|
|
801
|
+
normalized_paths.append(normalized)
|
|
802
|
+
command = _normalize_command_text(request.command) if request.command is not None else None
|
|
803
|
+
if command is not None and selected_platform == "windows":
|
|
804
|
+
command = command.casefold()
|
|
805
|
+
return _NormalizedRequest(
|
|
806
|
+
tool_name=tool_name,
|
|
807
|
+
paths=tuple(normalized_paths),
|
|
808
|
+
command=command,
|
|
809
|
+
platform=selected_platform,
|
|
810
|
+
sensitive=bool(request.sensitive),
|
|
811
|
+
external_directory=bool(request.external_directory or detected_external),
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _select_platform(
|
|
816
|
+
platform: Literal["auto", "windows", "posix"], *values: str | None
|
|
817
|
+
) -> Literal["windows", "posix"]:
|
|
818
|
+
if platform in {"windows", "posix"}:
|
|
819
|
+
return platform
|
|
820
|
+
if platform != "auto":
|
|
821
|
+
raise PermissionPolicyValidationError("Permission request platform is invalid.")
|
|
822
|
+
if any(value and _WINDOWS_ABSOLUTE_RE.match(value) for value in values):
|
|
823
|
+
return "windows"
|
|
824
|
+
return "windows" if os.name == "nt" else "posix"
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _normalize_workspace_path(
|
|
828
|
+
value: str,
|
|
829
|
+
workspace_root: str | None,
|
|
830
|
+
platform: Literal["windows", "posix"],
|
|
831
|
+
) -> tuple[str | None, bool]:
|
|
832
|
+
if not isinstance(value, str) or not value.strip() or "\x00" in value:
|
|
833
|
+
raise PermissionPolicyValidationError("Permission request path is invalid.")
|
|
834
|
+
if platform == "windows":
|
|
835
|
+
return _normalize_windows_workspace_path(value, workspace_root)
|
|
836
|
+
return _normalize_posix_workspace_path(value, workspace_root)
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _normalize_windows_workspace_path(
|
|
840
|
+
value: str, workspace_root: str | None
|
|
841
|
+
) -> tuple[str | None, bool]:
|
|
842
|
+
raw = value.strip().replace("/", "\\")
|
|
843
|
+
is_absolute = bool(_WINDOWS_ABSOLUTE_RE.match(raw))
|
|
844
|
+
if is_absolute:
|
|
845
|
+
if not workspace_root or not _WINDOWS_ABSOLUTE_RE.match(workspace_root):
|
|
846
|
+
return None, True
|
|
847
|
+
try:
|
|
848
|
+
relative = ntpath.relpath(ntpath.normpath(raw), ntpath.normpath(workspace_root))
|
|
849
|
+
except ValueError:
|
|
850
|
+
return None, True
|
|
851
|
+
if relative == ".." or relative.startswith(f"..{ntpath.sep}") or ntpath.isabs(relative):
|
|
852
|
+
return None, True
|
|
853
|
+
raw = relative
|
|
854
|
+
normalized = ntpath.normpath(raw).replace("\\", "/")
|
|
855
|
+
if normalized in {"", "."}:
|
|
856
|
+
return ".", False
|
|
857
|
+
if normalized == ".." or normalized.startswith("../") or _WINDOWS_ABSOLUTE_RE.match(normalized):
|
|
858
|
+
return None, True
|
|
859
|
+
return normalized.casefold(), False
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _normalize_posix_workspace_path(
|
|
863
|
+
value: str, workspace_root: str | None
|
|
864
|
+
) -> tuple[str | None, bool]:
|
|
865
|
+
raw = value.strip().replace("\\", "/")
|
|
866
|
+
if posixpath.isabs(raw):
|
|
867
|
+
if not workspace_root or not posixpath.isabs(workspace_root.replace("\\", "/")):
|
|
868
|
+
return None, True
|
|
869
|
+
root = posixpath.normpath(workspace_root.replace("\\", "/"))
|
|
870
|
+
normalized_absolute = posixpath.normpath(raw)
|
|
871
|
+
try:
|
|
872
|
+
if posixpath.commonpath((root, normalized_absolute)) != root:
|
|
873
|
+
return None, True
|
|
874
|
+
except ValueError:
|
|
875
|
+
return None, True
|
|
876
|
+
raw = posixpath.relpath(normalized_absolute, root)
|
|
877
|
+
normalized = posixpath.normpath(raw)
|
|
878
|
+
if normalized in {"", "."}:
|
|
879
|
+
return ".", False
|
|
880
|
+
if normalized == ".." or normalized.startswith("../") or posixpath.isabs(normalized):
|
|
881
|
+
return None, True
|
|
882
|
+
return normalized, False
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def _rule_matches(rule: PermissionRule, request: _NormalizedRequest) -> bool:
|
|
886
|
+
if rule.tool_pattern is not None and not fnmatch.fnmatchcase(
|
|
887
|
+
request.tool_name, rule.tool_pattern
|
|
888
|
+
):
|
|
889
|
+
return False
|
|
890
|
+
if rule.command_pattern is not None:
|
|
891
|
+
if request.command is None:
|
|
892
|
+
return False
|
|
893
|
+
pattern = normalize_command_pattern(rule.command_pattern, platform=request.platform)
|
|
894
|
+
if not fnmatch.fnmatchcase(request.command, pattern):
|
|
895
|
+
return False
|
|
896
|
+
if rule.path_pattern is not None:
|
|
897
|
+
if not request.paths:
|
|
898
|
+
return False
|
|
899
|
+
pattern = (
|
|
900
|
+
rule.path_pattern.casefold() if request.platform == "windows" else rule.path_pattern
|
|
901
|
+
)
|
|
902
|
+
matches = [_path_glob_matches(path, pattern) for path in request.paths]
|
|
903
|
+
# A deny/ask guard protects any matching target; an allow must cover every target.
|
|
904
|
+
if rule.effect is PolicyEffect.ALLOW:
|
|
905
|
+
return all(matches)
|
|
906
|
+
return any(matches)
|
|
907
|
+
return True
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _path_glob_matches(path: str, pattern: str) -> bool:
|
|
911
|
+
regex_parts: list[str] = ["^"]
|
|
912
|
+
index = 0
|
|
913
|
+
while index < len(pattern):
|
|
914
|
+
char = pattern[index]
|
|
915
|
+
if char == "*" and index + 1 < len(pattern) and pattern[index + 1] == "*":
|
|
916
|
+
if index + 2 < len(pattern) and pattern[index + 2] == "/":
|
|
917
|
+
regex_parts.append("(?:.*/)?")
|
|
918
|
+
index += 3
|
|
919
|
+
continue
|
|
920
|
+
if index > 0 and pattern[index - 1] == "/" and index + 2 == len(pattern):
|
|
921
|
+
regex_parts[-1] = "(?:/.*)?"
|
|
922
|
+
index += 2
|
|
923
|
+
continue
|
|
924
|
+
regex_parts.append(".*")
|
|
925
|
+
index += 2
|
|
926
|
+
continue
|
|
927
|
+
if char == "*":
|
|
928
|
+
regex_parts.append("[^/]*")
|
|
929
|
+
elif char == "?":
|
|
930
|
+
regex_parts.append("[^/]")
|
|
931
|
+
else:
|
|
932
|
+
regex_parts.append(re.escape(char))
|
|
933
|
+
index += 1
|
|
934
|
+
regex_parts.append("$")
|
|
935
|
+
return re.fullmatch("".join(regex_parts), path) is not None
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _rule_specificity(rule: PermissionRule) -> int:
|
|
939
|
+
patterns = tuple(
|
|
940
|
+
pattern
|
|
941
|
+
for pattern in (rule.tool_pattern, rule.path_pattern, rule.command_pattern)
|
|
942
|
+
if pattern is not None
|
|
943
|
+
)
|
|
944
|
+
selector_count = len(patterns)
|
|
945
|
+
exact_count = sum(not any(char in pattern for char in "*?") for pattern in patterns)
|
|
946
|
+
literal_count = sum(sum(char not in "*?" for char in pattern) for pattern in patterns)
|
|
947
|
+
wildcard_count = sum(sum(char in "*?" for char in pattern) for pattern in patterns)
|
|
948
|
+
return (
|
|
949
|
+
selector_count * 1_000_000
|
|
950
|
+
+ exact_count * 100_000
|
|
951
|
+
+ min(literal_count, 9_999) * 10
|
|
952
|
+
- min(wildcard_count, 9)
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _validated_scope_fingerprint(scope: Mapping[str, Any]) -> tuple[str, str]:
|
|
957
|
+
if not isinstance(scope, Mapping):
|
|
958
|
+
raise PermissionPolicyValidationError("Approval session scope is invalid.")
|
|
959
|
+
try:
|
|
960
|
+
copied = dict(scope)
|
|
961
|
+
scope_type = str(copied.get("type") or "")
|
|
962
|
+
if scope_type not in SUPPORTED_APPROVAL_SCOPES:
|
|
963
|
+
raise ValueError
|
|
964
|
+
if scope_type == "exact_command_hash":
|
|
965
|
+
if _SHA256_RE.fullmatch(str(copied.get("command_hash") or "")) is None:
|
|
966
|
+
raise ValueError
|
|
967
|
+
elif scope_type == "exact_file_set":
|
|
968
|
+
if _SHA256_RE.fullmatch(str(copied.get("files_hash") or "")) is None:
|
|
969
|
+
raise ValueError
|
|
970
|
+
files = copied.get("files")
|
|
971
|
+
if not isinstance(files, list) or not files:
|
|
972
|
+
raise ValueError
|
|
973
|
+
elif scope_type == "exact_verify_command_set":
|
|
974
|
+
if _SHA256_RE.fullmatch(str(copied.get("commands_hash") or "")) is None:
|
|
975
|
+
raise ValueError
|
|
976
|
+
else:
|
|
977
|
+
if copied.get("backend_owned") is not True or not str(copied.get("safe_kind") or ""):
|
|
978
|
+
raise ValueError
|
|
979
|
+
encoded = json.dumps(
|
|
980
|
+
copied,
|
|
981
|
+
ensure_ascii=True,
|
|
982
|
+
sort_keys=True,
|
|
983
|
+
separators=(",", ":"),
|
|
984
|
+
).encode("utf-8")
|
|
985
|
+
except (TypeError, ValueError):
|
|
986
|
+
raise PermissionPolicyValidationError("Approval session scope is invalid.") from None
|
|
987
|
+
return scope_type, hashlib.sha256(encoded).hexdigest()
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def _session_safety_evaluation(reason: str, rule_id: str) -> PermissionEvaluation:
|
|
991
|
+
return PermissionEvaluation(
|
|
992
|
+
decision=PolicyEffect.ASK,
|
|
993
|
+
reason=reason,
|
|
994
|
+
matched_rule_id=rule_id,
|
|
995
|
+
matched_rule_source="builtin_safety",
|
|
996
|
+
specificity=2_147_483_647,
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
__all__ = [
|
|
1001
|
+
"InMemorySessionGrantAdapter",
|
|
1002
|
+
"PERMISSION_POLICY_SCHEMA_VERSION",
|
|
1003
|
+
"PermissionEvaluation",
|
|
1004
|
+
"PermissionPolicy",
|
|
1005
|
+
"PermissionPolicyCorruptError",
|
|
1006
|
+
"PermissionPolicyError",
|
|
1007
|
+
"PermissionPolicyStore",
|
|
1008
|
+
"PermissionPolicyValidationError",
|
|
1009
|
+
"PermissionRequest",
|
|
1010
|
+
"PermissionRule",
|
|
1011
|
+
"PolicyEffect",
|
|
1012
|
+
"SessionPermissionGrant",
|
|
1013
|
+
"normalize_command_pattern",
|
|
1014
|
+
"normalize_path_pattern",
|
|
1015
|
+
"normalize_workspace_path",
|
|
1016
|
+
]
|