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,315 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
from .error_text import sanitize_error_text_for_output
|
|
10
|
+
|
|
11
|
+
_NETWORK_MODEL_SIGNALS = (
|
|
12
|
+
"llm request failed",
|
|
13
|
+
"llm error",
|
|
14
|
+
"network",
|
|
15
|
+
"timeout",
|
|
16
|
+
"timed out",
|
|
17
|
+
"connection",
|
|
18
|
+
"dns",
|
|
19
|
+
"tls",
|
|
20
|
+
"certificate",
|
|
21
|
+
"api key",
|
|
22
|
+
"unauthorized",
|
|
23
|
+
"forbidden",
|
|
24
|
+
"rate limit",
|
|
25
|
+
"too many requests",
|
|
26
|
+
"base_url",
|
|
27
|
+
"can't reach",
|
|
28
|
+
"chat/completions",
|
|
29
|
+
"model provider",
|
|
30
|
+
"provider rejected",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
_TOOL_TRANSCRIPT_SIGNAL_GROUPS = (
|
|
34
|
+
(
|
|
35
|
+
"assistant message with 'tool_calls' must be followed by tool messages",
|
|
36
|
+
'assistant message with "tool_calls" must be followed by tool messages',
|
|
37
|
+
),
|
|
38
|
+
(
|
|
39
|
+
"tool_call_id",
|
|
40
|
+
"did not have response messages",
|
|
41
|
+
),
|
|
42
|
+
(
|
|
43
|
+
"tool transcript",
|
|
44
|
+
"missing",
|
|
45
|
+
),
|
|
46
|
+
(
|
|
47
|
+
"tool transcript",
|
|
48
|
+
"malformed",
|
|
49
|
+
),
|
|
50
|
+
(
|
|
51
|
+
"tool transcript",
|
|
52
|
+
"incomplete",
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class LLMErrorDisplay:
|
|
59
|
+
title: str
|
|
60
|
+
guidance_lines: tuple[str, ...]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_tool_transcript_error(text: str) -> bool:
|
|
64
|
+
lowered = str(text or "").lower()
|
|
65
|
+
if not lowered:
|
|
66
|
+
return False
|
|
67
|
+
return any(all(token in lowered for token in group) for group in _TOOL_TRANSCRIPT_SIGNAL_GROUPS)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def is_network_or_model_error(text: str) -> bool:
|
|
71
|
+
lowered = str(text or "").lower()
|
|
72
|
+
if not lowered or is_tool_transcript_error(lowered):
|
|
73
|
+
return False
|
|
74
|
+
return any(token in lowered for token in _NETWORK_MODEL_SIGNALS)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def friendly_llm_error_message(error: Any) -> str:
|
|
78
|
+
"""Return user-facing provider error text without raw exception wrappers."""
|
|
79
|
+
try:
|
|
80
|
+
from .llm.openai_compat import alysis_trial_error_message
|
|
81
|
+
|
|
82
|
+
trial_message = alysis_trial_error_message(error)
|
|
83
|
+
except Exception: # noqa: BLE001
|
|
84
|
+
trial_message = None
|
|
85
|
+
if trial_message:
|
|
86
|
+
return sanitize_error_text_for_output(trial_message)
|
|
87
|
+
|
|
88
|
+
text = sanitize_error_text_for_output(error).strip()
|
|
89
|
+
if not text:
|
|
90
|
+
return "The model provider returned an empty error."
|
|
91
|
+
if is_tool_transcript_error(text):
|
|
92
|
+
return text
|
|
93
|
+
|
|
94
|
+
status = _llm_error_status_code(text)
|
|
95
|
+
detail = _provider_error_detail(text)
|
|
96
|
+
lowered = f"{text}\n{detail}".casefold()
|
|
97
|
+
url = _first_url(detail)
|
|
98
|
+
url_suffix = f" See: {url}" if url else ""
|
|
99
|
+
|
|
100
|
+
if status in {401, 403} or _looks_like_auth_error(lowered):
|
|
101
|
+
status_suffix = f" ({status})" if status is not None else ""
|
|
102
|
+
return f"Your API key was rejected{status_suffix}. Update it with /config.{url_suffix}"
|
|
103
|
+
|
|
104
|
+
if _looks_like_connection_error(error, lowered):
|
|
105
|
+
host = _provider_host_from_error(error) or _provider_host_from_text(text)
|
|
106
|
+
host_suffix = f" at {host}" if host else ""
|
|
107
|
+
return (
|
|
108
|
+
f"Can't reach the model provider{host_suffix}. "
|
|
109
|
+
"Check the profile base URL in /config and try again."
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if _looks_like_timeout_error(lowered):
|
|
113
|
+
return (
|
|
114
|
+
"The model provider did not respond before the request timed out. "
|
|
115
|
+
"Check the base URL/network, or increase the model timeout in /config."
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if "tool_choice" in lowered:
|
|
119
|
+
status_suffix = f" ({status})" if status is not None else ""
|
|
120
|
+
return (
|
|
121
|
+
f"The provider rejected the tool_choice parameter{status_suffix}. "
|
|
122
|
+
"Use a tool-compatible model/provider, or retry with provider compatibility enabled."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
unsupported_model = _unsupported_model_name(detail) or _unsupported_model_name(text)
|
|
126
|
+
if unsupported_model:
|
|
127
|
+
status_suffix = f" ({status})" if status is not None else ""
|
|
128
|
+
return (
|
|
129
|
+
f"The provider rejected model '{unsupported_model}' for this request"
|
|
130
|
+
f"{status_suffix}. Check the model id or provider profile in /config."
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if status == 429 or "rate limit" in lowered or "too many requests" in lowered:
|
|
134
|
+
return "The model provider is rate-limiting this session (429). Wait a moment and retry."
|
|
135
|
+
|
|
136
|
+
if status is not None and detail and detail != text:
|
|
137
|
+
return f"The model provider rejected the request ({status}): {_single_line(detail)}"
|
|
138
|
+
return text
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def classify_llm_error_display(text: str) -> LLMErrorDisplay:
|
|
142
|
+
# Transcript integrity errors often arrive inside a generic "LLM error 400" wrapper,
|
|
143
|
+
# so classify them before broader network/model guidance.
|
|
144
|
+
if is_tool_transcript_error(text):
|
|
145
|
+
return LLMErrorDisplay(
|
|
146
|
+
title="Tool Transcript Error",
|
|
147
|
+
guidance_lines=(
|
|
148
|
+
"This session's tool transcript is incomplete or malformed.",
|
|
149
|
+
"Retry in a new session. If it repeats, inspect resume/compaction around missing tool responses.",
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
if is_network_or_model_error(text):
|
|
153
|
+
return LLMErrorDisplay(
|
|
154
|
+
title="Network/Model Error",
|
|
155
|
+
guidance_lines=(
|
|
156
|
+
"Check base URL, API key, and network connectivity.",
|
|
157
|
+
"Retry when ready. Use /status for current session settings.",
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
return LLMErrorDisplay(title="LLM Error", guidance_lines=())
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _llm_error_status_code(text: str) -> int | None:
|
|
164
|
+
match = re.search(r"\bLLM\s+error\s+(\d{3})\b", str(text or ""), re.IGNORECASE)
|
|
165
|
+
if match is None:
|
|
166
|
+
return None
|
|
167
|
+
try:
|
|
168
|
+
return int(match.group(1))
|
|
169
|
+
except ValueError:
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _llm_error_body(text: str) -> str:
|
|
174
|
+
raw = str(text or "").strip()
|
|
175
|
+
match = re.search(r"\bLLM\s+error\s+\d{3}\s*:\s*", raw, re.IGNORECASE)
|
|
176
|
+
if match is None:
|
|
177
|
+
return raw
|
|
178
|
+
return raw[match.end() :].strip()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _json_error_payload(text: str) -> dict[str, Any]:
|
|
182
|
+
try:
|
|
183
|
+
payload = json.loads(str(text or "").strip())
|
|
184
|
+
except json.JSONDecodeError:
|
|
185
|
+
return {}
|
|
186
|
+
return payload if isinstance(payload, dict) else {}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _provider_error_detail(text: str) -> str:
|
|
190
|
+
body = _llm_error_body(text)
|
|
191
|
+
payload = _json_error_payload(body)
|
|
192
|
+
if not payload:
|
|
193
|
+
return _single_line(body)
|
|
194
|
+
raw_error = payload.get("error")
|
|
195
|
+
error = raw_error if isinstance(raw_error, dict) else payload
|
|
196
|
+
message = error.get("message") if isinstance(error, dict) else None
|
|
197
|
+
if isinstance(message, str) and message.strip():
|
|
198
|
+
return _single_line(message)
|
|
199
|
+
code = error.get("code") if isinstance(error, dict) else None
|
|
200
|
+
if isinstance(code, str) and code.strip():
|
|
201
|
+
return _single_line(code)
|
|
202
|
+
return _single_line(body)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _single_line(text: str) -> str:
|
|
206
|
+
return re.sub(r"\s+", " ", str(text or "").strip())
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _first_url(text: str) -> str | None:
|
|
210
|
+
match = re.search(r"https?://[^\s)>\]}\"']+", str(text or ""))
|
|
211
|
+
if match is None:
|
|
212
|
+
return None
|
|
213
|
+
return match.group(0).rstrip(".,;:")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _looks_like_auth_error(lowered: str) -> bool:
|
|
217
|
+
return any(
|
|
218
|
+
token in lowered
|
|
219
|
+
for token in (
|
|
220
|
+
"api key",
|
|
221
|
+
"invalid_api_key",
|
|
222
|
+
"incorrect api key",
|
|
223
|
+
"unauthorized",
|
|
224
|
+
"authentication",
|
|
225
|
+
"forbidden",
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _looks_like_connection_error(error: Any, lowered: str) -> bool:
|
|
231
|
+
for exc in _exception_chain(error):
|
|
232
|
+
type_name = type(exc).__name__.casefold()
|
|
233
|
+
if type_name in {"connecterror", "connecttimeout"}:
|
|
234
|
+
return True
|
|
235
|
+
return any(
|
|
236
|
+
token in lowered
|
|
237
|
+
for token in (
|
|
238
|
+
"connection refused",
|
|
239
|
+
"connect error",
|
|
240
|
+
"connect timeout",
|
|
241
|
+
"connecttimeout",
|
|
242
|
+
"dns",
|
|
243
|
+
"name resolution",
|
|
244
|
+
"network is unreachable",
|
|
245
|
+
"temporary failure in name resolution",
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _looks_like_timeout_error(lowered: str) -> bool:
|
|
251
|
+
return any(
|
|
252
|
+
token in lowered
|
|
253
|
+
for token in (
|
|
254
|
+
"read timeout",
|
|
255
|
+
"readtimeout",
|
|
256
|
+
"timed out",
|
|
257
|
+
"timeout",
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _provider_host_from_error(error: Any) -> str | None:
|
|
263
|
+
for exc in _exception_chain(error):
|
|
264
|
+
request = getattr(exc, "request", None)
|
|
265
|
+
url = getattr(request, "url", None)
|
|
266
|
+
host = getattr(url, "host", None)
|
|
267
|
+
if isinstance(host, str) and host:
|
|
268
|
+
port = getattr(url, "port", None)
|
|
269
|
+
return f"{host}:{port}" if port is not None else host
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _provider_host_from_text(text: str) -> str | None:
|
|
274
|
+
safe_label = re.search(
|
|
275
|
+
r"(?P<host>(?:\[[0-9a-f:]+\]|[A-Za-z0-9.-]+)(?::\d+)?)\s+"
|
|
276
|
+
r"\(endpoint\s+[0-9a-f]{12}\)",
|
|
277
|
+
str(text or ""),
|
|
278
|
+
re.IGNORECASE,
|
|
279
|
+
)
|
|
280
|
+
if safe_label is not None:
|
|
281
|
+
return safe_label.group("host")
|
|
282
|
+
url = _first_url(text)
|
|
283
|
+
if not url:
|
|
284
|
+
return None
|
|
285
|
+
try:
|
|
286
|
+
parsed = urlparse(url)
|
|
287
|
+
except ValueError:
|
|
288
|
+
return None
|
|
289
|
+
if not parsed.hostname:
|
|
290
|
+
return None
|
|
291
|
+
return f"{parsed.hostname}:{parsed.port}" if parsed.port is not None else parsed.hostname
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _exception_chain(error: Any) -> list[Any]:
|
|
295
|
+
chain: list[Any] = []
|
|
296
|
+
seen: set[int] = set()
|
|
297
|
+
current = error
|
|
298
|
+
while current is not None and id(current) not in seen:
|
|
299
|
+
seen.add(id(current))
|
|
300
|
+
chain.append(current)
|
|
301
|
+
cause = getattr(current, "__cause__", None)
|
|
302
|
+
context = getattr(current, "__context__", None)
|
|
303
|
+
current = cause if cause is not None else context
|
|
304
|
+
return chain
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _unsupported_model_name(text: str) -> str | None:
|
|
308
|
+
match = re.search(
|
|
309
|
+
r"(?:not\s+supported\s+model|unsupported\s+model|model\s+not\s+supported)\s+['\"]?([^'\"\s,.;}]+)",
|
|
310
|
+
str(text or ""),
|
|
311
|
+
re.IGNORECASE,
|
|
312
|
+
)
|
|
313
|
+
if match is None:
|
|
314
|
+
return None
|
|
315
|
+
return match.group(1).strip()
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Write-path secret redaction for Alysis Code log and telemetry sinks.
|
|
2
|
+
|
|
3
|
+
Background: a live ``ALYSIS_API_KEY`` value once reached a session JSONL file
|
|
4
|
+
in cleartext. A tool call dumped the process environment and the tool output was
|
|
5
|
+
logged verbatim. An archive-time scanner caught it after the fact, but by then
|
|
6
|
+
the credential had already been written to disk. This module closes that gap at
|
|
7
|
+
the write boundary: each Alysis Code log/telemetry sink funnels its final
|
|
8
|
+
serialized string through :func:`redact_log_text` immediately before the write,
|
|
9
|
+
so a captured secret value cannot reach disk in the first place.
|
|
10
|
+
|
|
11
|
+
The archive-time scanner in ``feedback_report`` is deliberately left untouched
|
|
12
|
+
and remains the second, independent layer.
|
|
13
|
+
|
|
14
|
+
Design constraints:
|
|
15
|
+
|
|
16
|
+
* Stdlib only, with no intra-package imports. Every sink module must be able to
|
|
17
|
+
import this one without creating a cycle and without dragging in heavy
|
|
18
|
+
third-party dependencies (httpx, pydantic, rich).
|
|
19
|
+
* Deterministic and idempotent: ``redact(redact(t)) == redact(t)``.
|
|
20
|
+
* Fast: one precompiled alternation covers every known spelling of every
|
|
21
|
+
captured value, and one further combined pass covers risky assignment
|
|
22
|
+
patterns, so a line costs two regex scans regardless of how many secrets are
|
|
23
|
+
known. Run ``python -m alysis_code.logging_redaction`` for a
|
|
24
|
+
micro-benchmark.
|
|
25
|
+
|
|
26
|
+
What is captured
|
|
27
|
+
----------------
|
|
28
|
+
At construction the redactor snapshots the *values* of environment variables
|
|
29
|
+
whose names end in ``_API_KEY``, ``_TOKEN``, ``_SECRET`` or ``_PASSWORD`` (or
|
|
30
|
+
that are exactly ``API_KEY``/``TOKEN``/``SECRET``/``PASSWORD``), plus any
|
|
31
|
+
``ALYSIS_*`` variable whose value looks credential-like. Values shorter than
|
|
32
|
+
``MIN_VALUE_LENGTH`` are ignored: they collide with ordinary log prose far too
|
|
33
|
+
often to be replaced safely.
|
|
34
|
+
|
|
35
|
+
Known limitations (deliberate)
|
|
36
|
+
------------------------------
|
|
37
|
+
* Base64 is matched only for the exact UTF-8 bytes of the value at alignment 0
|
|
38
|
+
(standard and URL-safe, padded and unpadded). A secret base64-encoded as part
|
|
39
|
+
of a *larger* blob lands at a shifted alignment and is not detected; the
|
|
40
|
+
archive-time scanner remains the backstop for that case.
|
|
41
|
+
* A secret deliberately split across two fields (for example the first half in
|
|
42
|
+
one JSON value and the second half in another) is not reassembled. Each half
|
|
43
|
+
is redacted only if it independently matches a captured value.
|
|
44
|
+
* The risky-pattern pass requires a high-entropy payload, so a low-entropy
|
|
45
|
+
placeholder such as ``password=aaaaaaaaaaaaaaaa`` is left alone on purpose.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import base64
|
|
51
|
+
import json
|
|
52
|
+
import math
|
|
53
|
+
import os
|
|
54
|
+
import re
|
|
55
|
+
import threading
|
|
56
|
+
from collections import Counter
|
|
57
|
+
from collections.abc import Mapping
|
|
58
|
+
from urllib.parse import quote, quote_plus
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
"MIN_CREDENTIAL_LENGTH",
|
|
62
|
+
"MIN_VALUE_LENGTH",
|
|
63
|
+
"SecretRedactor",
|
|
64
|
+
"default_redactor",
|
|
65
|
+
"redact_log_text",
|
|
66
|
+
"reset_default_redactor",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
# Guillemets keep the marker visually distinct and, more importantly, outside the
|
|
70
|
+
# payload character classes below, which is what makes redaction idempotent by
|
|
71
|
+
# construction rather than by convention.
|
|
72
|
+
_PLACEHOLDER_PREFIX = "«redacted:"
|
|
73
|
+
_PLACEHOLDER_SUFFIX = "»"
|
|
74
|
+
_PATTERN_PLACEHOLDER = f"{_PLACEHOLDER_PREFIX}pattern{_PLACEHOLDER_SUFFIX}"
|
|
75
|
+
|
|
76
|
+
#: Values shorter than this are never captured: too collision-prone.
|
|
77
|
+
MIN_VALUE_LENGTH = 8
|
|
78
|
+
#: Length floor for the ``ALYSIS_*`` "looks like a credential" heuristic.
|
|
79
|
+
MIN_CREDENTIAL_LENGTH = 16
|
|
80
|
+
#: Shannon entropy floor, in bits per character.
|
|
81
|
+
MIN_ENTROPY_BITS_PER_CHAR = 3.0
|
|
82
|
+
#: Entropy floor for a payload found by the risky-pattern pass.
|
|
83
|
+
MIN_PATTERN_ENTROPY_BITS_PER_CHAR = 3.0
|
|
84
|
+
|
|
85
|
+
_SECRET_NAME_SUFFIXES = ("_API_KEY", "_TOKEN", "_SECRET", "_PASSWORD")
|
|
86
|
+
_SECRET_NAME_EXACT = frozenset({"API_KEY", "TOKEN", "SECRET", "PASSWORD"})
|
|
87
|
+
_HEURISTIC_NAME_PREFIX = "ALYSIS_"
|
|
88
|
+
|
|
89
|
+
# Characters a credential may plausibly be made of. Excludes whitespace, quotes
|
|
90
|
+
# and braces so that a JSON blob or an English sentence can never be mistaken
|
|
91
|
+
# for one value.
|
|
92
|
+
_TOKEN_CHARSET_RE = re.compile(r"[A-Za-z0-9._~+/=:-]+")
|
|
93
|
+
_PATHISH_RE = re.compile(r"(?:\A[./~]|\A[A-Za-z]:[\\/]|://)")
|
|
94
|
+
_HEXISH_RE = re.compile(r"[0-9a-fA-F]+")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _shannon_entropy_bits_per_char(value: str) -> float:
|
|
98
|
+
"""Return Shannon entropy of ``value`` in bits per character."""
|
|
99
|
+
if not value:
|
|
100
|
+
return 0.0
|
|
101
|
+
total = len(value)
|
|
102
|
+
return -sum((count / total) * math.log2(count / total) for count in Counter(value).values())
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _is_named_secret(name: str) -> bool:
|
|
106
|
+
upper = name.upper()
|
|
107
|
+
return upper in _SECRET_NAME_EXACT or upper.endswith(_SECRET_NAME_SUFFIXES)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _looks_credential_like(value: str) -> bool:
|
|
111
|
+
"""Heuristic for ``ALYSIS_*`` values whose name does not give them away.
|
|
112
|
+
|
|
113
|
+
Deliberately conservative about *shape* rather than length alone, so that
|
|
114
|
+
ordinary configuration such as a model id or a file path is not swept up.
|
|
115
|
+
"""
|
|
116
|
+
if len(value) < MIN_CREDENTIAL_LENGTH:
|
|
117
|
+
return False
|
|
118
|
+
if not _TOKEN_CHARSET_RE.fullmatch(value):
|
|
119
|
+
return False
|
|
120
|
+
if _PATHISH_RE.search(value):
|
|
121
|
+
return False
|
|
122
|
+
if _shannon_entropy_bits_per_char(value) < MIN_ENTROPY_BITS_PER_CHAR:
|
|
123
|
+
return False
|
|
124
|
+
has_lower = any(char.islower() for char in value)
|
|
125
|
+
has_upper = any(char.isupper() for char in value)
|
|
126
|
+
has_digit = any(char.isdigit() for char in value)
|
|
127
|
+
if has_lower and has_upper and has_digit:
|
|
128
|
+
return True
|
|
129
|
+
# Long single-case hex digests are credential-shaped even though they only
|
|
130
|
+
# use two character classes.
|
|
131
|
+
return len(value) >= 32 and _HEXISH_RE.fullmatch(value) is not None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _value_variants(value: str) -> list[str]:
|
|
135
|
+
"""Every spelling of ``value`` that could plausibly be written to a log."""
|
|
136
|
+
variants = {value}
|
|
137
|
+
# JSON string escaping. Sinks serialize with ensure_ascii=True, but cover
|
|
138
|
+
# both settings so a non-ASCII secret is caught either way.
|
|
139
|
+
for ensure_ascii in (True, False):
|
|
140
|
+
variants.add(json.dumps(value, ensure_ascii=ensure_ascii)[1:-1])
|
|
141
|
+
# Percent-encoding, as seen in URLs and form bodies.
|
|
142
|
+
variants.add(quote(value, safe=""))
|
|
143
|
+
variants.add(quote_plus(value, safe=""))
|
|
144
|
+
# Base64 of the exact bytes at alignment 0 (see module docstring).
|
|
145
|
+
raw = value.encode("utf-8")
|
|
146
|
+
for encoded in (base64.b64encode(raw), base64.urlsafe_b64encode(raw)):
|
|
147
|
+
text = encoded.decode("ascii")
|
|
148
|
+
variants.add(text)
|
|
149
|
+
variants.add(text.rstrip("="))
|
|
150
|
+
return [variant for variant in variants if len(variant) >= MIN_VALUE_LENGTH]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# Assignment-style and Authorization-header patterns whose payload is long and
|
|
154
|
+
# high-entropy. The payload class excludes the guillemets used by the
|
|
155
|
+
# placeholders, so an already-redacted string can never be re-matched.
|
|
156
|
+
_RISKY_PATTERN_RE = re.compile(
|
|
157
|
+
r"""
|
|
158
|
+
(?:
|
|
159
|
+
\b(?:
|
|
160
|
+
api[-_]?key | access[-_]?key | secret[-_]?key | client[-_]?secret
|
|
161
|
+
| auth[-_]?token | access[-_]?token | refresh[-_]?token | api[-_]?token
|
|
162
|
+
| authorization | token | secret | password | passwd
|
|
163
|
+
)\b
|
|
164
|
+
\s* ["']? \s* [:=] \s* ["']? \s*
|
|
165
|
+
(?: (?: bearer | basic | token ) \s+ )?
|
|
166
|
+
(?P<assigned> [A-Za-z0-9._~+/=-]{16,})
|
|
167
|
+
|
|
|
168
|
+
\b bearer \s+ (?P<bearer> [A-Za-z0-9._~+/=-]{16,})
|
|
169
|
+
)
|
|
170
|
+
""",
|
|
171
|
+
re.VERBOSE | re.IGNORECASE,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _replace_risky(match: re.Match) -> str:
|
|
176
|
+
assigned = match.group("assigned")
|
|
177
|
+
if assigned is not None:
|
|
178
|
+
payload, payload_start = assigned, match.start("assigned")
|
|
179
|
+
else:
|
|
180
|
+
payload, payload_start = match.group("bearer"), match.start("bearer")
|
|
181
|
+
if payload.startswith(_PLACEHOLDER_PREFIX):
|
|
182
|
+
return match.group(0)
|
|
183
|
+
if _shannon_entropy_bits_per_char(payload) < MIN_PATTERN_ENTROPY_BITS_PER_CHAR:
|
|
184
|
+
# A low-entropy payload is almost always a placeholder or an enum value;
|
|
185
|
+
# redacting it would only make real logs harder to read.
|
|
186
|
+
return match.group(0)
|
|
187
|
+
# The payload is always the tail of the match, so keeping everything before
|
|
188
|
+
# it preserves the surrounding syntax (including JSON quoting).
|
|
189
|
+
return match.group(0)[: payload_start - match.start()] + _PATTERN_PLACEHOLDER
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class SecretRedactor:
|
|
193
|
+
"""An immutable snapshot of the secret values known at construction time.
|
|
194
|
+
|
|
195
|
+
Pass ``environ`` explicitly to get a deterministic redactor in tests; the
|
|
196
|
+
default reads :data:`os.environ`.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
def __init__(self, environ: Mapping[str, str] | None = None) -> None:
|
|
200
|
+
source = os.environ if environ is None else environ
|
|
201
|
+
replacements: dict[str, str] = {}
|
|
202
|
+
names: list[str] = []
|
|
203
|
+
for name, value in sorted(source.items()):
|
|
204
|
+
if not isinstance(value, str) or len(value) < MIN_VALUE_LENGTH:
|
|
205
|
+
continue
|
|
206
|
+
if _is_named_secret(name):
|
|
207
|
+
pass
|
|
208
|
+
elif name.startswith(_HEURISTIC_NAME_PREFIX) and _looks_credential_like(value):
|
|
209
|
+
pass
|
|
210
|
+
else:
|
|
211
|
+
continue
|
|
212
|
+
names.append(name)
|
|
213
|
+
placeholder = f"{_PLACEHOLDER_PREFIX}{name}{_PLACEHOLDER_SUFFIX}"
|
|
214
|
+
for variant in _value_variants(value):
|
|
215
|
+
# Iteration is sorted by name, so if two variables share a value
|
|
216
|
+
# the alphabetically-first name wins. Deterministic either way.
|
|
217
|
+
replacements.setdefault(variant, placeholder)
|
|
218
|
+
self.secret_names: tuple[str, ...] = tuple(names)
|
|
219
|
+
self._replacements = replacements
|
|
220
|
+
self._exact_re = self._compile(replacements)
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
def _compile(replacements: Mapping[str, str]) -> re.Pattern | None:
|
|
224
|
+
if not replacements:
|
|
225
|
+
return None
|
|
226
|
+
# Longest first, so that when one variant is a prefix of another the
|
|
227
|
+
# longer (more specific) spelling wins at any given position.
|
|
228
|
+
ordered = sorted(replacements, key=lambda variant: (-len(variant), variant))
|
|
229
|
+
return re.compile("|".join(re.escape(variant) for variant in ordered))
|
|
230
|
+
|
|
231
|
+
def _replace_exact(self, match: re.Match) -> str:
|
|
232
|
+
return self._replacements[match.group(0)]
|
|
233
|
+
|
|
234
|
+
def redact(self, text: str) -> str:
|
|
235
|
+
"""Return ``text`` with every known secret value and risky payload masked."""
|
|
236
|
+
if not text:
|
|
237
|
+
return text
|
|
238
|
+
if self._exact_re is not None:
|
|
239
|
+
text = self._exact_re.sub(self._replace_exact, text)
|
|
240
|
+
return _RISKY_PATTERN_RE.sub(_replace_risky, text)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
_DEFAULT_LOCK = threading.Lock()
|
|
244
|
+
_DEFAULT_REDACTOR: SecretRedactor | None = None
|
|
245
|
+
_DEFAULT_SIGNATURE: tuple[tuple[str, str], ...] | None = None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _environ_signature(source: Mapping[str, str]) -> tuple[tuple[str, str], ...]:
|
|
249
|
+
"""Cheap fingerprint of the secret-bearing part of the environment."""
|
|
250
|
+
matches = [
|
|
251
|
+
(name, value)
|
|
252
|
+
for name, value in source.items()
|
|
253
|
+
if isinstance(value, str)
|
|
254
|
+
and len(value) >= MIN_VALUE_LENGTH
|
|
255
|
+
and (_is_named_secret(name) or name.startswith(_HEURISTIC_NAME_PREFIX))
|
|
256
|
+
]
|
|
257
|
+
matches.sort()
|
|
258
|
+
return tuple(matches)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def default_redactor() -> SecretRedactor:
|
|
262
|
+
"""Return the process-wide redactor, rebuilding it if the environment moved.
|
|
263
|
+
|
|
264
|
+
Credentials are often loaded into the environment after import, so a
|
|
265
|
+
build-once cache would miss exactly the value that matters. Comparing a
|
|
266
|
+
cheap signature keeps the compiled regex hot while staying correct.
|
|
267
|
+
"""
|
|
268
|
+
global _DEFAULT_REDACTOR, _DEFAULT_SIGNATURE
|
|
269
|
+
signature = _environ_signature(os.environ)
|
|
270
|
+
with _DEFAULT_LOCK:
|
|
271
|
+
if _DEFAULT_REDACTOR is None or signature != _DEFAULT_SIGNATURE:
|
|
272
|
+
_DEFAULT_REDACTOR = SecretRedactor()
|
|
273
|
+
_DEFAULT_SIGNATURE = signature
|
|
274
|
+
return _DEFAULT_REDACTOR
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def reset_default_redactor() -> None:
|
|
278
|
+
"""Drop the cached redactor. Intended for tests."""
|
|
279
|
+
global _DEFAULT_REDACTOR, _DEFAULT_SIGNATURE
|
|
280
|
+
with _DEFAULT_LOCK:
|
|
281
|
+
_DEFAULT_REDACTOR = None
|
|
282
|
+
_DEFAULT_SIGNATURE = None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def redact_log_text(text: str) -> str:
|
|
286
|
+
"""Redact ``text`` immediately before it is written to a log sink."""
|
|
287
|
+
if not text:
|
|
288
|
+
return text
|
|
289
|
+
return default_redactor().redact(text)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _benchmark(size_mb: float = 100.0) -> None:
|
|
293
|
+
"""Micro-benchmark the redaction budget: 100MB in well under a minute."""
|
|
294
|
+
import time
|
|
295
|
+
|
|
296
|
+
secret = "sk-syl-" + ("A7bQ9zX2mK4pL8vN" * 2)
|
|
297
|
+
environ = {"ALYSIS_API_KEY": secret, "PATH": "/usr/bin:/bin"}
|
|
298
|
+
redactor = SecretRedactor(environ)
|
|
299
|
+
block = (
|
|
300
|
+
"2026-08-21T10:00:00Z level=info msg=tool_result "
|
|
301
|
+
'payload={"stdout": "HOME=/root\\nSHELL=/bin/bash\\n"} '
|
|
302
|
+
f"ALYSIS_API_KEY={secret} "
|
|
303
|
+
"Authorization: Bearer q1W2e3R4t5Y6u7I8o9P0a1S2d3F4g5H6 "
|
|
304
|
+
"ordinary log prose that must survive untouched\n"
|
|
305
|
+
)
|
|
306
|
+
repeats = max(1, int((size_mb * 1024 * 1024) / len(block)))
|
|
307
|
+
text = block * repeats
|
|
308
|
+
actual_mb = len(text) / (1024 * 1024)
|
|
309
|
+
|
|
310
|
+
start = time.perf_counter()
|
|
311
|
+
result = redactor.redact(text)
|
|
312
|
+
elapsed = time.perf_counter() - start
|
|
313
|
+
|
|
314
|
+
print(f"input : {actual_mb:.1f} MB ({repeats} blocks)")
|
|
315
|
+
print(f"elapsed : {elapsed:.3f} s")
|
|
316
|
+
print(f"throughput : {actual_mb / elapsed:.1f} MB/s")
|
|
317
|
+
print(f"secret leaked: {secret in result}")
|
|
318
|
+
print(f"projected 100MB: {elapsed * (100.0 / actual_mb):.2f} s")
|
|
319
|
+
|
|
320
|
+
start = time.perf_counter()
|
|
321
|
+
again = redactor.redact(result)
|
|
322
|
+
print(f"idempotent : {again == result} (second pass {time.perf_counter() - start:.3f} s)")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
if __name__ == "__main__":
|
|
326
|
+
_benchmark()
|