iCode-TUI 0.29.1__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.
- chrys/__init__.py +16 -0
- chrys/app/__init__.py +3 -0
- chrys/app/acp/__init__.py +3 -0
- chrys/app/acp/bridge.py +664 -0
- chrys/app/acp/content.py +132 -0
- chrys/app/acp/doc/frontend-api.md +425 -0
- chrys/app/acp/history.py +585 -0
- chrys/app/acp/server.py +1796 -0
- chrys/app/acp/session_manager.py +1815 -0
- chrys/app/acp/tool_status.py +45 -0
- chrys/app/cli/__init__.py +3 -0
- chrys/app/cli/acp.py +178 -0
- chrys/app/cli/app.py +165 -0
- chrys/app/cli/approvals.py +101 -0
- chrys/app/cli/headless.py +223 -0
- chrys/app/cli/launch_cwd.py +42 -0
- chrys/app/cli/profiles.py +298 -0
- chrys/app/cli/progress.py +857 -0
- chrys/app/cli/run.py +425 -0
- chrys/app/cli/serve.py +1593 -0
- chrys/app/cli/tool_summary.py +225 -0
- chrys/app/cli/trajectory.py +201 -0
- chrys/app/cli/workflow.py +601 -0
- chrys/app/features/__init__.py +3 -0
- chrys/app/features/buddy/__init__.py +3 -0
- chrys/app/features/buddy/actions.py +68 -0
- chrys/app/features/buddy/animation.py +76 -0
- chrys/app/features/buddy/commands.py +196 -0
- chrys/app/features/buddy/docs/design.md +187 -0
- chrys/app/features/buddy/hatchery.py +80 -0
- chrys/app/features/buddy/lifecycle.py +41 -0
- chrys/app/features/buddy/model.py +265 -0
- chrys/app/features/buddy/pixel_renderer.py +113 -0
- chrys/app/features/buddy/pixel_sprites.py +279 -0
- chrys/app/features/buddy/portrait.py +76 -0
- chrys/app/features/buddy/progression.py +58 -0
- chrys/app/features/buddy/replies.py +191 -0
- chrys/app/features/buddy/sprites/alien.toml +70 -0
- chrys/app/features/buddy/sprites/axolotl.toml +71 -0
- chrys/app/features/buddy/sprites/bat.toml +69 -0
- chrys/app/features/buddy/sprites/bee.toml +69 -0
- chrys/app/features/buddy/sprites/cactus.toml +73 -0
- chrys/app/features/buddy/sprites/capybara.toml +70 -0
- chrys/app/features/buddy/sprites/cat.toml +69 -0
- chrys/app/features/buddy/sprites/crab.toml +70 -0
- chrys/app/features/buddy/sprites/dragon.toml +71 -0
- chrys/app/features/buddy/sprites/duck.toml +69 -0
- chrys/app/features/buddy/sprites/fox.toml +69 -0
- chrys/app/features/buddy/sprites/frog.toml +70 -0
- chrys/app/features/buddy/sprites/ghost.toml +70 -0
- chrys/app/features/buddy/sprites/goose.toml +69 -0
- chrys/app/features/buddy/sprites/jelly.toml +70 -0
- chrys/app/features/buddy/sprites/llama.toml +70 -0
- chrys/app/features/buddy/sprites/mushroom.toml +69 -0
- chrys/app/features/buddy/sprites/octopus.toml +70 -0
- chrys/app/features/buddy/sprites/owl.toml +70 -0
- chrys/app/features/buddy/sprites/panda.toml +71 -0
- chrys/app/features/buddy/sprites/penguin.toml +69 -0
- chrys/app/features/buddy/sprites/rabbit.toml +70 -0
- chrys/app/features/buddy/sprites/robot.toml +72 -0
- chrys/app/features/buddy/sprites/shark.toml +70 -0
- chrys/app/features/buddy/sprites/snail.toml +69 -0
- chrys/app/features/buddy/sprites/snake.toml +69 -0
- chrys/app/features/buddy/sprites/tabby.toml +69 -0
- chrys/app/features/buddy/sprites/turtle.toml +69 -0
- chrys/app/features/buddy/store.py +143 -0
- chrys/app/features/session_title/__init__.py +7 -0
- chrys/app/features/session_title/updater.py +582 -0
- chrys/app/installer.py +651 -0
- chrys/app/parsing.py +17 -0
- chrys/app/tui/__init__.py +3 -0
- chrys/app/tui/app.py +1287 -0
- chrys/app/tui/behaviors/__init__.py +3 -0
- chrys/app/tui/behaviors/click_outside_dismiss.py +90 -0
- chrys/app/tui/behaviors/insert_clipboard.py +73 -0
- chrys/app/tui/behaviors/right_click_copy.py +146 -0
- chrys/app/tui/binding_display.py +139 -0
- chrys/app/tui/buddy_messages.py +17 -0
- chrys/app/tui/buddy_reply.py +86 -0
- chrys/app/tui/chrys.tcss +549 -0
- chrys/app/tui/clipboard.py +109 -0
- chrys/app/tui/copy_messages.py +10 -0
- chrys/app/tui/i18n.py +230 -0
- chrys/app/tui/language.py +58 -0
- chrys/app/tui/notifications/__init__.py +12 -0
- chrys/app/tui/notifications/drivers.py +180 -0
- chrys/app/tui/notifications/service.py +126 -0
- chrys/app/tui/notifications/settings.py +141 -0
- chrys/app/tui/screens/__init__.py +3 -0
- chrys/app/tui/screens/agents/__init__.py +8 -0
- chrys/app/tui/screens/agents/agent_draft_store.py +937 -0
- chrys/app/tui/screens/agents/config.py +2142 -0
- chrys/app/tui/screens/agents/config.tcss +169 -0
- chrys/app/tui/screens/agents/panels/__init__.py +3 -0
- chrys/app/tui/screens/agents/panels/acp.py +1176 -0
- chrys/app/tui/screens/agents/panels/basic.py +497 -0
- chrys/app/tui/screens/agents/panels/compaction.py +231 -0
- chrys/app/tui/screens/agents/panels/config_card.py +218 -0
- chrys/app/tui/screens/agents/panels/instructions.py +91 -0
- chrys/app/tui/screens/agents/panels/markdown_report.py +76 -0
- chrys/app/tui/screens/agents/panels/mcp.py +2262 -0
- chrys/app/tui/screens/agents/panels/memory.py +684 -0
- chrys/app/tui/screens/agents/panels/path_entry.py +227 -0
- chrys/app/tui/screens/agents/panels/skills.py +806 -0
- chrys/app/tui/screens/agents/panels/subagents.py +652 -0
- chrys/app/tui/screens/agents/panels/tools.py +205 -0
- chrys/app/tui/screens/agents/picker.py +103 -0
- chrys/app/tui/screens/agents/skill_paths.py +38 -0
- chrys/app/tui/screens/agents/validation_messages.py +452 -0
- chrys/app/tui/screens/dialogs/__init__.py +35 -0
- chrys/app/tui/screens/dialogs/agent_load.py +834 -0
- chrys/app/tui/screens/dialogs/agent_load.tcss +78 -0
- chrys/app/tui/screens/dialogs/app_mode.py +55 -0
- chrys/app/tui/screens/dialogs/approval/__init__.py +15 -0
- chrys/app/tui/screens/dialogs/approval/approval.tcss +311 -0
- chrys/app/tui/screens/dialogs/approval/bodies/__init__.py +3 -0
- chrys/app/tui/screens/dialogs/approval/bodies/file_edit.py +210 -0
- chrys/app/tui/screens/dialogs/approval/bodies/sub_agent.py +82 -0
- chrys/app/tui/screens/dialogs/approval/body.py +110 -0
- chrys/app/tui/screens/dialogs/approval/dialog.py +565 -0
- chrys/app/tui/screens/dialogs/approval/mode.py +105 -0
- chrys/app/tui/screens/dialogs/ask_user.py +148 -0
- chrys/app/tui/screens/dialogs/ask_user.tcss +62 -0
- chrys/app/tui/screens/dialogs/base.py +84 -0
- chrys/app/tui/screens/dialogs/confirm.py +184 -0
- chrys/app/tui/screens/dialogs/confirm.tcss +46 -0
- chrys/app/tui/screens/dialogs/connection_test.py +183 -0
- chrys/app/tui/screens/dialogs/connection_test.tcss +92 -0
- chrys/app/tui/screens/dialogs/diagram.py +102 -0
- chrys/app/tui/screens/dialogs/diagram.tcss +24 -0
- chrys/app/tui/screens/dialogs/editor.py +378 -0
- chrys/app/tui/screens/dialogs/editor.tcss +132 -0
- chrys/app/tui/screens/dialogs/file_picker.py +582 -0
- chrys/app/tui/screens/dialogs/file_picker.tcss +78 -0
- chrys/app/tui/screens/dialogs/fork_session.py +219 -0
- chrys/app/tui/screens/dialogs/fork_session.tcss +75 -0
- chrys/app/tui/screens/dialogs/image_compression.py +102 -0
- chrys/app/tui/screens/dialogs/image_compression.tcss +28 -0
- chrys/app/tui/screens/dialogs/man_page.py +172 -0
- chrys/app/tui/screens/dialogs/man_page.tcss +39 -0
- chrys/app/tui/screens/dialogs/new_folder.py +277 -0
- chrys/app/tui/screens/dialogs/new_folder.tcss +51 -0
- chrys/app/tui/screens/dialogs/notice.tcss +14 -0
- chrys/app/tui/screens/dialogs/prompt_cache.py +144 -0
- chrys/app/tui/screens/dialogs/prompt_cache.tcss +38 -0
- chrys/app/tui/screens/dialogs/runtime_details.py +371 -0
- chrys/app/tui/screens/dialogs/runtime_details.tcss +49 -0
- chrys/app/tui/screens/dialogs/session_title.py +110 -0
- chrys/app/tui/screens/dialogs/session_title.tcss +43 -0
- chrys/app/tui/screens/dialogs/tool_view.py +207 -0
- chrys/app/tui/screens/dialogs/tool_view.tcss +43 -0
- chrys/app/tui/screens/dialogs/vision_unsupported.py +122 -0
- chrys/app/tui/screens/dialogs/vision_unsupported.tcss +35 -0
- chrys/app/tui/screens/dialogs/workflow_confirm.py +91 -0
- chrys/app/tui/screens/dialogs/workflow_confirm.tcss +17 -0
- chrys/app/tui/screens/dialogs/workflow_input.py +258 -0
- chrys/app/tui/screens/dialogs/workflow_input.tcss +73 -0
- chrys/app/tui/screens/dialogs/workflow_node.py +462 -0
- chrys/app/tui/screens/dialogs/workflow_node.tcss +41 -0
- chrys/app/tui/screens/dialogs/workflow_picker.py +46 -0
- chrys/app/tui/screens/dialogs/workflow_result.py +136 -0
- chrys/app/tui/screens/dialogs/workflow_result.tcss +14 -0
- chrys/app/tui/screens/diff/__init__.py +22 -0
- chrys/app/tui/screens/diff/rollback_modal.py +1381 -0
- chrys/app/tui/screens/diff/rollback_modal.tcss +248 -0
- chrys/app/tui/screens/diff/screen.py +1532 -0
- chrys/app/tui/screens/diff/screen.tcss +104 -0
- chrys/app/tui/screens/guides/__init__.py +7 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/agents.md +178 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/approval.md +117 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/chat-options.md +276 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/compaction.md +42 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/memory.md +48 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/models.md +158 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/settings.md +210 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/themes.md +61 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/tools.md +60 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/web-tools.md +286 -0
- chrys/app/tui/screens/guides/_docs/en/guides/daily-use/sessions.md +77 -0
- chrys/app/tui/screens/guides/_docs/en/guides/daily-use/workspaces.md +51 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/external-acp-agents.md +126 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/hooks.md +167 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/mcp.md +95 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/skills.md +175 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/icode-acp.md +61 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/icode-run.md +190 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/icode-serve.md +239 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/workflows.md +664 -0
- chrys/app/tui/screens/guides/_docs/en/reference/agent-profile.md +548 -0
- chrys/app/tui/screens/guides/_docs/en/reference/hooks.md +602 -0
- chrys/app/tui/screens/guides/_docs/en/reference/opentelemetry.md +177 -0
- chrys/app/tui/screens/guides/_docs/en/reference/settings.md +245 -0
- chrys/app/tui/screens/guides/_docs/en/reference/themes.md +149 -0
- chrys/app/tui/screens/guides/_docs/en/reference/tool-kinds-and-names.md +58 -0
- chrys/app/tui/screens/guides/_docs/en/reference/tui-slash-commands.md +76 -0
- chrys/app/tui/screens/guides/_docs/en/reference/workflows.md +1006 -0
- chrys/app/tui/screens/guides/_docs/en/start/getting-started.md +170 -0
- chrys/app/tui/screens/guides/_docs/en/start/what-is-icode.md +34 -0
- chrys/app/tui/screens/guides/_docs/index.yaml +100 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/agents.md +178 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/approval.md +117 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/chat-options.md +276 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/compaction.md +42 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/memory.md +48 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/models.md +158 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/settings.md +210 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/themes.md +61 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/tools.md +60 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/web-tools.md +286 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/daily-use/sessions.md +77 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/daily-use/workspaces.md +51 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/external-acp-agents.md +126 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/hooks.md +167 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/mcp.md +95 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/skills.md +175 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-acp.md +61 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-run.md +190 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-serve.md +239 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/workflows.md +664 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/agent-profile.md +548 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/hooks.md +602 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/opentelemetry.md +177 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/settings.md +245 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/themes.md +149 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/tool-kinds-and-names.md +58 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/tui-slash-commands.md +76 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/workflows.md +1005 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/start/getting-started.md +170 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/start/what-is-icode.md +34 -0
- chrys/app/tui/screens/guides/index.py +215 -0
- chrys/app/tui/screens/guides/reader.py +173 -0
- chrys/app/tui/screens/guides/screen.py +442 -0
- chrys/app/tui/screens/guides/screen.tcss +52 -0
- chrys/app/tui/screens/logs/__init__.py +7 -0
- chrys/app/tui/screens/logs/screen.py +504 -0
- chrys/app/tui/screens/logs/screen.tcss +45 -0
- chrys/app/tui/screens/main/__init__.py +7 -0
- chrys/app/tui/screens/main/action_availability.py +30 -0
- chrys/app/tui/screens/main/buddy_command.py +143 -0
- chrys/app/tui/screens/main/commands.py +1073 -0
- chrys/app/tui/screens/main/config_actions.py +464 -0
- chrys/app/tui/screens/main/copy_actions.py +117 -0
- chrys/app/tui/screens/main/dialog_controllers.py +826 -0
- chrys/app/tui/screens/main/dialog_gateway.py +166 -0
- chrys/app/tui/screens/main/diff_controller.py +478 -0
- chrys/app/tui/screens/main/engine_read_model.py +184 -0
- chrys/app/tui/screens/main/event_handlers.py +2014 -0
- chrys/app/tui/screens/main/input_flow.py +241 -0
- chrys/app/tui/screens/main/live_diff.py +244 -0
- chrys/app/tui/screens/main/main_screen_presenter.py +295 -0
- chrys/app/tui/screens/main/model_indicator.py +217 -0
- chrys/app/tui/screens/main/navigation.py +343 -0
- chrys/app/tui/screens/main/ports.py +571 -0
- chrys/app/tui/screens/main/recent_dirs.py +123 -0
- chrys/app/tui/screens/main/rollback_controller.py +586 -0
- chrys/app/tui/screens/main/runtime_info.py +71 -0
- chrys/app/tui/screens/main/screen.py +2265 -0
- chrys/app/tui/screens/main/screen.tcss +21 -0
- chrys/app/tui/screens/main/session_handlers.py +1127 -0
- chrys/app/tui/screens/main/session_title.py +239 -0
- chrys/app/tui/screens/main/settings_coordinator.py +365 -0
- chrys/app/tui/screens/main/settings_persistence.py +184 -0
- chrys/app/tui/screens/main/shell_mode.py +112 -0
- chrys/app/tui/screens/main/state.py +289 -0
- chrys/app/tui/screens/main/submit_state.py +11 -0
- chrys/app/tui/screens/main/subscriptions.py +177 -0
- chrys/app/tui/screens/main/suggestions.py +982 -0
- chrys/app/tui/screens/main/tool_action_bridge.py +75 -0
- chrys/app/tui/screens/main/view_adapter.py +1567 -0
- chrys/app/tui/screens/main/workflow_browser.py +747 -0
- chrys/app/tui/screens/main/workflow_content.py +248 -0
- chrys/app/tui/screens/main/workflow_controller.py +240 -0
- chrys/app/tui/screens/main/workflow_feedback.py +137 -0
- chrys/app/tui/screens/main/workflow_flow.py +49 -0
- chrys/app/tui/screens/main/workflow_run_control.py +456 -0
- chrys/app/tui/screens/main/workflow_session_view.py +316 -0
- chrys/app/tui/screens/main/workspace_actions.py +272 -0
- chrys/app/tui/screens/main/workspace_branch.py +194 -0
- chrys/app/tui/screens/menus/__init__.py +8 -0
- chrys/app/tui/screens/menus/languages.py +114 -0
- chrys/app/tui/screens/models/__init__.py +7 -0
- chrys/app/tui/screens/models/picker.py +147 -0
- chrys/app/tui/screens/models/screen.py +2043 -0
- chrys/app/tui/screens/models/screen.tcss +255 -0
- chrys/app/tui/screens/sessions/__init__.py +8 -0
- chrys/app/tui/screens/sessions/presenter.py +586 -0
- chrys/app/tui/screens/sessions/screen.py +779 -0
- chrys/app/tui/screens/sessions/screen.tcss +127 -0
- chrys/app/tui/screens/settings/__init__.py +29 -0
- chrys/app/tui/screens/settings/danger.py +26 -0
- chrys/app/tui/screens/settings/dialog.py +281 -0
- chrys/app/tui/screens/settings/layout.py +519 -0
- chrys/app/tui/screens/settings/migrate_dialog.py +253 -0
- chrys/app/tui/screens/settings/panes/__init__.py +3 -0
- chrys/app/tui/screens/settings/panes/notifications.py +248 -0
- chrys/app/tui/screens/settings/panes/sessions.py +148 -0
- chrys/app/tui/screens/settings/ports.py +55 -0
- chrys/app/tui/screens/settings/provenance.py +102 -0
- chrys/app/tui/screens/settings/rows.py +546 -0
- chrys/app/tui/screens/settings/settings.tcss +406 -0
- chrys/app/tui/screens/themes/__init__.py +8 -0
- chrys/app/tui/screens/themes/dialogs.py +330 -0
- chrys/app/tui/screens/themes/editor.py +632 -0
- chrys/app/tui/screens/themes/editor.tcss +268 -0
- chrys/app/tui/screens/themes/messages.py +95 -0
- chrys/app/tui/screens/themes/palette.py +694 -0
- chrys/app/tui/screens/themes/panel.py +113 -0
- chrys/app/tui/screens/themes/picker.py +203 -0
- chrys/app/tui/screens/themes/save.py +191 -0
- chrys/app/tui/support/__init__.py +3 -0
- chrys/app/tui/support/file_manager.py +64 -0
- chrys/app/tui/support/gc_freeze.py +1114 -0
- chrys/app/tui/support/path_shortcuts.py +50 -0
- chrys/app/tui/support/prompt_history.py +298 -0
- chrys/app/tui/support/workspace_mru.py +476 -0
- chrys/app/tui/terminal/__init__.py +6 -0
- chrys/app/tui/terminal/_pty_backend.py +114 -0
- chrys/app/tui/terminal/emulator/__init__.py +24 -0
- chrys/app/tui/terminal/emulator/buffer.py +456 -0
- chrys/app/tui/terminal/emulator/charsets.py +113 -0
- chrys/app/tui/terminal/emulator/core.py +963 -0
- chrys/app/tui/terminal/emulator/input.py +342 -0
- chrys/app/tui/terminal/emulator/parser.py +227 -0
- chrys/app/tui/terminal/emulator/pen.py +158 -0
- chrys/app/tui/terminal/launcher.py +455 -0
- chrys/app/tui/terminal/leaked_reports.py +44 -0
- chrys/app/tui/terminal/panel.py +323 -0
- chrys/app/tui/terminal/pty_process.py +405 -0
- chrys/app/tui/terminal/rendering.py +151 -0
- chrys/app/tui/terminal/shell.py +273 -0
- chrys/app/tui/terminal/shell_integration.py +199 -0
- chrys/app/tui/terminal/title.py +224 -0
- chrys/app/tui/terminal/widget.py +654 -0
- chrys/app/tui/theme.py +422 -0
- chrys/app/tui/theme_loader.py +201 -0
- chrys/app/tui/theme_stylesheet.py +49 -0
- chrys/app/tui/themes/__init__.py +3 -0
- chrys/app/tui/themes/document.py +120 -0
- chrys/app/tui/themes/preview.py +170 -0
- chrys/app/tui/themes/store.py +176 -0
- chrys/app/tui/toasts.py +69 -0
- chrys/app/tui/util/__init__.py +3 -0
- chrys/app/tui/util/context_pressure.py +146 -0
- chrys/app/tui/util/debug_log.py +292 -0
- chrys/app/tui/util/diff_entries.py +64 -0
- chrys/app/tui/util/formatting.py +53 -0
- chrys/app/tui/util/git_branch.py +326 -0
- chrys/app/tui/util/invocation_progress.py +57 -0
- chrys/app/tui/util/logo.py +32 -0
- chrys/app/tui/util/message_gate.py +35 -0
- chrys/app/tui/util/mutation_sources.py +14 -0
- chrys/app/tui/util/picker_text.py +27 -0
- chrys/app/tui/util/removal.py +47 -0
- chrys/app/tui/util/rich_style.py +36 -0
- chrys/app/tui/util/source_text.py +12 -0
- chrys/app/tui/util/static_update.py +88 -0
- chrys/app/tui/util/todo_format.py +51 -0
- chrys/app/tui/util/visibility.py +214 -0
- chrys/app/tui/widgets/__init__.py +60 -0
- chrys/app/tui/widgets/ask_user_controls.py +652 -0
- chrys/app/tui/widgets/ask_user_markdown.py +17 -0
- chrys/app/tui/widgets/ask_user_prompt.py +606 -0
- chrys/app/tui/widgets/buttons.py +52 -0
- chrys/app/tui/widgets/chat/__init__.py +3 -0
- chrys/app/tui/widgets/chat/agent_transcript_surface.py +818 -0
- chrys/app/tui/widgets/chat/chrome.py +296 -0
- chrys/app/tui/widgets/chat/compaction_card.py +353 -0
- chrys/app/tui/widgets/chat/context_fold.py +77 -0
- chrys/app/tui/widgets/chat/file_snapshot.py +91 -0
- chrys/app/tui/widgets/chat/image_preview.py +214 -0
- chrys/app/tui/widgets/chat/live_renderer.py +383 -0
- chrys/app/tui/widgets/chat/messages.py +1039 -0
- chrys/app/tui/widgets/chat/panel.py +1769 -0
- chrys/app/tui/widgets/chat/ports.py +245 -0
- chrys/app/tui/widgets/chat/renderers/__init__.py +7 -0
- chrys/app/tui/widgets/chat/renderers/ask_user.py +747 -0
- chrys/app/tui/widgets/chat/renderers/execute.py +431 -0
- chrys/app/tui/widgets/chat/renderers/file_edit.py +925 -0
- chrys/app/tui/widgets/chat/renderers/hosted_code.py +83 -0
- chrys/app/tui/widgets/chat/renderers/hosted_generic.py +330 -0
- chrys/app/tui/widgets/chat/renderers/hosted_image.py +107 -0
- chrys/app/tui/widgets/chat/renderers/hosted_mcp.py +43 -0
- chrys/app/tui/widgets/chat/renderers/hosted_search.py +167 -0
- chrys/app/tui/widgets/chat/renderers/hosted_shell.py +132 -0
- chrys/app/tui/widgets/chat/renderers/read_file.py +399 -0
- chrys/app/tui/widgets/chat/renderers/search.py +348 -0
- chrys/app/tui/widgets/chat/renderers/skill.py +564 -0
- chrys/app/tui/widgets/chat/renderers/sleep.py +287 -0
- chrys/app/tui/widgets/chat/renderers/sub_agent.py +1385 -0
- chrys/app/tui/widgets/chat/renderers/todo.py +114 -0
- chrys/app/tui/widgets/chat/renderers/web.py +69 -0
- chrys/app/tui/widgets/chat/replay.py +1605 -0
- chrys/app/tui/widgets/chat/replay_mount.py +322 -0
- chrys/app/tui/widgets/chat/scroll_controller.py +579 -0
- chrys/app/tui/widgets/chat/selection_controller.py +435 -0
- chrys/app/tui/widgets/chat/session_json.py +508 -0
- chrys/app/tui/widgets/chat/toc_model.py +156 -0
- chrys/app/tui/widgets/chat/tool_call.py +2478 -0
- chrys/app/tui/widgets/chat/tool_registry.py +753 -0
- chrys/app/tui/widgets/chat/tool_renderers.py +247 -0
- chrys/app/tui/widgets/chat/tool_view_builders.py +260 -0
- chrys/app/tui/widgets/chat/transcript_reader.py +57 -0
- chrys/app/tui/widgets/checkbox.py +24 -0
- chrys/app/tui/widgets/chrome/__init__.py +3 -0
- chrys/app/tui/widgets/chrome/app_header.py +283 -0
- chrys/app/tui/widgets/chrome/commands.py +95 -0
- chrys/app/tui/widgets/chrome/file_index.py +68 -0
- chrys/app/tui/widgets/chrome/file_scanner.py +670 -0
- chrys/app/tui/widgets/chrome/footer.py +284 -0
- chrys/app/tui/widgets/chrome/image_paste.py +351 -0
- chrys/app/tui/widgets/chrome/input_bar.py +1299 -0
- chrys/app/tui/widgets/chrome/status_bar.py +911 -0
- chrys/app/tui/widgets/chrome/suggestion_list.py +652 -0
- chrys/app/tui/widgets/click_affordance.py +24 -0
- chrys/app/tui/widgets/color_picker/__init__.py +254 -0
- chrys/app/tui/widgets/color_picker/controls.py +171 -0
- chrys/app/tui/widgets/color_picker/model.py +89 -0
- chrys/app/tui/widgets/dialog_buttons.py +58 -0
- chrys/app/tui/widgets/dialog_scroll.py +24 -0
- chrys/app/tui/widgets/diff_view/__init__.py +20 -0
- chrys/app/tui/widgets/diff_view/cells.py +59 -0
- chrys/app/tui/widgets/diff_view/code.py +280 -0
- chrys/app/tui/widgets/diff_view/compute.py +286 -0
- chrys/app/tui/widgets/diff_view/gutter.py +161 -0
- chrys/app/tui/widgets/diff_view/inline.py +77 -0
- chrys/app/tui/widgets/diff_view/palette.py +87 -0
- chrys/app/tui/widgets/diff_view/protocols.py +24 -0
- chrys/app/tui/widgets/diff_view/rows.py +138 -0
- chrys/app/tui/widgets/diff_view/unified.py +267 -0
- chrys/app/tui/widgets/diff_view/widget.py +209 -0
- chrys/app/tui/widgets/editor/__init__.py +79 -0
- chrys/app/tui/widgets/editor/coordinates.py +30 -0
- chrys/app/tui/widgets/editor/highlighter.py +139 -0
- chrys/app/tui/widgets/editor/keymaps.py +357 -0
- chrys/app/tui/widgets/editor/types.py +216 -0
- chrys/app/tui/widgets/editor/vim.py +1195 -0
- chrys/app/tui/widgets/editor/widget.py +401 -0
- chrys/app/tui/widgets/file_preview.py +58 -0
- chrys/app/tui/widgets/hatch.py +138 -0
- chrys/app/tui/widgets/input.py +66 -0
- chrys/app/tui/widgets/loading.py +125 -0
- chrys/app/tui/widgets/markdown/__init__.py +25 -0
- chrys/app/tui/widgets/markdown/_utils.py +21 -0
- chrys/app/tui/widgets/markdown/anchors.py +71 -0
- chrys/app/tui/widgets/markdown/blocks.py +115 -0
- chrys/app/tui/widgets/markdown/copyable.py +163 -0
- chrys/app/tui/widgets/markdown/diagram/__init__.py +35 -0
- chrys/app/tui/widgets/markdown/diagram/canvas.py +409 -0
- chrys/app/tui/widgets/markdown/diagram/charts.py +52 -0
- chrys/app/tui/widgets/markdown/diagram/diagnostics.py +101 -0
- chrys/app/tui/widgets/markdown/diagram/geometry.py +46 -0
- chrys/app/tui/widgets/markdown/diagram/layout.py +494 -0
- chrys/app/tui/widgets/markdown/diagram/messages.py +18 -0
- chrys/app/tui/widgets/markdown/diagram/model.py +275 -0
- chrys/app/tui/widgets/markdown/diagram/parser.py +173 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/__init__.py +3 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/charts.py +349 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/common.py +403 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/graphs.py +1072 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/planning.py +317 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/schedule.py +298 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/schemas.py +241 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/structure.py +221 -0
- chrys/app/tui/widgets/markdown/diagram/presentation.py +32 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/__init__.py +3 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/charts.py +275 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/common.py +73 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/planning.py +157 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/schedule.py +202 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/structure.py +77 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/xy.py +385 -0
- chrys/app/tui/widgets/markdown/diagram/router.py +651 -0
- chrys/app/tui/widgets/markdown/diagram/specs/__init__.py +3 -0
- chrys/app/tui/widgets/markdown/diagram/specs/charts.py +94 -0
- chrys/app/tui/widgets/markdown/diagram/specs/planning.py +68 -0
- chrys/app/tui/widgets/markdown/diagram/specs/schedule.py +65 -0
- chrys/app/tui/widgets/markdown/diagram/specs/structure.py +23 -0
- chrys/app/tui/widgets/markdown/diagram/viewer.py +92 -0
- chrys/app/tui/widgets/markdown/links.py +47 -0
- chrys/app/tui/widgets/markdown/math/__init__.py +36 -0
- chrys/app/tui/widgets/markdown/math/layout.py +739 -0
- chrys/app/tui/widgets/markdown/math/markdown.py +291 -0
- chrys/app/tui/widgets/markdown/math/parser.py +325 -0
- chrys/app/tui/widgets/markdown/math/symbols.py +325 -0
- chrys/app/tui/widgets/markdown/navigator.py +80 -0
- chrys/app/tui/widgets/markdown/parser.py +1057 -0
- chrys/app/tui/widgets/markdown/stream.py +73 -0
- chrys/app/tui/widgets/markdown/toc.py +98 -0
- chrys/app/tui/widgets/markdown/viewer.py +146 -0
- chrys/app/tui/widgets/markdown/widget.py +2046 -0
- chrys/app/tui/widgets/marquee.py +101 -0
- chrys/app/tui/widgets/option_menu.py +82 -0
- chrys/app/tui/widgets/page_navigator.py +118 -0
- chrys/app/tui/widgets/select.py +196 -0
- chrys/app/tui/widgets/selection.py +59 -0
- chrys/app/tui/widgets/sidebar/__init__.py +3 -0
- chrys/app/tui/widgets/sidebar/buddy.py +367 -0
- chrys/app/tui/widgets/sidebar/context.py +482 -0
- chrys/app/tui/widgets/sidebar/debug.py +195 -0
- chrys/app/tui/widgets/sidebar/empty_state.py +11 -0
- chrys/app/tui/widgets/sidebar/panel.py +198 -0
- chrys/app/tui/widgets/sidebar/tasks.py +198 -0
- chrys/app/tui/widgets/sidebar/toc.py +166 -0
- chrys/app/tui/widgets/sidebar/toc_types.py +30 -0
- chrys/app/tui/widgets/syntax_theme.py +85 -0
- chrys/app/tui/widgets/text_area.py +258 -0
- chrys/app/tui/widgets/trajectory/__init__.py +8 -0
- chrys/app/tui/widgets/trajectory/chartkit.py +264 -0
- chrys/app/tui/widgets/trajectory/insights.py +1266 -0
- chrys/app/tui/widgets/trajectory/overview.py +1101 -0
- chrys/app/tui/widgets/trajectory/panel.py +983 -0
- chrys/app/tui/widgets/trajectory/presentation.py +311 -0
- chrys/app/tui/widgets/trajectory/session_info.py +124 -0
- chrys/app/tui/widgets/trajectory/text_view.py +124 -0
- chrys/app/tui/widgets/trajectory/timeline.py +380 -0
- chrys/app/tui/widgets/welcome.py +126 -0
- chrys/app/tui/widgets/workflow/__init__.py +5 -0
- chrys/app/tui/widgets/workflow/edges.py +89 -0
- chrys/app/tui/widgets/workflow/graph.py +869 -0
- chrys/app/tui/widgets/workflow/info.py +310 -0
- chrys/app/tui/widgets/workflow/node_view.py +27 -0
- chrys/app/tui/widgets/workflow/output.py +225 -0
- chrys/app/tui/widgets/workflow/panel.py +658 -0
- chrys/app/tui/widgets/workflow/projector.py +324 -0
- chrys/app/tui/widgets/workflow/records.py +69 -0
- chrys/app/tui/widgets/workflow/scrollbar.py +82 -0
- chrys/app/tui/widgets/workflow/selection.py +151 -0
- chrys/app/tui/widgets/workflow/source.py +65 -0
- chrys/app/tui/widgets/workflow/text.py +257 -0
- chrys/app/tui/widgets/workflow/values.py +317 -0
- chrys/foundation/__init__.py +0 -0
- chrys/foundation/branding.py +15 -0
- chrys/foundation/config/__init__.py +7 -0
- chrys/foundation/config/coercion.py +374 -0
- chrys/foundation/config/context.py +34 -0
- chrys/foundation/config/env_file.py +141 -0
- chrys/foundation/config/env_layers.py +370 -0
- chrys/foundation/config/migrations.py +521 -0
- chrys/foundation/config/notification_events.py +22 -0
- chrys/foundation/config/process_settings.py +332 -0
- chrys/foundation/config/runtime_pointer.py +133 -0
- chrys/foundation/config/settings.py +1952 -0
- chrys/foundation/config/settings_store.py +996 -0
- chrys/foundation/config/spec.py +396 -0
- chrys/foundation/config/user_settings.py +140 -0
- chrys/foundation/config/warnings.py +262 -0
- chrys/foundation/config/web_values.py +135 -0
- chrys/foundation/config/yaml_store.py +487 -0
- chrys/foundation/errors/__init__.py +68 -0
- chrys/foundation/errors/_legacy.py +117 -0
- chrys/foundation/errors/_text.py +13 -0
- chrys/foundation/errors/_walk.py +85 -0
- chrys/foundation/errors/classify.py +278 -0
- chrys/foundation/errors/display.py +274 -0
- chrys/foundation/errors/formatting.py +283 -0
- chrys/foundation/errors/kinds.py +67 -0
- chrys/foundation/errors/network.py +497 -0
- chrys/foundation/errors/provider.py +445 -0
- chrys/foundation/errors/route.py +85 -0
- chrys/foundation/events/__init__.py +8 -0
- chrys/foundation/events/bus.py +143 -0
- chrys/foundation/events/types.py +1629 -0
- chrys/foundation/events/workflow.py +38 -0
- chrys/foundation/hosted_tools.py +142 -0
- chrys/foundation/i18n/__init__.py +32 -0
- chrys/foundation/i18n/_catalogs/zh-Hans/LC_MESSAGES/chrys.mo +0 -0
- chrys/foundation/i18n/catalogs.py +42 -0
- chrys/foundation/i18n/formatting.py +202 -0
- chrys/foundation/i18n/locale.py +92 -0
- chrys/foundation/i18n/localizer.py +187 -0
- chrys/foundation/i18n/messages.py +268 -0
- chrys/foundation/io/__init__.py +3 -0
- chrys/foundation/io/result_content.py +237 -0
- chrys/foundation/models/__init__.py +3 -0
- chrys/foundation/models/approval_reuse.py +23 -0
- chrys/foundation/models/ask_user.py +468 -0
- chrys/foundation/models/execution.py +18 -0
- chrys/foundation/models/history_markers.py +115 -0
- chrys/foundation/models/invocations.py +63 -0
- chrys/foundation/models/mutation_scope.py +28 -0
- chrys/foundation/models/session_env.py +87 -0
- chrys/foundation/models/session_surface.py +29 -0
- chrys/foundation/models/todos.py +74 -0
- chrys/foundation/models/turns.py +125 -0
- chrys/foundation/models/workflow_session.py +89 -0
- chrys/foundation/models/workspace.py +59 -0
- chrys/foundation/net/__init__.py +3 -0
- chrys/foundation/net/address_class.py +57 -0
- chrys/foundation/net/route_probe.py +94 -0
- chrys/foundation/net/url.py +107 -0
- chrys/foundation/observability/__init__.py +3 -0
- chrys/foundation/observability/exporters.py +174 -0
- chrys/foundation/observability/gate.py +31 -0
- chrys/foundation/observability/setup.py +497 -0
- chrys/foundation/observability/sink.py +228 -0
- chrys/foundation/patches/__init__.py +19 -0
- chrys/foundation/patches/mdurl_cache.py +59 -0
- chrys/foundation/patches/patcher.py +287 -0
- chrys/foundation/patches/staged_members.py +205 -0
- chrys/foundation/patches/textual_block_border.py +105 -0
- chrys/foundation/patches/textual_button_ansi.py +65 -0
- chrys/foundation/patches/textual_button_ansi_old.txt +68 -0
- chrys/foundation/patches/textual_callback_cache.py +248 -0
- chrys/foundation/patches/textual_callback_dispatch.py +109 -0
- chrys/foundation/patches/textual_compositor_cjk.py +566 -0
- chrys/foundation/patches/textual_dispatch_cache.py +150 -0
- chrys/foundation/patches/textual_ime_cursor_anchor.py +92 -0
- chrys/foundation/patches/textual_kitty_keyboard.py +268 -0
- chrys/foundation/patches/textual_lru_acyclic.py +241 -0
- chrys/foundation/patches/textual_message_pump.py +136 -0
- chrys/foundation/patches/textual_node_diet.py +875 -0
- chrys/foundation/patches/textual_one_shot_timer.py +48 -0
- chrys/foundation/patches/textual_option_list.py +90 -0
- chrys/foundation/patches/textual_precompose.py +89 -0
- chrys/foundation/patches/textual_pruned_tabs.py +69 -0
- chrys/foundation/patches/textual_reflow_reuse.py +1753 -0
- chrys/foundation/patches/textual_removed_node_caches.py +86 -0
- chrys/foundation/patches/textual_removed_screen_callbacks.py +55 -0
- chrys/foundation/patches/textual_selection_extract.py +48 -0
- chrys/foundation/patches/textual_strip_cycles.py +119 -0
- chrys/foundation/patches/textual_tab_selection.py +1611 -0
- chrys/foundation/patches/textual_timer_skip.py +70 -0
- chrys/foundation/patches/textual_utf8_decoder.py +106 -0
- chrys/foundation/patches/textual_win_sleep.py +167 -0
- chrys/foundation/patches/watchdog_fsevents.py +70 -0
- chrys/foundation/patches/watchdog_windows.py +52 -0
- chrys/foundation/platform/__init__.py +556 -0
- chrys/foundation/platform/c_api.py +32 -0
- chrys/foundation/platform/child_reap.py +107 -0
- chrys/foundation/platform/command_line.py +95 -0
- chrys/foundation/platform/files.py +1470 -0
- chrys/foundation/platform/output_capture.py +113 -0
- chrys/foundation/platform/paths.py +193 -0
- chrys/foundation/platform/process.py +1468 -0
- chrys/foundation/platform/pty_output.py +19 -0
- chrys/foundation/platform/runtime_paths.py +281 -0
- chrys/foundation/platform/win_console.py +129 -0
- chrys/foundation/reasoning_origin.py +67 -0
- chrys/foundation/recovery.py +16 -0
- chrys/foundation/retry.py +278 -0
- chrys/foundation/text/__init__.py +3 -0
- chrys/foundation/text/encoding.py +603 -0
- chrys/foundation/text/images.py +304 -0
- chrys/foundation/text/lines.py +24 -0
- chrys/foundation/text/mentions.py +90 -0
- chrys/foundation/text/model_json.py +25 -0
- chrys/foundation/text/reading_order.py +20 -0
- chrys/foundation/text/tokenizer.py +25 -0
- chrys/foundation/text/tool_output.py +287 -0
- chrys/foundation/text/yaml_io.py +62 -0
- chrys/foundation/tool_call_context.py +202 -0
- chrys/foundation/tool_execution_stamp.py +184 -0
- chrys/foundation/tool_invocation_order.py +48 -0
- chrys/foundation/tool_kinds.py +102 -0
- chrys/foundation/tool_result_metadata.py +287 -0
- chrys/foundation/trajectory/__init__.py +10 -0
- chrys/foundation/trajectory/context.py +481 -0
- chrys/foundation/trajectory/envelope.py +571 -0
- chrys/foundation/trajectory/event_types.py +294 -0
- chrys/foundation/trajectory/finalizers.py +46 -0
- chrys/foundation/trajectory/fingerprint.py +62 -0
- chrys/foundation/trajectory/ids.py +155 -0
- chrys/foundation/trajectory/keys.py +99 -0
- chrys/foundation/trajectory/lease.py +173 -0
- chrys/foundation/trajectory/metadata.py +72 -0
- chrys/foundation/trajectory/reader.py +271 -0
- chrys/foundation/trajectory/recovery.py +225 -0
- chrys/foundation/trajectory/revisions.py +152 -0
- chrys/foundation/trajectory/segments.py +225 -0
- chrys/foundation/trajectory/tools.py +117 -0
- chrys/foundation/trajectory/usage.py +151 -0
- chrys/foundation/trajectory/writer.py +972 -0
- chrys/foundation/trajectory_timing.py +97 -0
- chrys/foundation/util/__init__.py +3 -0
- chrys/foundation/util/async_tasks.py +29 -0
- chrys/foundation/util/chrys_headers.py +23 -0
- chrys/foundation/util/env_templates.py +104 -0
- chrys/foundation/util/filenames.py +96 -0
- chrys/foundation/util/header_charset.py +138 -0
- chrys/foundation/util/httpx_helpers.py +12 -0
- chrys/foundation/util/lock.py +236 -0
- chrys/foundation/util/once_close.py +46 -0
- chrys/foundation/util/session_ids.py +29 -0
- chrys/foundation/util/shell_tokens.py +88 -0
- chrys/foundation/util/sub_agent_context.py +38 -0
- chrys/foundation/util/time.py +35 -0
- chrys/foundation/util/unicode_scalars.py +36 -0
- chrys/foundation/vendor/__init__.py +43 -0
- chrys/foundation/vendor/ripgrep/rg-aarch64-apple-darwin +0 -0
- chrys/foundation/vendor/ripgrep/rg-aarch64-pc-windows-msvc.exe +0 -0
- chrys/foundation/vendor/ripgrep/rg-aarch64-unknown-linux-gnu +0 -0
- chrys/foundation/vendor/ripgrep/rg-x86_64-apple-darwin +0 -0
- chrys/foundation/vendor/ripgrep/rg-x86_64-pc-windows-msvc.exe +0 -0
- chrys/foundation/vendor/ripgrep/rg-x86_64-unknown-linux-musl +0 -0
- chrys/kernel/__init__.py +256 -0
- chrys/kernel/_content.py +1866 -0
- chrys/kernel/_loop_recorder.py +631 -0
- chrys/kernel/_null_overlay.py +1026 -0
- chrys/kernel/_result_ceiling.py +216 -0
- chrys/kernel/_serialization.py +431 -0
- chrys/kernel/_tool_arg_errors.py +1629 -0
- chrys/kernel/_tool_execution.py +1172 -0
- chrys/kernel/_tool_expansion.py +26 -0
- chrys/kernel/_types.py +2598 -0
- chrys/kernel/agent.py +994 -0
- chrys/kernel/client.py +1084 -0
- chrys/kernel/compaction.py +1315 -0
- chrys/kernel/exceptions.py +211 -0
- chrys/kernel/exchanges.py +683 -0
- chrys/kernel/identity.py +95 -0
- chrys/kernel/images.py +36 -0
- chrys/kernel/instrumentation.py +1618 -0
- chrys/kernel/loop.py +2308 -0
- chrys/kernel/middleware.py +616 -0
- chrys/kernel/sessions.py +604 -0
- chrys/kernel/tools.py +1061 -0
- chrys/kernel/types.py +126 -0
- chrys/orchestration/__init__.py +3 -0
- chrys/orchestration/engine/__init__.py +3 -0
- chrys/orchestration/engine/assembly.py +236 -0
- chrys/orchestration/engine/build/__init__.py +3 -0
- chrys/orchestration/engine/build/builder.py +1061 -0
- chrys/orchestration/engine/build/construction.py +408 -0
- chrys/orchestration/engine/build/loaded.py +139 -0
- chrys/orchestration/engine/engine.py +789 -0
- chrys/orchestration/engine/execution.py +771 -0
- chrys/orchestration/engine/loader.py +865 -0
- chrys/orchestration/engine/rollback.py +918 -0
- chrys/orchestration/engine/run/__init__.py +3 -0
- chrys/orchestration/engine/run/active_injection.py +419 -0
- chrys/orchestration/engine/run/attachments.py +519 -0
- chrys/orchestration/engine/run/bindings.py +1095 -0
- chrys/orchestration/engine/run/coordinator.py +1062 -0
- chrys/orchestration/engine/run/finalizer.py +417 -0
- chrys/orchestration/engine/run/input_refs.py +60 -0
- chrys/orchestration/engine/run/prompt_content.py +457 -0
- chrys/orchestration/engine/run/resume.py +299 -0
- chrys/orchestration/engine/run/retry.py +948 -0
- chrys/orchestration/engine/run/runner.py +625 -0
- chrys/orchestration/engine/run/runtime_skills.py +146 -0
- chrys/orchestration/engine/run/sub_agent_coordination.py +126 -0
- chrys/orchestration/engine/run/turn_hooks.py +258 -0
- chrys/orchestration/engine/run/turn_state.py +149 -0
- chrys/orchestration/engine/run/working_dir.py +50 -0
- chrys/orchestration/engine/session_lifecycle.py +2013 -0
- chrys/orchestration/engine/state/__init__.py +3 -0
- chrys/orchestration/engine/state/active_session.py +183 -0
- chrys/orchestration/engine/state/controls.py +639 -0
- chrys/orchestration/engine/state/current_agent.py +26 -0
- chrys/orchestration/engine/state/lifecycle_permits.py +395 -0
- chrys/orchestration/engine/state/machine.py +193 -0
- chrys/orchestration/engine/state/session_writer.py +434 -0
- chrys/orchestration/engine/trajectory.py +714 -0
- chrys/orchestration/engine/usage.py +118 -0
- chrys/orchestration/invoker/__init__.py +3 -0
- chrys/orchestration/invoker/acp.py +727 -0
- chrys/orchestration/invoker/acp_protocol.py +1115 -0
- chrys/orchestration/invoker/acp_spec.py +79 -0
- chrys/orchestration/invoker/attempts.py +994 -0
- chrys/orchestration/invoker/child_compaction.py +131 -0
- chrys/orchestration/invoker/child_history.py +99 -0
- chrys/orchestration/invoker/contracts.py +253 -0
- chrys/orchestration/invoker/evidence.py +94 -0
- chrys/orchestration/invoker/kernel.py +343 -0
- chrys/orchestration/invoker/origin.py +79 -0
- chrys/orchestration/invoker/resources.py +343 -0
- chrys/orchestration/invoker/runtime.py +299 -0
- chrys/orchestration/invoker/usage.py +80 -0
- chrys/orchestration/session_hooks.py +117 -0
- chrys/orchestration/session_host.py +1023 -0
- chrys/orchestration/session_resources.py +95 -0
- chrys/orchestration/session_usage.py +219 -0
- chrys/orchestration/startup.py +255 -0
- chrys/orchestration/sub_agents/__init__.py +3 -0
- chrys/orchestration/sub_agents/acp_policy.py +269 -0
- chrys/orchestration/sub_agents/kernel_policy.py +848 -0
- chrys/orchestration/sub_agents/registration.py +44 -0
- chrys/orchestration/sub_agents/shell.py +337 -0
- chrys/orchestration/sub_agents/tools.py +2228 -0
- chrys/orchestration/workflows/__init__.py +3 -0
- chrys/orchestration/workflows/agent_archive.py +156 -0
- chrys/orchestration/workflows/agent_node.py +894 -0
- chrys/orchestration/workflows/agent_node_build.py +687 -0
- chrys/orchestration/workflows/catalog.py +332 -0
- chrys/orchestration/workflows/coordinator.py +771 -0
- chrys/orchestration/workflows/hooks.py +176 -0
- chrys/orchestration/workflows/preview.py +322 -0
- chrys/orchestration/workflows/rollback.py +151 -0
- chrys/orchestration/workflows/runner.py +986 -0
- chrys/orchestration/workflows/session.py +390 -0
- chrys/orchestration/workflows/settings.py +97 -0
- chrys/orchestration/workflows/validation.py +674 -0
- chrys/orchestration/workflows/worker_client.py +1002 -0
- chrys/service/__init__.py +3 -0
- chrys/service/acp_client/__init__.py +65 -0
- chrys/service/acp_client/_transport.py +922 -0
- chrys/service/acp_client/client.py +1311 -0
- chrys/service/acp_client/errors.py +224 -0
- chrys/service/acp_client/protocol.py +220 -0
- chrys/service/acp_client/spawn.py +239 -0
- chrys/service/acp_client/spec.py +100 -0
- chrys/service/agent_middleware/__init__.py +60 -0
- chrys/service/agent_middleware/_metadata_keys.py +52 -0
- chrys/service/agent_middleware/control/__init__.py +3 -0
- chrys/service/agent_middleware/control/approval.py +769 -0
- chrys/service/agent_middleware/control/approval_hooks.py +62 -0
- chrys/service/agent_middleware/control/ask_user.py +207 -0
- chrys/service/agent_middleware/control/interrupt.py +50 -0
- chrys/service/agent_middleware/control/sleep.py +118 -0
- chrys/service/agent_middleware/control/todo.py +117 -0
- chrys/service/agent_middleware/events/__init__.py +3 -0
- chrys/service/agent_middleware/events/hook_dispatch.py +250 -0
- chrys/service/agent_middleware/events/hosted_tools.py +1762 -0
- chrys/service/agent_middleware/events/intermediate_text.py +128 -0
- chrys/service/agent_middleware/events/rejection_metadata.py +50 -0
- chrys/service/agent_middleware/events/result_persistence.py +98 -0
- chrys/service/agent_middleware/events/sub_agent_events.py +951 -0
- chrys/service/agent_middleware/events/tool_error_text.py +22 -0
- chrys/service/agent_middleware/events/tool_events.py +779 -0
- chrys/service/agent_middleware/injection.py +510 -0
- chrys/service/agent_middleware/reminders/__init__.py +91 -0
- chrys/service/agent_middleware/reminders/archive_pointer.py +154 -0
- chrys/service/agent_middleware/reminders/base.py +25 -0
- chrys/service/agent_middleware/reminders/context_usage.py +74 -0
- chrys/service/agent_middleware/reminders/file_change.py +63 -0
- chrys/service/agent_middleware/reminders/mcp.py +39 -0
- chrys/service/agent_middleware/reminders/profile_switch.py +106 -0
- chrys/service/agent_middleware/reminders/runtime_env.py +190 -0
- chrys/service/agent_middleware/reminders/skills.py +44 -0
- chrys/service/agent_middleware/reminders/sub_agents.py +35 -0
- chrys/service/agent_middleware/reminders/todo.py +35 -0
- chrys/service/agent_middleware/reminders/turn_line.py +48 -0
- chrys/service/agent_middleware/response_validation.py +1380 -0
- chrys/service/agent_middleware/system_reminder.py +1107 -0
- chrys/service/agent_middleware/validators.py +396 -0
- chrys/service/analytics/__init__.py +124 -0
- chrys/service/analytics/_actions.py +896 -0
- chrys/service/analytics/_context_evidence.py +357 -0
- chrys/service/analytics/_critical_path.py +179 -0
- chrys/service/analytics/_facts.py +919 -0
- chrys/service/analytics/_insights.py +769 -0
- chrys/service/analytics/_metric_ops.py +83 -0
- chrys/service/analytics/_session_projection.py +460 -0
- chrys/service/analytics/_timeline.py +434 -0
- chrys/service/analytics/_turn_graph.py +1237 -0
- chrys/service/analytics/_turns.py +1377 -0
- chrys/service/analytics/_workflows.py +171 -0
- chrys/service/analytics/aggregation.py +1474 -0
- chrys/service/analytics/classification.py +128 -0
- chrys/service/analytics/export.py +803 -0
- chrys/service/analytics/findings.py +282 -0
- chrys/service/analytics/math.py +79 -0
- chrys/service/analytics/model.py +886 -0
- chrys/service/analytics/reader.py +321 -0
- chrys/service/approval/__init__.py +3 -0
- chrys/service/approval/arbitration.py +102 -0
- chrys/service/approval/command_identity.py +109 -0
- chrys/service/approval/correlation.py +57 -0
- chrys/service/approval/grant_store.py +108 -0
- chrys/service/approval/judge.py +589 -0
- chrys/service/approval/level.py +15 -0
- chrys/service/approval/policy.py +209 -0
- chrys/service/approval/reuse.py +223 -0
- chrys/service/approval/reuse_binding.py +133 -0
- chrys/service/approval/safety_classifier.py +536 -0
- chrys/service/approval/turn_context.py +49 -0
- chrys/service/context/__init__.py +3 -0
- chrys/service/context/compaction/__init__.py +38 -0
- chrys/service/context/compaction/breaker.py +156 -0
- chrys/service/context/compaction/budgets.py +93 -0
- chrys/service/context/compaction/calibration.py +77 -0
- chrys/service/context/compaction/compression.py +690 -0
- chrys/service/context/compaction/current_turn_drop.py +458 -0
- chrys/service/context/compaction/events.py +201 -0
- chrys/service/context/compaction/exclusions.py +261 -0
- chrys/service/context/compaction/groups.py +361 -0
- chrys/service/context/compaction/last_words.py +1996 -0
- chrys/service/context/compaction/last_words_state.py +752 -0
- chrys/service/context/compaction/scoped.py +472 -0
- chrys/service/context/compaction/spill.py +1947 -0
- chrys/service/context/compaction/strategy.py +1116 -0
- chrys/service/context/compaction/summaries.py +319 -0
- chrys/service/context/compaction/turns.py +375 -0
- chrys/service/context/manager.py +174 -0
- chrys/service/context/memory_loader.py +792 -0
- chrys/service/context/middleware/__init__.py +3 -0
- chrys/service/context/middleware/usage.py +332 -0
- chrys/service/context/providers/__init__.py +3 -0
- chrys/service/context/providers/context_management.py +432 -0
- chrys/service/context/providers/history.py +647 -0
- chrys/service/context/providers/memory.py +50 -0
- chrys/service/context/tool_content.py +22 -0
- chrys/service/hooks/__init__.py +37 -0
- chrys/service/hooks/detached_worker.py +118 -0
- chrys/service/hooks/docs/README.md +14 -0
- chrys/service/hooks/docs/authoring.md +684 -0
- chrys/service/hooks/docs/configuration.md +499 -0
- chrys/service/hooks/docs/design.md +359 -0
- chrys/service/hooks/docs/project-hooks.md +348 -0
- chrys/service/hooks/events.py +157 -0
- chrys/service/hooks/loader.py +665 -0
- chrys/service/hooks/manager.py +807 -0
- chrys/service/hooks/matcher.py +111 -0
- chrys/service/hooks/outbox.py +294 -0
- chrys/service/hooks/runner.py +572 -0
- chrys/service/hooks/schema.py +407 -0
- chrys/service/llm/__init__.py +7 -0
- chrys/service/llm/_structured_outputs.py +192 -0
- chrys/service/llm/anthropic_messages/__init__.py +9 -0
- chrys/service/llm/anthropic_messages/client.py +254 -0
- chrys/service/llm/anthropic_messages/decode.py +331 -0
- chrys/service/llm/anthropic_messages/history.py +279 -0
- chrys/service/llm/anthropic_messages/request.py +323 -0
- chrys/service/llm/anthropic_messages/server_tools.py +319 -0
- chrys/service/llm/anthropic_messages/stream.py +297 -0
- chrys/service/llm/anthropic_messages/thinking_binding.py +156 -0
- chrys/service/llm/chat_completions/__init__.py +9 -0
- chrys/service/llm/chat_completions/client.py +369 -0
- chrys/service/llm/chat_completions/decode.py +338 -0
- chrys/service/llm/chat_completions/history.py +657 -0
- chrys/service/llm/chat_completions/reasoning.py +244 -0
- chrys/service/llm/chat_completions/request.py +192 -0
- chrys/service/llm/chat_completions/stream.py +546 -0
- chrys/service/llm/chat_completions/validation.py +308 -0
- chrys/service/llm/clients.py +613 -0
- chrys/service/llm/images.py +65 -0
- chrys/service/llm/mock.py +413 -0
- chrys/service/llm/observer.py +446 -0
- chrys/service/llm/one_shot.py +106 -0
- chrys/service/llm/openai_exceptions.py +93 -0
- chrys/service/llm/openai_responses/__init__.py +10 -0
- chrys/service/llm/openai_responses/client.py +323 -0
- chrys/service/llm/openai_responses/decode.py +432 -0
- chrys/service/llm/openai_responses/history.py +519 -0
- chrys/service/llm/openai_responses/hosted.py +625 -0
- chrys/service/llm/openai_responses/replay.py +629 -0
- chrys/service/llm/openai_responses/request.py +342 -0
- chrys/service/llm/openai_responses/stream.py +847 -0
- chrys/service/llm/openai_timestamps.py +57 -0
- chrys/service/llm/providers.py +107 -0
- chrys/service/llm/proxy_route.py +54 -0
- chrys/service/llm/raw_http_log.py +309 -0
- chrys/service/llm/route_facts.py +63 -0
- chrys/service/llm/route_sessions.py +49 -0
- chrys/service/llm/sdk_anthropic.py +18 -0
- chrys/service/llm/sdk_headers.py +47 -0
- chrys/service/llm/sdk_openai.py +18 -0
- chrys/service/llm/sdk_retry.py +59 -0
- chrys/service/llm/wire_client.py +246 -0
- chrys/service/mcp/__init__.py +3 -0
- chrys/service/mcp/_connection.py +235 -0
- chrys/service/mcp/_http_transport.py +350 -0
- chrys/service/mcp/_progressive.py +459 -0
- chrys/service/mcp/_stdio_transport.py +614 -0
- chrys/service/mcp/_tool_mixins.py +96 -0
- chrys/service/mcp/adapter.py +1031 -0
- chrys/service/mcp/cache.py +476 -0
- chrys/service/mcp/content.py +32 -0
- chrys/service/mcp/errors.py +169 -0
- chrys/service/mcp/owned.py +1330 -0
- chrys/service/mcp/result_limits.py +168 -0
- chrys/service/mcp/thinking_warning.py +95 -0
- chrys/service/mcp/validation.py +74 -0
- chrys/service/mutations/__init__.py +84 -0
- chrys/service/mutations/coordination.py +894 -0
- chrys/service/mutations/detector.py +458 -0
- chrys/service/mutations/git_calibrator.py +397 -0
- chrys/service/mutations/git_state.py +647 -0
- chrys/service/mutations/pipeline.py +779 -0
- chrys/service/mutations/scanner.py +466 -0
- chrys/service/mutations/snapshot_files.py +233 -0
- chrys/service/mutations/store.py +625 -0
- chrys/service/mutations/tool_names.py +15 -0
- chrys/service/mutations/trace.py +246 -0
- chrys/service/mutations/tracker.py +1289 -0
- chrys/service/mutations/types.py +559 -0
- chrys/service/mutations/workspace_changes.py +1583 -0
- chrys/service/profiles/__init__.py +2 -0
- chrys/service/profiles/agents/__init__.py +3 -0
- chrys/service/profiles/agents/builtins/Code.yaml +199 -0
- chrys/service/profiles/agents/builtins/Explore.yaml +62 -0
- chrys/service/profiles/agents/builtins/General.yaml +114 -0
- chrys/service/profiles/agents/builtins/QA.yaml +128 -0
- chrys/service/profiles/agents/loader.py +808 -0
- chrys/service/profiles/agents/registry.py +727 -0
- chrys/service/profiles/agents/schema.py +371 -0
- chrys/service/profiles/agents/serializer.py +196 -0
- chrys/service/profiles/coercion.py +22 -0
- chrys/service/profiles/models/__init__.py +3 -0
- chrys/service/profiles/models/env_bridge.py +157 -0
- chrys/service/profiles/models/loader.py +282 -0
- chrys/service/profiles/models/options.py +438 -0
- chrys/service/profiles/models/registry.py +104 -0
- chrys/service/profiles/models/resolver.py +286 -0
- chrys/service/profiles/models/schema.py +91 -0
- chrys/service/profiles/models/serializer.py +76 -0
- chrys/service/session/__init__.py +3 -0
- chrys/service/session/checkpoint.py +156 -0
- chrys/service/session/history.py +1616 -0
- chrys/service/session/message_metadata.py +151 -0
- chrys/service/session/persistence.py +435 -0
- chrys/service/session/runtime_metadata.py +210 -0
- chrys/service/session/sub_agent_logs.py +1069 -0
- chrys/service/session/sub_agent_transcript.py +654 -0
- chrys/service/skills/__init__.py +3 -0
- chrys/service/skills/adapter.py +408 -0
- chrys/service/skills/constants.py +8 -0
- chrys/service/skills/loader.py +575 -0
- chrys/service/skills/model.py +130 -0
- chrys/service/skills/provider.py +841 -0
- chrys/service/skills/runner.py +390 -0
- chrys/service/state/__init__.py +3 -0
- chrys/service/state/_fork.py +710 -0
- chrys/service/state/_session_files.py +175 -0
- chrys/service/state/_session_meta.py +552 -0
- chrys/service/state/locks.py +154 -0
- chrys/service/state/serializers.py +156 -0
- chrys/service/state/session_catalog.py +361 -0
- chrys/service/state/session_listing.py +84 -0
- chrys/service/state/session_migration.py +446 -0
- chrys/service/state/session_mru.py +315 -0
- chrys/service/state/store.py +2132 -0
- chrys/service/state/workflow.py +158 -0
- chrys/service/text_blocks.py +47 -0
- chrys/service/todos/__init__.py +3 -0
- chrys/service/todos/reminder.py +31 -0
- chrys/service/todos/tracker.py +58 -0
- chrys/service/tools/__init__.py +3 -0
- chrys/service/tools/approval_targets.py +92 -0
- chrys/service/tools/builtins/__init__.py +3 -0
- chrys/service/tools/builtins/ask_user.py +326 -0
- chrys/service/tools/builtins/doc_converter/__init__.py +494 -0
- chrys/service/tools/builtins/doc_converter/artifacts.py +392 -0
- chrys/service/tools/builtins/doc_converter/parsers/__init__.py +3 -0
- chrys/service/tools/builtins/doc_converter/parsers/_table.py +34 -0
- chrys/service/tools/builtins/doc_converter/parsers/base.py +101 -0
- chrys/service/tools/builtins/doc_converter/parsers/docx.py +88 -0
- chrys/service/tools/builtins/doc_converter/parsers/pdf.py +144 -0
- chrys/service/tools/builtins/doc_converter/parsers/pptx.py +103 -0
- chrys/service/tools/builtins/doc_converter/parsers/xls.py +32 -0
- chrys/service/tools/builtins/doc_converter/parsers/xlsx.py +51 -0
- chrys/service/tools/builtins/doc_converter/registry.py +55 -0
- chrys/service/tools/builtins/filesystem.py +1071 -0
- chrys/service/tools/builtins/search.py +1265 -0
- chrys/service/tools/builtins/shell.py +967 -0
- chrys/service/tools/builtins/shell_command_filtering/__init__.py +5 -0
- chrys/service/tools/builtins/shell_command_filtering/policy.py +60 -0
- chrys/service/tools/builtins/shell_command_filtering/presets.py +152 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/__init__.py +358 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/commands.py +416 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/git.py +408 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/syntax.py +87 -0
- chrys/service/tools/builtins/shell_command_filtering/tokenizer.py +214 -0
- chrys/service/tools/builtins/shell_filter.py +24 -0
- chrys/service/tools/builtins/sleep.py +91 -0
- chrys/service/tools/builtins/todo.py +64 -0
- chrys/service/tools/builtins/web/__init__.py +6 -0
- chrys/service/tools/builtins/web/build.py +268 -0
- chrys/service/tools/builtins/web/config.py +338 -0
- chrys/service/tools/builtins/web/credentials.py +37 -0
- chrys/service/tools/builtins/web/fetch/__init__.py +3 -0
- chrys/service/tools/builtins/web/fetch/cache.py +68 -0
- chrys/service/tools/builtins/web/fetch/convert.py +56 -0
- chrys/service/tools/builtins/web/fetch/tool.py +223 -0
- chrys/service/tools/builtins/web/hosted.py +61 -0
- chrys/service/tools/builtins/web/http.py +330 -0
- chrys/service/tools/builtins/web/progress.py +18 -0
- chrys/service/tools/builtins/web/search/__init__.py +3 -0
- chrys/service/tools/builtins/web/search/exa_mcp.py +146 -0
- chrys/service/tools/builtins/web/search/html.py +152 -0
- chrys/service/tools/builtins/web/search/json_api.py +227 -0
- chrys/service/tools/builtins/web/search/providers.py +77 -0
- chrys/service/tools/builtins/web/search/tool.py +218 -0
- chrys/service/tools/builtins/web/search/types.py +28 -0
- chrys/service/tools/builtins/web/tls_identity.py +66 -0
- chrys/service/tools/kinds.py +115 -0
- chrys/service/tools/names.py +47 -0
- chrys/service/tools/registry.py +242 -0
- chrys/service/tools/result_metadata.py +103 -0
- chrys/service/tools/session_artifacts.py +196 -0
- chrys/service/tools/spill.py +218 -0
- chrys/service/tools/workspace_paths.py +81 -0
- chrys/service/trajectory/__init__.py +11 -0
- chrys/service/trajectory/approvals.py +201 -0
- chrys/service/trajectory/compaction.py +420 -0
- chrys/service/trajectory/fork.py +75 -0
- chrys/service/trajectory/hooks.py +217 -0
- chrys/service/trajectory/items.py +41 -0
- chrys/service/trajectory/preparation.py +374 -0
- chrys/service/trajectory/retries.py +95 -0
- chrys/service/trajectory/revisions.py +129 -0
- chrys/service/trajectory/segmented.py +163 -0
- chrys/service/trajectory/session.py +762 -0
- chrys/service/trajectory/state.py +83 -0
- chrys/service/trajectory/sub_agents.py +169 -0
- chrys/service/trajectory/tombstone.py +262 -0
- chrys/service/trajectory/tools.py +323 -0
- chrys/service/trajectory/validation.py +130 -0
- chrys/service/trajectory/waits.py +161 -0
- chrys/service/trajectory/workflow.py +148 -0
- chrys/service/vision.py +128 -0
- chrys/service/workflows/__init__.py +3 -0
- chrys/service/workflows/admission.py +151 -0
- chrys/service/workflows/artifacts.py +109 -0
- chrys/service/workflows/asks.py +130 -0
- chrys/service/workflows/builtins/__init__.py +12 -0
- chrys/service/workflows/builtins/demo-workflow.manifest.json +635 -0
- chrys/service/workflows/builtins/demo-workflow.py +756 -0
- chrys/service/workflows/discovery.py +554 -0
- chrys/service/workflows/environment.py +284 -0
- chrys/service/workflows/graph.py +430 -0
- chrys/service/workflows/history.py +82 -0
- chrys/service/workflows/interpreter.py +107 -0
- chrys/service/workflows/journal.py +334 -0
- chrys/service/workflows/layout.py +56 -0
- chrys/service/workflows/ledger.py +149 -0
- chrys/service/workflows/model_selection.py +18 -0
- chrys/service/workflows/orphans.py +209 -0
- chrys/service/workflows/outcomes.py +46 -0
- chrys/service/workflows/protocol.py +210 -0
- chrys/service/workflows/records.py +142 -0
- chrys/service/workflows/scheduler.py +770 -0
- chrys/service/workflows/sdk/__init__.py +51 -0
- chrys/service/workflows/sdk/_ask.py +167 -0
- chrys/service/workflows/sdk/_builder.py +781 -0
- chrys/service/workflows/sdk/_values.py +92 -0
- chrys/service/workflows/sdk_artifact.py +74 -0
- chrys/service/workflows/store.py +494 -0
- chrys/service/workflows/transcript.py +102 -0
- chrys/service/workflows/values.py +73 -0
- chrys/service/workflows/worker_host.py +1820 -0
- chrys/workflows.py +38 -0
- icode_tui-0.29.1.dist-info/METADATA +104 -0
- icode_tui-0.29.1.dist-info/RECORD +1138 -0
- icode_tui-0.29.1.dist-info/WHEEL +4 -0
- icode_tui-0.29.1.dist-info/entry_points.txt +2 -0
- icode_tui-0.29.1.dist-info/licenses/LICENSE +202 -0
- icode_tui-0.29.1.dist-info/licenses/NOTICE +1955 -0
|
@@ -0,0 +1,1470 @@
|
|
|
1
|
+
# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""Shared filesystem helpers."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import contextlib
|
|
8
|
+
import ctypes
|
|
9
|
+
import errno
|
|
10
|
+
import hashlib
|
|
11
|
+
import ntpath
|
|
12
|
+
import os
|
|
13
|
+
import secrets
|
|
14
|
+
import stat
|
|
15
|
+
import tempfile
|
|
16
|
+
import time
|
|
17
|
+
from ctypes import wintypes
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from functools import cache
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, BinaryIO, cast
|
|
22
|
+
|
|
23
|
+
from chrys.foundation.platform.c_api import declare_functions, struct_fields
|
|
24
|
+
|
|
25
|
+
_OWNER_ONLY_MODE = 0o600
|
|
26
|
+
_ATOMIC_NAME_TOKEN_BYTES = 8
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SecureFileError(OSError):
|
|
30
|
+
"""Raised when an owner-only file cannot be opened or verified securely."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class _DarwinAclAPI:
|
|
35
|
+
"""Typed handle for the lazily loaded Darwin ACL library."""
|
|
36
|
+
|
|
37
|
+
libc: ctypes.CDLL
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class _WindowsFileAPI:
|
|
42
|
+
"""Typed handles for the lazily loaded Win32 secure-file libraries."""
|
|
43
|
+
|
|
44
|
+
advapi32: ctypes.CDLL
|
|
45
|
+
kernel32: ctypes.CDLL
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _FileAttributeTagInfo(ctypes.Structure):
|
|
49
|
+
"""``FILE_ATTRIBUTE_TAG_INFO``, read by ``GetFileInformationByHandleEx``."""
|
|
50
|
+
|
|
51
|
+
_fields_ = struct_fields((wintypes.DWORD, "file_attributes reparse_tag"))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _FileDispositionInfo(ctypes.Structure):
|
|
55
|
+
"""``FILE_DISPOSITION_INFO``, written by ``SetFileInformationByHandle``."""
|
|
56
|
+
|
|
57
|
+
_fields_ = struct_fields((wintypes.BOOLEAN, "delete_file"))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _SecurityAttributes(ctypes.Structure):
|
|
61
|
+
"""``SECURITY_ATTRIBUTES`` carrying the descriptor a new file is created with."""
|
|
62
|
+
|
|
63
|
+
_fields_ = struct_fields(
|
|
64
|
+
(wintypes.DWORD, "length"),
|
|
65
|
+
(wintypes.LPVOID, "security_descriptor"),
|
|
66
|
+
(wintypes.BOOL, "inherit_handle"),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _AclSizeInformation(ctypes.Structure):
|
|
71
|
+
"""``ACL_SIZE_INFORMATION``, read by ``GetAclInformation``."""
|
|
72
|
+
|
|
73
|
+
_fields_ = struct_fields((wintypes.DWORD, "ace_count acl_bytes_in_use acl_bytes_free"))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _AceHeader(ctypes.Structure):
|
|
77
|
+
"""``ACE_HEADER``, the common prefix of every ACE."""
|
|
78
|
+
|
|
79
|
+
_fields_ = struct_fields((ctypes.c_ubyte, "ace_type ace_flags"), (wintypes.WORD, "ace_size"))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _AccessAllowedAce(ctypes.Structure):
|
|
83
|
+
"""``ACCESS_ALLOWED_ACE``; the trustee SID starts at ``sid_start``."""
|
|
84
|
+
|
|
85
|
+
_fields_ = struct_fields((_AceHeader, "header"), (wintypes.DWORD, "mask sid_start"))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _windows_stat_file_attributes(info: os.stat_result) -> int:
|
|
89
|
+
return cast(Any, info).st_file_attributes
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _windows_osfhandle(fd: int) -> int:
|
|
93
|
+
import msvcrt
|
|
94
|
+
|
|
95
|
+
return cast(Any, msvcrt).get_osfhandle(fd)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _windows_open_osfhandle(handle: int, flags: int) -> int:
|
|
99
|
+
import msvcrt
|
|
100
|
+
|
|
101
|
+
return cast(Any, msvcrt).open_osfhandle(handle, flags)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _posix_effective_uid() -> int:
|
|
105
|
+
return cast(Any, os).geteuid()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _is_windows() -> bool:
|
|
109
|
+
from chrys.foundation.platform import get_platform
|
|
110
|
+
|
|
111
|
+
return get_platform().is_windows
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _is_macos() -> bool:
|
|
115
|
+
from chrys.foundation.platform import get_platform
|
|
116
|
+
|
|
117
|
+
return get_platform().is_macos
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def fsync_directory(path: Path) -> None:
|
|
121
|
+
"""Best-effort fsync of a directory after an atomic rename."""
|
|
122
|
+
if os.name == "nt":
|
|
123
|
+
return
|
|
124
|
+
with contextlib.suppress(OSError):
|
|
125
|
+
fd = os.open(path, os.O_RDONLY)
|
|
126
|
+
try:
|
|
127
|
+
os.fsync(fd)
|
|
128
|
+
finally:
|
|
129
|
+
os.close(fd)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
_fsync_dir = fsync_directory
|
|
133
|
+
"""Backward-compatible alias for callers predating the public helper."""
|
|
134
|
+
|
|
135
|
+
_WINDOWS_REPLACE_MAX_ATTEMPTS = 6
|
|
136
|
+
_WINDOWS_REPLACE_RETRY_DELAY_SECONDS = 0.01
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def replace_with_retry(source: str | os.PathLike[str], target: str | os.PathLike[str]) -> None:
|
|
140
|
+
"""``os.replace`` that tries again briefly on Windows while the target is held open.
|
|
141
|
+
|
|
142
|
+
Antivirus scanners, the search indexer and sync clients open files for a
|
|
143
|
+
moment, and Windows refuses the rename with ``PermissionError`` meanwhile.
|
|
144
|
+
Up to six tries with about 0.3 s of sleeps in all; elsewhere one try.
|
|
145
|
+
"""
|
|
146
|
+
for attempt in range(_WINDOWS_REPLACE_MAX_ATTEMPTS):
|
|
147
|
+
try:
|
|
148
|
+
os.replace(source, target)
|
|
149
|
+
return
|
|
150
|
+
except PermissionError:
|
|
151
|
+
if attempt == _WINDOWS_REPLACE_MAX_ATTEMPTS - 1 or not _is_windows():
|
|
152
|
+
raise
|
|
153
|
+
time.sleep(_WINDOWS_REPLACE_RETRY_DELAY_SECONDS * 2**attempt)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _atomic_write_bytes(path: Path, payload: bytes) -> None:
|
|
157
|
+
"""Atomically write *payload* to *path* via temp-file + ``os.replace``."""
|
|
158
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
fd, tmp_name = tempfile.mkstemp(prefix=f"{path.name}.", suffix=".tmp", dir=path.parent)
|
|
160
|
+
tmp_path = Path(tmp_name)
|
|
161
|
+
try:
|
|
162
|
+
with os.fdopen(fd, "wb") as f:
|
|
163
|
+
f.write(payload)
|
|
164
|
+
f.flush()
|
|
165
|
+
os.fsync(f.fileno())
|
|
166
|
+
replace_with_retry(tmp_path, path)
|
|
167
|
+
fsync_directory(path.parent)
|
|
168
|
+
except BaseException:
|
|
169
|
+
with contextlib.suppress(FileNotFoundError):
|
|
170
|
+
tmp_path.unlink()
|
|
171
|
+
raise
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def atomic_write_text(path: Path, payload: str, *, encoding: str = "utf-8") -> bytes:
|
|
175
|
+
"""Atomically write text to *path* with a durable same-directory replace.
|
|
176
|
+
|
|
177
|
+
``errors="backslashreplace"`` makes the encode TOTAL: a lone surrogate (an
|
|
178
|
+
``os.fsdecode`` artifact of an undecodable filesystem byte, carried through
|
|
179
|
+
``json.dumps(ensure_ascii=False)``) is written as its plain-ASCII ``\\udcXX``
|
|
180
|
+
escape instead of raising ``UnicodeEncodeError``. This is the single sink that
|
|
181
|
+
guarantees no serialized envelope — ACP audit, main session, or fork — can
|
|
182
|
+
crash the write on a surrogate, regardless of which field carries it. A lone
|
|
183
|
+
surrogate reaching a strict write is always an fsdecode artifact, never data
|
|
184
|
+
a caller wants to hard-fail on. See ``surrogate_safe_text`` for the read-back
|
|
185
|
+
semantics (an escaped value re-parses to the surrogate; every write re-escapes).
|
|
186
|
+
|
|
187
|
+
Returns the bytes that landed on disk, for the caller that has to digest
|
|
188
|
+
what it wrote: reading the file back to find out would re-open the race the
|
|
189
|
+
digest exists to close, and re-encoding the payload beside this call would
|
|
190
|
+
duplicate the encoding contract above in a second place that can drift.
|
|
191
|
+
"""
|
|
192
|
+
encoded = payload.encode(encoding, errors="backslashreplace")
|
|
193
|
+
_atomic_write_bytes(path, encoded)
|
|
194
|
+
return encoded
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _atomic_create_bytes(path: Path, payload: bytes) -> None:
|
|
198
|
+
"""Create *path* with *payload* atomically, raising ``FileExistsError``.
|
|
199
|
+
|
|
200
|
+
Same temp-file durability as ``_atomic_write_bytes``, but the commit is
|
|
201
|
+
the platform's atomic no-clobber primitive (``os.rename`` refuses to
|
|
202
|
+
replace on Windows, hard-linking refuses everywhere else), so a file that
|
|
203
|
+
appears after a caller's early existence check is never silently replaced.
|
|
204
|
+
"""
|
|
205
|
+
from chrys.foundation.platform import get_platform
|
|
206
|
+
|
|
207
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
208
|
+
fd, tmp_name = tempfile.mkstemp(prefix=f"{path.name}.", suffix=".tmp", dir=path.parent)
|
|
209
|
+
tmp_path = Path(tmp_name)
|
|
210
|
+
try:
|
|
211
|
+
with os.fdopen(fd, "wb") as f:
|
|
212
|
+
f.write(payload)
|
|
213
|
+
f.flush()
|
|
214
|
+
os.fsync(f.fileno())
|
|
215
|
+
if get_platform().is_windows:
|
|
216
|
+
os.rename(tmp_path, path)
|
|
217
|
+
else:
|
|
218
|
+
# Drop the temp link before the directory sync: syncing while both
|
|
219
|
+
# names exist would let a crash after return resurrect the temp
|
|
220
|
+
# entry, breaking the no-temp-file-on-success durability above.
|
|
221
|
+
os.link(tmp_path, path)
|
|
222
|
+
tmp_path.unlink()
|
|
223
|
+
fsync_directory(path.parent)
|
|
224
|
+
finally:
|
|
225
|
+
with contextlib.suppress(FileNotFoundError):
|
|
226
|
+
tmp_path.unlink()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def atomic_create_text(path: Path, payload: str, *, encoding: str = "utf-8") -> bytes:
|
|
230
|
+
"""Atomically create text at *path*, refusing to replace an existing file.
|
|
231
|
+
|
|
232
|
+
The encoding contract matches ``atomic_write_text`` (total via
|
|
233
|
+
``backslashreplace``); only the commit differs — create-if-absent, so a
|
|
234
|
+
concurrent writer surfaces as ``FileExistsError`` instead of data loss.
|
|
235
|
+
"""
|
|
236
|
+
encoded = payload.encode(encoding, errors="backslashreplace")
|
|
237
|
+
_atomic_create_bytes(path, encoded)
|
|
238
|
+
return encoded
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def file_fingerprint(path: Path) -> str | None:
|
|
242
|
+
"""Digest a file's exact bytes, or ``None`` when it cannot be read.
|
|
243
|
+
|
|
244
|
+
For "is this still the file I read a moment ago?", which is a question
|
|
245
|
+
about identity, not about content: the digest is over raw bytes rather
|
|
246
|
+
than over anything parsed, so it needs no grammar to be shared by two
|
|
247
|
+
readers that do not have one — one may parse the file with the locale
|
|
248
|
+
encoding while the other rewrites it as UTF-8, and duplicate keys,
|
|
249
|
+
quoting, references between lines and line endings are all covered
|
|
250
|
+
without any of them being understood.
|
|
251
|
+
|
|
252
|
+
Any change at all reads as a change, and that asymmetry is the point
|
|
253
|
+
wherever the answer gates a destructive step: an over-sensitive digest
|
|
254
|
+
costs one deferred attempt at work that is idempotent and resumable,
|
|
255
|
+
while an under-sensitive one destroys what the earlier read never saw.
|
|
256
|
+
``None`` — absent *or* unreadable — is the absence of evidence and must
|
|
257
|
+
never be compared as though it were evidence of sameness.
|
|
258
|
+
"""
|
|
259
|
+
try:
|
|
260
|
+
return digest_bytes(path.read_bytes())
|
|
261
|
+
except OSError:
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def digest_bytes(raw: bytes | None) -> str | None:
|
|
266
|
+
"""Digest bytes already in hand the same way :func:`file_fingerprint` does.
|
|
267
|
+
|
|
268
|
+
For the caller that reads the file itself and must compare what it read
|
|
269
|
+
rather than re-reading the path: two reads are two chances for the file to
|
|
270
|
+
have changed in between, and the comparison is only worth making when it
|
|
271
|
+
covers the same bytes the caller is about to act on. ``None`` in, ``None``
|
|
272
|
+
out — an absent file is still the absence of evidence.
|
|
273
|
+
"""
|
|
274
|
+
return None if raw is None else hashlib.sha256(raw).hexdigest()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _validate_link_free_parent(path: Path) -> None:
|
|
278
|
+
"""Reject symlink/reparse-point components in the existing parent chain."""
|
|
279
|
+
resolved = path.absolute()
|
|
280
|
+
current = Path(resolved.anchor)
|
|
281
|
+
for part in resolved.parts[1:-1]:
|
|
282
|
+
current /= part
|
|
283
|
+
try:
|
|
284
|
+
info = current.lstat()
|
|
285
|
+
except FileNotFoundError:
|
|
286
|
+
continue
|
|
287
|
+
if stat.S_ISLNK(info.st_mode):
|
|
288
|
+
msg = f"Secure file parent is a symbolic link: {current}"
|
|
289
|
+
raise SecureFileError(msg)
|
|
290
|
+
if _is_windows() and _windows_stat_file_attributes(info) & stat.FILE_ATTRIBUTE_REPARSE_POINT:
|
|
291
|
+
msg = f"Secure file parent is a reparse point: {current}"
|
|
292
|
+
raise SecureFileError(msg)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _darwin_acl_api() -> _DarwinAclAPI:
|
|
296
|
+
"""Load the small libSystem ACL surface used by owner-only verification."""
|
|
297
|
+
libc = ctypes.CDLL(None, use_errno=True)
|
|
298
|
+
declare_functions(
|
|
299
|
+
libc,
|
|
300
|
+
{
|
|
301
|
+
"acl_get_fd": (ctypes.c_void_p, [ctypes.c_int]),
|
|
302
|
+
"acl_get_entry": (ctypes.c_int, [ctypes.c_void_p, ctypes.c_int, ctypes.POINTER(ctypes.c_void_p)]),
|
|
303
|
+
"acl_init": (ctypes.c_void_p, [ctypes.c_int]),
|
|
304
|
+
"acl_set_fd": (ctypes.c_int, [ctypes.c_int, ctypes.c_void_p]),
|
|
305
|
+
"acl_free": (ctypes.c_int, [ctypes.c_void_p]),
|
|
306
|
+
},
|
|
307
|
+
)
|
|
308
|
+
return _DarwinAclAPI(libc=libc)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _verify_darwin_no_acl(fd: int) -> None:
|
|
312
|
+
"""Reject files carrying any extended ACL entry.
|
|
313
|
+
|
|
314
|
+
macOS NFSv4-style ACLs grant access invisibly to ``st_mode`` — a 0600
|
|
315
|
+
file with ``everyone allow read`` still fstats as owner-only. (Linux
|
|
316
|
+
needs no analog: a POSIX.1e ACL surfaces its mask in the group bits, so
|
|
317
|
+
the exact-0600 check already rejects it.)
|
|
318
|
+
"""
|
|
319
|
+
api = _darwin_acl_api()
|
|
320
|
+
ctypes.set_errno(0)
|
|
321
|
+
acl = api.libc.acl_get_fd(fd)
|
|
322
|
+
if not acl:
|
|
323
|
+
error = ctypes.get_errno()
|
|
324
|
+
# ENOENT: no ACL on the file; ENOTSUP family: the filesystem cannot
|
|
325
|
+
# carry one at all — both mean no principal beyond the owner.
|
|
326
|
+
if error in {errno.ENOENT, errno.ENOTSUP, errno.EOPNOTSUPP}:
|
|
327
|
+
return
|
|
328
|
+
raise SecureFileError(error, "Unable to inspect the secure file ACL.")
|
|
329
|
+
try:
|
|
330
|
+
entry = ctypes.c_void_p()
|
|
331
|
+
ctypes.set_errno(0)
|
|
332
|
+
got = api.libc.acl_get_entry(acl, 0, ctypes.byref(entry))
|
|
333
|
+
if got == 0:
|
|
334
|
+
raise SecureFileError("Secure file carries an extended ACL.")
|
|
335
|
+
error = ctypes.get_errno()
|
|
336
|
+
# Exhausted enumeration is exactly -1/EINVAL; any other combination is
|
|
337
|
+
# an inspection failure and must fail closed, not read as "no ACL".
|
|
338
|
+
if got != -1 or error != errno.EINVAL:
|
|
339
|
+
raise SecureFileError(error, "Unable to enumerate the secure file ACL.")
|
|
340
|
+
finally:
|
|
341
|
+
api.libc.acl_free(acl)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _strip_darwin_acl(fd: int) -> None:
|
|
345
|
+
"""Remove inherited ACL entries from a freshly created secure file.
|
|
346
|
+
|
|
347
|
+
A parent directory with ``file_inherit`` entries stamps them onto every
|
|
348
|
+
new file at creation, so create paths must strip before verification —
|
|
349
|
+
rejection alone would make secure files impossible to create there.
|
|
350
|
+
"""
|
|
351
|
+
api = _darwin_acl_api()
|
|
352
|
+
empty = api.libc.acl_init(1)
|
|
353
|
+
if not empty:
|
|
354
|
+
raise SecureFileError("Unable to allocate an empty ACL.")
|
|
355
|
+
try:
|
|
356
|
+
ctypes.set_errno(0)
|
|
357
|
+
if api.libc.acl_set_fd(fd, empty) != 0:
|
|
358
|
+
error = ctypes.get_errno()
|
|
359
|
+
# Filesystems without ACL support have nothing to strip; the
|
|
360
|
+
# verifier's ENOENT branch accepts them.
|
|
361
|
+
if error not in {errno.ENOTSUP, errno.EOPNOTSUPP}:
|
|
362
|
+
raise SecureFileError(error, "Unable to strip the inherited file ACL.")
|
|
363
|
+
finally:
|
|
364
|
+
api.libc.acl_free(empty)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _strip_created_posix_acl(fd: int) -> None:
|
|
368
|
+
if _is_macos():
|
|
369
|
+
_strip_darwin_acl(fd)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def strip_inherited_acl(fd: int) -> None:
|
|
373
|
+
"""Drop the ACL entries a file just created inherited from its directory.
|
|
374
|
+
|
|
375
|
+
On macOS a directory's inheritable entries (``everyone allow read,
|
|
376
|
+
file_inherit``) grant access beside ``st_mode``, so a private mode alone
|
|
377
|
+
does not make a new file private. Linux needs nothing (its POSIX.1e mask
|
|
378
|
+
follows the group bits a private mode clears), nor does Windows here or a
|
|
379
|
+
filesystem without ACLs.
|
|
380
|
+
"""
|
|
381
|
+
_strip_created_posix_acl(fd)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def strip_inherited_directory_acl(path: Path) -> None:
|
|
385
|
+
"""``strip_inherited_acl`` for a directory just created at *path*.
|
|
386
|
+
|
|
387
|
+
Strip it before creating anything inside: whatever is created in a
|
|
388
|
+
directory without an ACL inherits none.
|
|
389
|
+
"""
|
|
390
|
+
if not _is_macos():
|
|
391
|
+
return
|
|
392
|
+
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
|
|
393
|
+
fd = os.open(path, flags)
|
|
394
|
+
try:
|
|
395
|
+
_strip_darwin_acl(fd)
|
|
396
|
+
finally:
|
|
397
|
+
os.close(fd)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _verify_posix_owner_only(fd: int) -> None:
|
|
401
|
+
_verify_posix_owner_identity(fd)
|
|
402
|
+
info = os.fstat(fd)
|
|
403
|
+
if stat.S_IMODE(info.st_mode) != _OWNER_ONLY_MODE:
|
|
404
|
+
raise SecureFileError("Secure file permissions are not owner-only.")
|
|
405
|
+
if _is_macos():
|
|
406
|
+
_verify_darwin_no_acl(fd)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _verify_posix_owner_identity(fd: int) -> None:
|
|
410
|
+
info = os.fstat(fd)
|
|
411
|
+
if not stat.S_ISREG(info.st_mode):
|
|
412
|
+
raise SecureFileError("Secure file is not a regular file.")
|
|
413
|
+
if info.st_uid != _posix_effective_uid():
|
|
414
|
+
raise SecureFileError("Secure file is not owned by the current user.")
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _clear_posix_nonblock(fd: int) -> None:
|
|
418
|
+
import fcntl
|
|
419
|
+
|
|
420
|
+
posix_fcntl = cast(Any, fcntl)
|
|
421
|
+
posix_os = cast(Any, os)
|
|
422
|
+
status = posix_fcntl.fcntl(fd, posix_fcntl.F_GETFL)
|
|
423
|
+
if status & posix_os.O_NONBLOCK:
|
|
424
|
+
posix_fcntl.fcntl(fd, posix_fcntl.F_SETFL, status & ~posix_os.O_NONBLOCK)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _open_posix_parent(path: Path) -> tuple[int, str]:
|
|
428
|
+
"""Open each existing parent component with no-follow directory handles."""
|
|
429
|
+
absolute = path.absolute()
|
|
430
|
+
if not absolute.name:
|
|
431
|
+
raise SecureFileError("Secure file path has no filename.")
|
|
432
|
+
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_CLOEXEC", 0)
|
|
433
|
+
flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
434
|
+
directory_fd = os.open(absolute.anchor, flags)
|
|
435
|
+
try:
|
|
436
|
+
for component in absolute.parts[1:-1]:
|
|
437
|
+
next_fd = os.open(component, flags, dir_fd=directory_fd)
|
|
438
|
+
os.close(directory_fd)
|
|
439
|
+
directory_fd = next_fd
|
|
440
|
+
except BaseException:
|
|
441
|
+
os.close(directory_fd)
|
|
442
|
+
raise
|
|
443
|
+
return directory_fd, absolute.name
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
@cache
|
|
447
|
+
def _windows_file_api() -> _WindowsFileAPI:
|
|
448
|
+
"""Load the small Win32 security API surface used by secure files."""
|
|
449
|
+
windows_ctypes = cast(Any, ctypes)
|
|
450
|
+
advapi32 = windows_ctypes.WinDLL("advapi32", use_last_error=True)
|
|
451
|
+
kernel32 = windows_ctypes.WinDLL("kernel32", use_last_error=True)
|
|
452
|
+
out_pointer = ctypes.POINTER(wintypes.LPVOID)
|
|
453
|
+
declare_functions(
|
|
454
|
+
advapi32,
|
|
455
|
+
{
|
|
456
|
+
"ConvertStringSecurityDescriptorToSecurityDescriptorW": (
|
|
457
|
+
wintypes.BOOL,
|
|
458
|
+
[wintypes.LPCWSTR, wintypes.DWORD, out_pointer, ctypes.POINTER(wintypes.ULONG)],
|
|
459
|
+
),
|
|
460
|
+
# The five out-pointers receive the owner, group, DACL, SACL and security descriptor.
|
|
461
|
+
"GetSecurityInfo": (wintypes.DWORD, [wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD] + [out_pointer] * 5),
|
|
462
|
+
"GetTokenInformation": (
|
|
463
|
+
wintypes.BOOL,
|
|
464
|
+
[wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID, wintypes.DWORD, ctypes.POINTER(wintypes.DWORD)],
|
|
465
|
+
),
|
|
466
|
+
"OpenProcessToken": (wintypes.BOOL, [wintypes.HANDLE, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE)]),
|
|
467
|
+
"ConvertSidToStringSidW": (wintypes.BOOL, [wintypes.LPVOID, ctypes.POINTER(wintypes.LPWSTR)]),
|
|
468
|
+
"EqualSid": (wintypes.BOOL, [wintypes.LPVOID, wintypes.LPVOID]),
|
|
469
|
+
"GetAclInformation": (wintypes.BOOL, [wintypes.LPVOID, wintypes.LPVOID, wintypes.DWORD, ctypes.c_int]),
|
|
470
|
+
"GetAce": (wintypes.BOOL, [wintypes.LPVOID, wintypes.DWORD, out_pointer]),
|
|
471
|
+
"GetSecurityDescriptorControl": (
|
|
472
|
+
wintypes.BOOL,
|
|
473
|
+
[wintypes.LPVOID, ctypes.POINTER(wintypes.WORD), ctypes.POINTER(wintypes.DWORD)],
|
|
474
|
+
),
|
|
475
|
+
},
|
|
476
|
+
)
|
|
477
|
+
declare_functions(
|
|
478
|
+
kernel32,
|
|
479
|
+
{
|
|
480
|
+
"GetCurrentProcess": (wintypes.HANDLE, []),
|
|
481
|
+
"LocalFree": (wintypes.HLOCAL, [wintypes.HLOCAL]),
|
|
482
|
+
"CreateFileW": (
|
|
483
|
+
wintypes.HANDLE,
|
|
484
|
+
[
|
|
485
|
+
wintypes.LPCWSTR,
|
|
486
|
+
wintypes.DWORD,
|
|
487
|
+
wintypes.DWORD,
|
|
488
|
+
wintypes.LPVOID,
|
|
489
|
+
wintypes.DWORD,
|
|
490
|
+
wintypes.DWORD,
|
|
491
|
+
wintypes.HANDLE,
|
|
492
|
+
],
|
|
493
|
+
),
|
|
494
|
+
"GetFileInformationByHandleEx": (
|
|
495
|
+
wintypes.BOOL,
|
|
496
|
+
[wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID, wintypes.DWORD],
|
|
497
|
+
),
|
|
498
|
+
"GetFinalPathNameByHandleW": (
|
|
499
|
+
wintypes.DWORD,
|
|
500
|
+
[wintypes.HANDLE, wintypes.LPWSTR, wintypes.DWORD, wintypes.DWORD],
|
|
501
|
+
),
|
|
502
|
+
"SetFileInformationByHandle": (
|
|
503
|
+
wintypes.BOOL,
|
|
504
|
+
[wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID, wintypes.DWORD],
|
|
505
|
+
),
|
|
506
|
+
"CloseHandle": (wintypes.BOOL, [wintypes.HANDLE]),
|
|
507
|
+
},
|
|
508
|
+
)
|
|
509
|
+
return _WindowsFileAPI(advapi32=advapi32, kernel32=kernel32)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _windows_error(api: _WindowsFileAPI, message: str) -> SecureFileError:
|
|
513
|
+
error = cast(Any, ctypes).get_last_error()
|
|
514
|
+
return SecureFileError(error, message)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _windows_current_user_sid(api: _WindowsFileAPI) -> tuple[object, object, object]:
|
|
518
|
+
"""Return a token handle, owning buffer, and pointer to the current user SID."""
|
|
519
|
+
TOKEN_QUERY = 0x0008
|
|
520
|
+
TOKEN_USER_CLASS = 1
|
|
521
|
+
|
|
522
|
+
token = wintypes.HANDLE()
|
|
523
|
+
if not api.advapi32.OpenProcessToken(api.kernel32.GetCurrentProcess(), TOKEN_QUERY, ctypes.byref(token)):
|
|
524
|
+
raise _windows_error(api, "Unable to inspect the current Windows user token.")
|
|
525
|
+
needed = wintypes.DWORD()
|
|
526
|
+
api.advapi32.GetTokenInformation(token, TOKEN_USER_CLASS, None, 0, ctypes.byref(needed))
|
|
527
|
+
if not needed.value:
|
|
528
|
+
api.kernel32.CloseHandle(token)
|
|
529
|
+
raise _windows_error(api, "Unable to size the current Windows user token.")
|
|
530
|
+
buffer = ctypes.create_string_buffer(needed.value)
|
|
531
|
+
if not api.advapi32.GetTokenInformation(
|
|
532
|
+
token,
|
|
533
|
+
TOKEN_USER_CLASS,
|
|
534
|
+
buffer,
|
|
535
|
+
needed,
|
|
536
|
+
ctypes.byref(needed),
|
|
537
|
+
):
|
|
538
|
+
api.kernel32.CloseHandle(token)
|
|
539
|
+
raise _windows_error(api, "Unable to inspect the current Windows user token.")
|
|
540
|
+
sid = ctypes.cast(buffer, ctypes.POINTER(wintypes.LPVOID)).contents
|
|
541
|
+
return token, buffer, sid
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _windows_owner_only_descriptor(api: _WindowsFileAPI) -> object:
|
|
545
|
+
"""Allocate a protected owner-only descriptor for secure file creation."""
|
|
546
|
+
token, _token_buffer, sid = _windows_current_user_sid(api)
|
|
547
|
+
sid_text = wintypes.LPWSTR()
|
|
548
|
+
descriptor = wintypes.LPVOID()
|
|
549
|
+
try:
|
|
550
|
+
if not api.advapi32.ConvertSidToStringSidW(sid, ctypes.byref(sid_text)):
|
|
551
|
+
raise _windows_error(api, "Unable to encode the current Windows user SID.")
|
|
552
|
+
# The explicit O: owner is required: tokens whose default owner is a
|
|
553
|
+
# group (for example Administrators) would otherwise create files that
|
|
554
|
+
# our own owner==TokenUser verification immediately rejects.
|
|
555
|
+
sddl = f"O:{sid_text.value}D:P(A;;FA;;;{sid_text.value})"
|
|
556
|
+
if not api.advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
|
557
|
+
sddl,
|
|
558
|
+
1,
|
|
559
|
+
ctypes.byref(descriptor),
|
|
560
|
+
None,
|
|
561
|
+
):
|
|
562
|
+
raise _windows_error(api, "Unable to build an owner-only Windows DACL.")
|
|
563
|
+
return descriptor
|
|
564
|
+
finally:
|
|
565
|
+
api.kernel32.CloseHandle(token)
|
|
566
|
+
if sid_text:
|
|
567
|
+
api.kernel32.LocalFree(sid_text)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _verify_windows_owner_only(fd: int) -> None:
|
|
571
|
+
"""Verify owner identity and reject every allow ACE for another SID."""
|
|
572
|
+
api = _windows_file_api()
|
|
573
|
+
token, _token_buffer, current_sid = _windows_current_user_sid(api)
|
|
574
|
+
security_descriptor = wintypes.LPVOID()
|
|
575
|
+
try:
|
|
576
|
+
owner_sid = wintypes.LPVOID()
|
|
577
|
+
dacl = wintypes.LPVOID()
|
|
578
|
+
SE_FILE_OBJECT = 1
|
|
579
|
+
OWNER_SECURITY_INFORMATION = 0x00000001
|
|
580
|
+
DACL_SECURITY_INFORMATION = 0x00000004
|
|
581
|
+
handle = wintypes.HANDLE(_windows_osfhandle(fd))
|
|
582
|
+
|
|
583
|
+
file_info = _FileAttributeTagInfo()
|
|
584
|
+
if not api.kernel32.GetFileInformationByHandleEx(
|
|
585
|
+
handle,
|
|
586
|
+
9,
|
|
587
|
+
ctypes.byref(file_info),
|
|
588
|
+
ctypes.sizeof(file_info),
|
|
589
|
+
):
|
|
590
|
+
raise _windows_error(api, "Unable to inspect the secure Windows file.")
|
|
591
|
+
if file_info.file_attributes & (0x00000010 | 0x00000400):
|
|
592
|
+
raise SecureFileError("Secure Windows file is a directory or reparse point.")
|
|
593
|
+
|
|
594
|
+
result = api.advapi32.GetSecurityInfo(
|
|
595
|
+
handle,
|
|
596
|
+
SE_FILE_OBJECT,
|
|
597
|
+
OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
|
|
598
|
+
ctypes.byref(owner_sid),
|
|
599
|
+
None,
|
|
600
|
+
ctypes.byref(dacl),
|
|
601
|
+
None,
|
|
602
|
+
ctypes.byref(security_descriptor),
|
|
603
|
+
)
|
|
604
|
+
if result:
|
|
605
|
+
raise SecureFileError(result, "Unable to verify the Windows file DACL.")
|
|
606
|
+
if not owner_sid.value or not api.advapi32.EqualSid(owner_sid, current_sid):
|
|
607
|
+
raise SecureFileError("Secure file is not owned by the current Windows user.")
|
|
608
|
+
if not dacl.value:
|
|
609
|
+
raise SecureFileError("Secure file has no Windows DACL.")
|
|
610
|
+
control = wintypes.WORD()
|
|
611
|
+
revision = wintypes.DWORD()
|
|
612
|
+
if not api.advapi32.GetSecurityDescriptorControl(
|
|
613
|
+
security_descriptor,
|
|
614
|
+
ctypes.byref(control),
|
|
615
|
+
ctypes.byref(revision),
|
|
616
|
+
):
|
|
617
|
+
raise _windows_error(api, "Unable to inspect Windows DACL inheritance.")
|
|
618
|
+
if not control.value & 0x1000:
|
|
619
|
+
raise SecureFileError("Secure file Windows DACL is not protected from inheritance.")
|
|
620
|
+
|
|
621
|
+
ACL_SIZE_INFORMATION_CLASS = 2
|
|
622
|
+
ACCESS_ALLOWED_ACE_TYPE = 0
|
|
623
|
+
OTHER_ALLOW_ACE_TYPES = frozenset({4, 5, 9, 11})
|
|
624
|
+
info = _AclSizeInformation()
|
|
625
|
+
if not api.advapi32.GetAclInformation(
|
|
626
|
+
dacl,
|
|
627
|
+
ctypes.byref(info),
|
|
628
|
+
ctypes.sizeof(info),
|
|
629
|
+
ACL_SIZE_INFORMATION_CLASS,
|
|
630
|
+
):
|
|
631
|
+
raise _windows_error(api, "Unable to inspect the Windows file DACL.")
|
|
632
|
+
|
|
633
|
+
owner_allow = False
|
|
634
|
+
for index in range(info.ace_count):
|
|
635
|
+
raw_ace = wintypes.LPVOID()
|
|
636
|
+
if not api.advapi32.GetAce(dacl, index, ctypes.byref(raw_ace)):
|
|
637
|
+
raise _windows_error(api, "Unable to inspect a Windows file DACL entry.")
|
|
638
|
+
header = ctypes.cast(raw_ace, ctypes.POINTER(_AceHeader)).contents
|
|
639
|
+
if header.ace_type in OTHER_ALLOW_ACE_TYPES:
|
|
640
|
+
raise SecureFileError("Secure file DACL contains an unsupported allow entry.")
|
|
641
|
+
if header.ace_type != ACCESS_ALLOWED_ACE_TYPE:
|
|
642
|
+
continue
|
|
643
|
+
sid_address = cast(int, raw_ace.value) + _AccessAllowedAce.sid_start.offset
|
|
644
|
+
ace_sid = wintypes.LPVOID(sid_address)
|
|
645
|
+
if not api.advapi32.EqualSid(ace_sid, current_sid):
|
|
646
|
+
raise SecureFileError("Secure file DACL grants access to another Windows principal.")
|
|
647
|
+
owner_allow = True
|
|
648
|
+
if not owner_allow:
|
|
649
|
+
raise SecureFileError("Secure file DACL does not grant access to its owner.")
|
|
650
|
+
finally:
|
|
651
|
+
api.kernel32.CloseHandle(token)
|
|
652
|
+
if security_descriptor:
|
|
653
|
+
api.kernel32.LocalFree(security_descriptor)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def verify_owner_only_fd(fd: int) -> None:
|
|
657
|
+
"""Verify that *fd* identifies a regular file accessible only by its owner."""
|
|
658
|
+
if _is_windows():
|
|
659
|
+
_verify_windows_owner_only(fd)
|
|
660
|
+
else:
|
|
661
|
+
_verify_posix_owner_only(fd)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _verify_windows_owner_identity(fd: int) -> None:
|
|
665
|
+
"""Verify only file kind and owner identity, tolerating any DACL."""
|
|
666
|
+
api = _windows_file_api()
|
|
667
|
+
token, _token_buffer, current_sid = _windows_current_user_sid(api)
|
|
668
|
+
security_descriptor = wintypes.LPVOID()
|
|
669
|
+
try:
|
|
670
|
+
owner_sid = wintypes.LPVOID()
|
|
671
|
+
SE_FILE_OBJECT = 1
|
|
672
|
+
OWNER_SECURITY_INFORMATION = 0x00000001
|
|
673
|
+
handle = wintypes.HANDLE(_windows_osfhandle(fd))
|
|
674
|
+
|
|
675
|
+
file_info = _FileAttributeTagInfo()
|
|
676
|
+
if not api.kernel32.GetFileInformationByHandleEx(
|
|
677
|
+
handle,
|
|
678
|
+
9,
|
|
679
|
+
ctypes.byref(file_info),
|
|
680
|
+
ctypes.sizeof(file_info),
|
|
681
|
+
):
|
|
682
|
+
raise _windows_error(api, "Unable to inspect the secure Windows file.")
|
|
683
|
+
if file_info.file_attributes & (0x00000010 | 0x00000400):
|
|
684
|
+
raise SecureFileError("Secure Windows file is a directory or reparse point.")
|
|
685
|
+
|
|
686
|
+
result = api.advapi32.GetSecurityInfo(
|
|
687
|
+
handle,
|
|
688
|
+
SE_FILE_OBJECT,
|
|
689
|
+
OWNER_SECURITY_INFORMATION,
|
|
690
|
+
ctypes.byref(owner_sid),
|
|
691
|
+
None,
|
|
692
|
+
None,
|
|
693
|
+
None,
|
|
694
|
+
ctypes.byref(security_descriptor),
|
|
695
|
+
)
|
|
696
|
+
if result:
|
|
697
|
+
raise SecureFileError(result, "Unable to verify the Windows file owner.")
|
|
698
|
+
if not owner_sid.value or not api.advapi32.EqualSid(owner_sid, current_sid):
|
|
699
|
+
raise SecureFileError("Secure file is not owned by the current Windows user.")
|
|
700
|
+
finally:
|
|
701
|
+
api.kernel32.CloseHandle(token)
|
|
702
|
+
if security_descriptor:
|
|
703
|
+
api.kernel32.LocalFree(security_descriptor)
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def verify_owner_identity_fd(fd: int) -> None:
|
|
707
|
+
"""Verify regular-file kind and owner identity without permission checks.
|
|
708
|
+
|
|
709
|
+
Legacy artifacts written before the owner-only writers existed carry
|
|
710
|
+
default permissions/DACLs; source reads stay no-follow and owner-checked
|
|
711
|
+
but must not reject them on mode alone.
|
|
712
|
+
"""
|
|
713
|
+
if _is_windows():
|
|
714
|
+
_verify_windows_owner_identity(fd)
|
|
715
|
+
else:
|
|
716
|
+
_verify_posix_owner_identity(fd)
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _windows_final_path(api: _WindowsFileAPI, handle: int) -> str:
|
|
720
|
+
"""Resolve the opened handle's final DOS path for identity verification."""
|
|
721
|
+
size = 32768
|
|
722
|
+
buffer = ctypes.create_unicode_buffer(size)
|
|
723
|
+
length = api.kernel32.GetFinalPathNameByHandleW(handle, buffer, size, 0)
|
|
724
|
+
if not length or length >= size:
|
|
725
|
+
raise _windows_error(api, "Unable to resolve the secure Windows file path.")
|
|
726
|
+
text = buffer.value
|
|
727
|
+
if text.startswith("\\\\?\\UNC\\"):
|
|
728
|
+
return "\\\\" + text[8:]
|
|
729
|
+
if text.startswith("\\\\?\\"):
|
|
730
|
+
return text[4:]
|
|
731
|
+
return text
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _windows_os_error(code: int, path: Path) -> OSError:
|
|
735
|
+
"""Build the ``OSError`` Python raises for a failed Win32 file call on *path*.
|
|
736
|
+
|
|
737
|
+
The Win32 code sets ``winerror`` and maps to the POSIX ``errno`` (so an
|
|
738
|
+
access denial is a ``PermissionError`` and a missing parent is ENOENT),
|
|
739
|
+
and the message is the system's own text.
|
|
740
|
+
"""
|
|
741
|
+
return OSError(None, cast(Any, ctypes).FormatError(code).strip(), str(path), code)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
_WINDOWS_FILE_ATTRIBUTE_DIRECTORY = 0x00000010
|
|
745
|
+
_WINDOWS_FILE_ATTRIBUTE_REPARSE_POINT = 0x00000400
|
|
746
|
+
_WINDOWS_REPARSE_TAG_NAME_SURROGATE = 0x20000000
|
|
747
|
+
_WINDOWS_SHARE_READ = 0x00000001
|
|
748
|
+
_WINDOWS_SHARE_WRITE = 0x00000002
|
|
749
|
+
_WINDOWS_SHARE_DELETE = 0x00000004
|
|
750
|
+
WINDOWS_SECURE_OPEN_SHARE_MODE = _WINDOWS_SHARE_READ | _WINDOWS_SHARE_WRITE | _WINDOWS_SHARE_DELETE
|
|
751
|
+
"""Share mode for every secure Windows open.
|
|
752
|
+
|
|
753
|
+
``FILE_SHARE_DELETE`` is load-bearing: it is what lets an append-only log be
|
|
754
|
+
*deleted* while a stuck writer still holds it, which is how the tombstone
|
|
755
|
+
sweep removes a session directory the writer has not let go of. CPython's
|
|
756
|
+
default ``open()`` omits it, and without it the sweep could only wait.
|
|
757
|
+
|
|
758
|
+
It does not make the ancestors renamable: Windows refuses to rename a
|
|
759
|
+
directory that has an open child whatever the child's share mode, which is
|
|
760
|
+
why logical deletion falls back to a recorded intent (see
|
|
761
|
+
``service/trajectory/tombstone.py``) instead of the rename POSIX allows.
|
|
762
|
+
"""
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _windows_secure_open(
|
|
766
|
+
path: Path,
|
|
767
|
+
*,
|
|
768
|
+
read: bool,
|
|
769
|
+
write: bool,
|
|
770
|
+
create: bool,
|
|
771
|
+
truncate: bool,
|
|
772
|
+
delete: bool = False,
|
|
773
|
+
append: bool = False,
|
|
774
|
+
verify_identity_only: bool = False,
|
|
775
|
+
legacy_import: bool = False,
|
|
776
|
+
) -> int:
|
|
777
|
+
"""Open a Windows file handle without traversing a final reparse point.
|
|
778
|
+
|
|
779
|
+
``legacy_import`` opens a regular file of any owner for
|
|
780
|
+
``secure_open_regular_binary``: open failures are raised as Windows
|
|
781
|
+
reports them, and a reparse point is refused only when it names another
|
|
782
|
+
file (a symbolic link, a junction), since cloud placeholders, deduplicated
|
|
783
|
+
and WOF-compressed files keep their data behind one.
|
|
784
|
+
"""
|
|
785
|
+
api = _windows_file_api()
|
|
786
|
+
access = (0x80000000 if read else 0) | (0x40000000 if write else 0) | (0x00010000 if delete else 0)
|
|
787
|
+
share = WINDOWS_SECURE_OPEN_SHARE_MODE
|
|
788
|
+
disposition = 1 if create else 3
|
|
789
|
+
flags = 0x00000080 | 0x00200000
|
|
790
|
+
security_descriptor = _windows_owner_only_descriptor(api) if create else None
|
|
791
|
+
|
|
792
|
+
security_attributes = None
|
|
793
|
+
if security_descriptor is not None:
|
|
794
|
+
security_attributes = _SecurityAttributes(
|
|
795
|
+
ctypes.sizeof(_SecurityAttributes),
|
|
796
|
+
security_descriptor,
|
|
797
|
+
False,
|
|
798
|
+
)
|
|
799
|
+
create_error = 0
|
|
800
|
+
try:
|
|
801
|
+
handle = api.kernel32.CreateFileW(
|
|
802
|
+
str(path),
|
|
803
|
+
access,
|
|
804
|
+
share,
|
|
805
|
+
ctypes.byref(security_attributes) if security_attributes is not None else None,
|
|
806
|
+
disposition,
|
|
807
|
+
flags,
|
|
808
|
+
None,
|
|
809
|
+
)
|
|
810
|
+
if handle == wintypes.HANDLE(-1).value:
|
|
811
|
+
create_error = cast(Any, ctypes).get_last_error()
|
|
812
|
+
finally:
|
|
813
|
+
if security_descriptor is not None:
|
|
814
|
+
api.kernel32.LocalFree(security_descriptor)
|
|
815
|
+
if handle == wintypes.HANDLE(-1).value:
|
|
816
|
+
if legacy_import:
|
|
817
|
+
raise _windows_os_error(create_error, path)
|
|
818
|
+
raise SecureFileError(create_error, "Unable to open owner-only Windows file.")
|
|
819
|
+
|
|
820
|
+
try:
|
|
821
|
+
info = _FileAttributeTagInfo()
|
|
822
|
+
if not api.kernel32.GetFileInformationByHandleEx(
|
|
823
|
+
handle,
|
|
824
|
+
9,
|
|
825
|
+
ctypes.byref(info),
|
|
826
|
+
ctypes.sizeof(info),
|
|
827
|
+
):
|
|
828
|
+
raise _windows_error(api, "Unable to inspect owner-only Windows file.")
|
|
829
|
+
refused_reparse = info.file_attributes & _WINDOWS_FILE_ATTRIBUTE_REPARSE_POINT and (
|
|
830
|
+
not legacy_import or info.reparse_tag & _WINDOWS_REPARSE_TAG_NAME_SURROGATE
|
|
831
|
+
)
|
|
832
|
+
if info.file_attributes & _WINDOWS_FILE_ATTRIBUTE_DIRECTORY or refused_reparse:
|
|
833
|
+
raise SecureFileError("Secure Windows file is a directory or reparse point.")
|
|
834
|
+
# The pathname-based parent walk is racy against junction swaps;
|
|
835
|
+
# re-verify by handle that traversal landed on the validated path.
|
|
836
|
+
final_path = _windows_final_path(api, handle)
|
|
837
|
+
if ntpath.normcase(final_path) != ntpath.normcase(ntpath.abspath(str(path))):
|
|
838
|
+
raise SecureFileError("Secure Windows file resolved outside its validated parents.")
|
|
839
|
+
fd_flags = os.O_RDONLY if read and not write else os.O_WRONLY
|
|
840
|
+
if read and write:
|
|
841
|
+
fd_flags = os.O_RDWR
|
|
842
|
+
fd_flags |= getattr(os, "O_NOINHERIT", 0)
|
|
843
|
+
# Binary, always: the CRT would otherwise adopt the handle in text
|
|
844
|
+
# mode and expand every LF to CRLF behind the caller's back, so a
|
|
845
|
+
# writer that counts the bytes it handed over counts short.
|
|
846
|
+
fd_flags |= getattr(os, "O_BINARY", 0)
|
|
847
|
+
if append:
|
|
848
|
+
# The CRT honours ``_O_APPEND`` on an adopted handle: every write
|
|
849
|
+
# seeks to end first, matching POSIX ``O_APPEND`` for one writer.
|
|
850
|
+
fd_flags |= os.O_APPEND
|
|
851
|
+
fd = _windows_open_osfhandle(int(handle), fd_flags)
|
|
852
|
+
except BaseException:
|
|
853
|
+
api.kernel32.CloseHandle(handle)
|
|
854
|
+
raise
|
|
855
|
+
try:
|
|
856
|
+
if legacy_import:
|
|
857
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
858
|
+
raise SecureFileError("Secure file is not a regular file.")
|
|
859
|
+
elif verify_identity_only:
|
|
860
|
+
verify_owner_identity_fd(fd)
|
|
861
|
+
else:
|
|
862
|
+
verify_owner_only_fd(fd)
|
|
863
|
+
if truncate:
|
|
864
|
+
os.ftruncate(fd, 0)
|
|
865
|
+
except BaseException:
|
|
866
|
+
os.close(fd)
|
|
867
|
+
raise
|
|
868
|
+
return fd
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _windows_secure_unlink(path: Path, identity: os.stat_result | None) -> None:
|
|
872
|
+
"""Best-effort deletion through a fully verified handle, never by name.
|
|
873
|
+
|
|
874
|
+
Cleanup after a failed atomic publish must not re-traverse the parent by
|
|
875
|
+
name: a junction swapped in behind ``os.replace`` would redirect a
|
|
876
|
+
name-based unlink onto an unrelated same-named file. Open with DELETE
|
|
877
|
+
access under the full secure-open validation (link-free parent walk,
|
|
878
|
+
reparse rejection, final-path identity, owner-only DACL), require the
|
|
879
|
+
opened object to be the exact file this writer created (rename preserves
|
|
880
|
+
the volume/file id *identity* captured from the temp fd — a same-user
|
|
881
|
+
replacement would pass every DACL check), and only then mark the verified
|
|
882
|
+
handle for disposal. If any gate fails the artifact is left in place —
|
|
883
|
+
every consumer re-verifies through secure_open on each read, so
|
|
884
|
+
unreachable debris is inert while a wrong deletion is unrecoverable.
|
|
885
|
+
"""
|
|
886
|
+
if identity is None:
|
|
887
|
+
return
|
|
888
|
+
try:
|
|
889
|
+
_validate_link_free_parent(path)
|
|
890
|
+
fd = _windows_secure_open(path, read=True, write=False, create=False, truncate=False, delete=True)
|
|
891
|
+
except SecureFileError, OSError:
|
|
892
|
+
return
|
|
893
|
+
api = _windows_file_api()
|
|
894
|
+
|
|
895
|
+
info = _FileDispositionInfo(True)
|
|
896
|
+
FILE_DISPOSITION_INFO_CLASS = 4
|
|
897
|
+
handle = wintypes.HANDLE(_windows_osfhandle(fd))
|
|
898
|
+
try:
|
|
899
|
+
with contextlib.suppress(OSError):
|
|
900
|
+
entry = os.fstat(fd)
|
|
901
|
+
if entry.st_dev != identity.st_dev or entry.st_ino != identity.st_ino:
|
|
902
|
+
return
|
|
903
|
+
api.kernel32.SetFileInformationByHandle(
|
|
904
|
+
handle,
|
|
905
|
+
FILE_DISPOSITION_INFO_CLASS,
|
|
906
|
+
ctypes.byref(info),
|
|
907
|
+
ctypes.sizeof(info),
|
|
908
|
+
)
|
|
909
|
+
finally:
|
|
910
|
+
os.close(fd)
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def secure_unlink_owner_verified(path: Path) -> bool:
|
|
914
|
+
"""Best-effort deletion of an owner-verified artifact, never by bare name.
|
|
915
|
+
|
|
916
|
+
A bare ``path.unlink()`` re-traverses every parent component by name; a
|
|
917
|
+
directory swapped for a symlink/junction between an artifact's verified
|
|
918
|
+
read and its deletion would redirect the unlink onto an unrelated
|
|
919
|
+
same-named file. Reach the parent through the no-follow component walk
|
|
920
|
+
and delete via ``dir_fd`` (POSIX) or a verified handle (Windows), after
|
|
921
|
+
re-checking the target is a regular file owned by the current user.
|
|
922
|
+
Owner-*verified* (not owner-only) on purpose: this deletes the same
|
|
923
|
+
legacy artifacts ``secure_open_owner_verified_binary`` reads. If any
|
|
924
|
+
gate fails the artifact is left in place and ``False`` is returned —
|
|
925
|
+
leftover debris is re-verified by every consumer while a wrong deletion
|
|
926
|
+
is unrecoverable.
|
|
927
|
+
"""
|
|
928
|
+
return _owner_verified_unlink(path, unlink=True)
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def can_unlink_owner_verified(path: Path) -> bool:
|
|
932
|
+
"""Whether :func:`secure_unlink_owner_verified` would delete *path*: the same gates, deleting nothing.
|
|
933
|
+
|
|
934
|
+
Also what the deletion needs that can be told beforehand: a writable,
|
|
935
|
+
searchable folder (POSIX) and no read-only attribute (Windows). For
|
|
936
|
+
refusing before asking a user to confirm; the deletion still reports
|
|
937
|
+
what this can't foresee (immutable flags, ACLs, a change in between).
|
|
938
|
+
"""
|
|
939
|
+
return _owner_verified_unlink(path, unlink=False)
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def _owner_verified_unlink(path: Path, *, unlink: bool) -> bool:
|
|
943
|
+
if _is_windows():
|
|
944
|
+
return _windows_secure_unlink_owner_verified(path, unlink=unlink)
|
|
945
|
+
try:
|
|
946
|
+
directory_fd, filename = _open_posix_parent(path)
|
|
947
|
+
except SecureFileError, OSError:
|
|
948
|
+
return False
|
|
949
|
+
try:
|
|
950
|
+
try:
|
|
951
|
+
entry = os.stat(filename, dir_fd=directory_fd, follow_symlinks=False)
|
|
952
|
+
except OSError:
|
|
953
|
+
return False
|
|
954
|
+
if not stat.S_ISREG(entry.st_mode) or entry.st_uid != _posix_effective_uid():
|
|
955
|
+
return False
|
|
956
|
+
if not unlink:
|
|
957
|
+
return os.access(".", os.W_OK | os.X_OK, dir_fd=directory_fd, effective_ids=True)
|
|
958
|
+
try:
|
|
959
|
+
os.unlink(filename, dir_fd=directory_fd)
|
|
960
|
+
except OSError:
|
|
961
|
+
return False
|
|
962
|
+
return True
|
|
963
|
+
finally:
|
|
964
|
+
os.close(directory_fd)
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def _windows_secure_unlink_owner_verified(path: Path, *, unlink: bool) -> bool:
|
|
968
|
+
try:
|
|
969
|
+
_validate_link_free_parent(path)
|
|
970
|
+
fd = _windows_secure_open(
|
|
971
|
+
path,
|
|
972
|
+
read=True,
|
|
973
|
+
write=False,
|
|
974
|
+
create=False,
|
|
975
|
+
truncate=False,
|
|
976
|
+
delete=True,
|
|
977
|
+
verify_identity_only=True,
|
|
978
|
+
)
|
|
979
|
+
except SecureFileError, OSError:
|
|
980
|
+
return False
|
|
981
|
+
if not unlink:
|
|
982
|
+
try:
|
|
983
|
+
# Marking a read-only file for deletion is refused.
|
|
984
|
+
return not _windows_stat_file_attributes(os.fstat(fd)) & stat.FILE_ATTRIBUTE_READONLY
|
|
985
|
+
finally:
|
|
986
|
+
os.close(fd)
|
|
987
|
+
api = _windows_file_api()
|
|
988
|
+
|
|
989
|
+
info = _FileDispositionInfo(True)
|
|
990
|
+
FILE_DISPOSITION_INFO_CLASS = 4
|
|
991
|
+
handle = wintypes.HANDLE(_windows_osfhandle(fd))
|
|
992
|
+
try:
|
|
993
|
+
return bool(
|
|
994
|
+
api.kernel32.SetFileInformationByHandle(
|
|
995
|
+
handle,
|
|
996
|
+
FILE_DISPOSITION_INFO_CLASS,
|
|
997
|
+
ctypes.byref(info),
|
|
998
|
+
ctypes.sizeof(info),
|
|
999
|
+
)
|
|
1000
|
+
)
|
|
1001
|
+
finally:
|
|
1002
|
+
os.close(fd)
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def secure_open_owner_only(
|
|
1006
|
+
path: Path,
|
|
1007
|
+
*,
|
|
1008
|
+
read: bool = False,
|
|
1009
|
+
write: bool = False,
|
|
1010
|
+
create: bool = False,
|
|
1011
|
+
truncate: bool = False,
|
|
1012
|
+
) -> int:
|
|
1013
|
+
"""Open and verify an owner-only regular file without following the final link."""
|
|
1014
|
+
if not read and not write:
|
|
1015
|
+
raise ValueError("secure_open_owner_only requires read=True or write=True")
|
|
1016
|
+
if truncate and not write:
|
|
1017
|
+
raise ValueError("secure_open_owner_only requires write=True when truncate=True")
|
|
1018
|
+
_validate_link_free_parent(path)
|
|
1019
|
+
if _is_windows():
|
|
1020
|
+
return _windows_secure_open(path, read=read, write=write, create=create, truncate=truncate)
|
|
1021
|
+
|
|
1022
|
+
flags = os.O_RDONLY if read and not write else os.O_WRONLY
|
|
1023
|
+
if read and write:
|
|
1024
|
+
flags = os.O_RDWR
|
|
1025
|
+
if create:
|
|
1026
|
+
flags |= os.O_CREAT | os.O_EXCL
|
|
1027
|
+
flags |= getattr(os, "O_CLOEXEC", 0)
|
|
1028
|
+
flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
1029
|
+
# Never block inside open(2): a planted FIFO would otherwise wedge the
|
|
1030
|
+
# caller before the regular-file verification can reject it.
|
|
1031
|
+
flags |= getattr(os, "O_NONBLOCK", 0)
|
|
1032
|
+
directory_fd, filename = _open_posix_parent(path)
|
|
1033
|
+
try:
|
|
1034
|
+
fd = os.open(filename, flags, _OWNER_ONLY_MODE, dir_fd=directory_fd)
|
|
1035
|
+
except OSError as exc:
|
|
1036
|
+
raise SecureFileError(exc.errno, "Unable to open owner-only file.") from exc
|
|
1037
|
+
finally:
|
|
1038
|
+
os.close(directory_fd)
|
|
1039
|
+
try:
|
|
1040
|
+
if create:
|
|
1041
|
+
os.fchmod(fd, _OWNER_ONLY_MODE)
|
|
1042
|
+
_strip_created_posix_acl(fd)
|
|
1043
|
+
verify_owner_only_fd(fd)
|
|
1044
|
+
_clear_posix_nonblock(fd)
|
|
1045
|
+
if truncate:
|
|
1046
|
+
os.ftruncate(fd, 0)
|
|
1047
|
+
except BaseException:
|
|
1048
|
+
os.close(fd)
|
|
1049
|
+
raise
|
|
1050
|
+
return fd
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
@dataclass(frozen=True)
|
|
1054
|
+
class OwnerOnlyAppendHandle:
|
|
1055
|
+
"""Result of :func:`secure_open_owner_only_append`."""
|
|
1056
|
+
|
|
1057
|
+
fd: int
|
|
1058
|
+
created: bool
|
|
1059
|
+
"""True when this call created the file (it was absent before)."""
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def secure_open_owner_only_append(path: Path) -> OwnerOnlyAppendHandle:
|
|
1063
|
+
"""Create-or-open *path* as an owner-only regular file in append mode.
|
|
1064
|
+
|
|
1065
|
+
The single sanctioned way to hold an owner-only append-only log: callers
|
|
1066
|
+
must not compose create/seek/reopen themselves or fall back to a bare
|
|
1067
|
+
``Path.open("a")``. Contract:
|
|
1068
|
+
|
|
1069
|
+
* **create-or-open** — an absent file is created exclusively with
|
|
1070
|
+
owner-only mode (and inherited ACLs stripped); an existing file is
|
|
1071
|
+
opened and verified owner-only (mode, ownership, regular file, no
|
|
1072
|
+
link in the parent chain, no final-symlink follow).
|
|
1073
|
+
* **append** — ``O_APPEND`` on POSIX; ``_O_APPEND`` on the adopted CRT fd
|
|
1074
|
+
on Windows, where the handle is also opened with ``FILE_SHARE_DELETE``
|
|
1075
|
+
so the log can still be deleted while a writer holds it.
|
|
1076
|
+
* **binary** — the fd is raw; callers write bytes, so ``\n`` is never
|
|
1077
|
+
translated on Windows.
|
|
1078
|
+
* **readable** — the descriptor also reads, so a writer that must inspect
|
|
1079
|
+
what it is about to append to (recovering a tail, say) reads the file it
|
|
1080
|
+
actually holds instead of reopening a pathname that may by then name a
|
|
1081
|
+
different file. Writes still go to the end regardless of any read.
|
|
1082
|
+
|
|
1083
|
+
The create-vs-open race (file vanishing between an ``EEXIST`` and the
|
|
1084
|
+
follow-up open, or appearing between a failed open and the create) is
|
|
1085
|
+
retried a bounded number of times.
|
|
1086
|
+
"""
|
|
1087
|
+
_validate_link_free_parent(path)
|
|
1088
|
+
for _attempt in range(16):
|
|
1089
|
+
try:
|
|
1090
|
+
fd = _open_owner_only_append_create(path)
|
|
1091
|
+
except FileExistsError:
|
|
1092
|
+
try:
|
|
1093
|
+
fd = _open_owner_only_append_existing(path)
|
|
1094
|
+
except FileNotFoundError:
|
|
1095
|
+
continue
|
|
1096
|
+
return OwnerOnlyAppendHandle(fd=fd, created=False)
|
|
1097
|
+
return OwnerOnlyAppendHandle(fd=fd, created=True)
|
|
1098
|
+
raise SecureFileError("Unable to create-or-open the owner-only append file.")
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
def _open_owner_only_append_create(path: Path) -> int:
|
|
1102
|
+
"""Exclusively create *path* owner-only in append mode; ``FileExistsError`` if present."""
|
|
1103
|
+
if _is_windows():
|
|
1104
|
+
try:
|
|
1105
|
+
return _windows_secure_open(path, read=True, write=True, create=True, truncate=False, append=True)
|
|
1106
|
+
except SecureFileError as exc:
|
|
1107
|
+
if exc.errno in {80, 183}: # ERROR_FILE_EXISTS / ERROR_ALREADY_EXISTS
|
|
1108
|
+
raise FileExistsError(str(path)) from exc
|
|
1109
|
+
raise
|
|
1110
|
+
flags = os.O_RDWR | os.O_APPEND | os.O_CREAT | os.O_EXCL
|
|
1111
|
+
flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
|
|
1112
|
+
directory_fd, filename = _open_posix_parent(path)
|
|
1113
|
+
try:
|
|
1114
|
+
try:
|
|
1115
|
+
fd = os.open(filename, flags, _OWNER_ONLY_MODE, dir_fd=directory_fd)
|
|
1116
|
+
except FileExistsError:
|
|
1117
|
+
raise
|
|
1118
|
+
except OSError as exc:
|
|
1119
|
+
raise SecureFileError(exc.errno, "Unable to create owner-only append file.") from exc
|
|
1120
|
+
finally:
|
|
1121
|
+
os.close(directory_fd)
|
|
1122
|
+
try:
|
|
1123
|
+
os.fchmod(fd, _OWNER_ONLY_MODE)
|
|
1124
|
+
_strip_created_posix_acl(fd)
|
|
1125
|
+
verify_owner_only_fd(fd)
|
|
1126
|
+
_clear_posix_nonblock(fd)
|
|
1127
|
+
except BaseException:
|
|
1128
|
+
os.close(fd)
|
|
1129
|
+
raise
|
|
1130
|
+
return fd
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
def _open_owner_only_append_existing(path: Path) -> int:
|
|
1134
|
+
"""Open an existing owner-only *path* in append mode; ``FileNotFoundError`` if absent."""
|
|
1135
|
+
if _is_windows():
|
|
1136
|
+
try:
|
|
1137
|
+
return _windows_secure_open(path, read=True, write=True, create=False, truncate=False, append=True)
|
|
1138
|
+
except SecureFileError as exc:
|
|
1139
|
+
if exc.errno in {2, 3}: # ERROR_FILE_NOT_FOUND / ERROR_PATH_NOT_FOUND
|
|
1140
|
+
raise FileNotFoundError(str(path)) from exc
|
|
1141
|
+
raise
|
|
1142
|
+
flags = os.O_RDWR | os.O_APPEND
|
|
1143
|
+
flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
|
|
1144
|
+
directory_fd, filename = _open_posix_parent(path)
|
|
1145
|
+
try:
|
|
1146
|
+
try:
|
|
1147
|
+
fd = os.open(filename, flags, dir_fd=directory_fd)
|
|
1148
|
+
except FileNotFoundError:
|
|
1149
|
+
raise
|
|
1150
|
+
except OSError as exc:
|
|
1151
|
+
raise SecureFileError(exc.errno, "Unable to open owner-only append file.") from exc
|
|
1152
|
+
finally:
|
|
1153
|
+
os.close(directory_fd)
|
|
1154
|
+
try:
|
|
1155
|
+
verify_owner_only_fd(fd)
|
|
1156
|
+
_clear_posix_nonblock(fd)
|
|
1157
|
+
except BaseException:
|
|
1158
|
+
os.close(fd)
|
|
1159
|
+
raise
|
|
1160
|
+
return fd
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def secure_open_owner_only_binary(path: Path) -> BinaryIO:
|
|
1164
|
+
"""Open an existing owner-only file for binary reading."""
|
|
1165
|
+
return os.fdopen(secure_open_owner_only(path, read=True), "rb")
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def secure_open_owner_verified_binary(path: Path) -> BinaryIO:
|
|
1169
|
+
"""Open an existing owner-verified file for binary reading.
|
|
1170
|
+
|
|
1171
|
+
Same no-follow/link-free-parent/regular-file/owner gates as the owner-only
|
|
1172
|
+
open, but without the owner-only mode/DACL requirement: session artifacts
|
|
1173
|
+
written by releases that predate the owner-only writers carry default
|
|
1174
|
+
permissions and must remain readable by restore, reconciliation, and fork.
|
|
1175
|
+
"""
|
|
1176
|
+
if _is_windows():
|
|
1177
|
+
try:
|
|
1178
|
+
fd = _windows_secure_open(
|
|
1179
|
+
path,
|
|
1180
|
+
read=True,
|
|
1181
|
+
write=False,
|
|
1182
|
+
create=False,
|
|
1183
|
+
truncate=False,
|
|
1184
|
+
verify_identity_only=True,
|
|
1185
|
+
)
|
|
1186
|
+
except SecureFileError as exc:
|
|
1187
|
+
if exc.errno == 3: # ERROR_PATH_NOT_FOUND: a missing parent reads as ENOENT here, as POSIX reports it
|
|
1188
|
+
raise SecureFileError(errno.ENOENT, "Unable to open owner-verified file.") from exc
|
|
1189
|
+
raise
|
|
1190
|
+
return os.fdopen(fd, "rb")
|
|
1191
|
+
flags = os.O_RDONLY
|
|
1192
|
+
flags |= getattr(os, "O_CLOEXEC", 0)
|
|
1193
|
+
flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
1194
|
+
# Never block inside open(2): a planted FIFO would otherwise wedge the
|
|
1195
|
+
# caller before the regular-file verification can reject it.
|
|
1196
|
+
flags |= getattr(os, "O_NONBLOCK", 0)
|
|
1197
|
+
_validate_link_free_parent(path)
|
|
1198
|
+
directory_fd, filename = _open_posix_parent(path)
|
|
1199
|
+
try:
|
|
1200
|
+
fd = os.open(filename, flags, dir_fd=directory_fd)
|
|
1201
|
+
except OSError as exc:
|
|
1202
|
+
raise SecureFileError(exc.errno, "Unable to open owner-verified file.") from exc
|
|
1203
|
+
finally:
|
|
1204
|
+
os.close(directory_fd)
|
|
1205
|
+
try:
|
|
1206
|
+
_verify_posix_owner_identity(fd)
|
|
1207
|
+
_clear_posix_nonblock(fd)
|
|
1208
|
+
except BaseException:
|
|
1209
|
+
os.close(fd)
|
|
1210
|
+
raise
|
|
1211
|
+
return os.fdopen(fd, "rb")
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def secure_open_regular_binary(path: Path) -> BinaryIO:
|
|
1215
|
+
"""Read a regular file without following its final link, accepting any readable owner/mode.
|
|
1216
|
+
|
|
1217
|
+
For explicit imports of legacy data only: migration may import files an
|
|
1218
|
+
elevated older release created. Only the leaf is opened no-follow; the
|
|
1219
|
+
parents are traversed as by any open, so a parent alias or an ancestor the
|
|
1220
|
+
user may enter but not list still works (the owner-verified reader opens
|
|
1221
|
+
every parent and needs read access to each). Open failures are raised as
|
|
1222
|
+
the system reports them: migration shows them as the reason a file stayed
|
|
1223
|
+
behind.
|
|
1224
|
+
"""
|
|
1225
|
+
if _is_windows():
|
|
1226
|
+
# The handle's final path must match the requested one, so compare
|
|
1227
|
+
# against the parents' real spelling (a mapped drive, a junction).
|
|
1228
|
+
target = path.parent.resolve() / path.name
|
|
1229
|
+
pinned = _windows_secure_open(target, read=True, write=False, create=False, truncate=False, legacy_import=True)
|
|
1230
|
+
try:
|
|
1231
|
+
fd = _windows_reopen_same_file(target, pinned)
|
|
1232
|
+
finally:
|
|
1233
|
+
os.close(pinned)
|
|
1234
|
+
return os.fdopen(fd, "rb")
|
|
1235
|
+
flags = os.O_RDONLY
|
|
1236
|
+
flags |= getattr(os, "O_CLOEXEC", 0)
|
|
1237
|
+
flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
1238
|
+
# Never block inside open(2): a planted FIFO would otherwise wedge the
|
|
1239
|
+
# caller before the regular-file verification can reject it.
|
|
1240
|
+
flags |= getattr(os, "O_NONBLOCK", 0)
|
|
1241
|
+
fd = os.open(path, flags)
|
|
1242
|
+
try:
|
|
1243
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
1244
|
+
raise SecureFileError("Secure file is not a regular file.")
|
|
1245
|
+
_clear_posix_nonblock(fd)
|
|
1246
|
+
except BaseException:
|
|
1247
|
+
os.close(fd)
|
|
1248
|
+
raise
|
|
1249
|
+
return os.fdopen(fd, "rb")
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
def _windows_reopen_same_file(path: Path, pinned_fd: int) -> int:
|
|
1253
|
+
"""Reopen *path* for reading as any program would, pinned to *pinned_fd*'s file.
|
|
1254
|
+
|
|
1255
|
+
The no-follow handle proves the leaf is no link, but it opens a reparse
|
|
1256
|
+
point itself, and for a cloud placeholder (OneDrive Files On-Demand), a
|
|
1257
|
+
deduplicated or a WOF-compressed file that is the raw stream behind the
|
|
1258
|
+
filter, not the file's content. The ordinary reopen reads through the
|
|
1259
|
+
filter; since it resolves the name again, it must land on the same file.
|
|
1260
|
+
"""
|
|
1261
|
+
api = _windows_file_api()
|
|
1262
|
+
handle = api.kernel32.CreateFileW(str(path), 0x80000000, WINDOWS_SECURE_OPEN_SHARE_MODE, None, 3, 0x00000080, None)
|
|
1263
|
+
if handle == wintypes.HANDLE(-1).value:
|
|
1264
|
+
raise _windows_os_error(cast(Any, ctypes).get_last_error(), path)
|
|
1265
|
+
try:
|
|
1266
|
+
fd = _windows_open_osfhandle(
|
|
1267
|
+
int(handle), os.O_RDONLY | getattr(os, "O_NOINHERIT", 0) | getattr(os, "O_BINARY", 0)
|
|
1268
|
+
)
|
|
1269
|
+
except BaseException:
|
|
1270
|
+
api.kernel32.CloseHandle(handle)
|
|
1271
|
+
raise
|
|
1272
|
+
try:
|
|
1273
|
+
if not os.path.samestat(os.fstat(pinned_fd), os.fstat(fd)):
|
|
1274
|
+
raise SecureFileError("Windows file was replaced while it was being opened.")
|
|
1275
|
+
except BaseException:
|
|
1276
|
+
os.close(fd)
|
|
1277
|
+
raise
|
|
1278
|
+
return fd
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
def atomic_write_owner_only_bytes(path: Path, payload: bytes, *, create_parents: bool = True) -> None:
|
|
1282
|
+
"""Atomically replace *path* with an owner-only byte payload.
|
|
1283
|
+
|
|
1284
|
+
With *create_parents* false a missing parent directory is an error rather than made: a record
|
|
1285
|
+
rewritten in place must not resurrect a directory deleted since the record was read.
|
|
1286
|
+
"""
|
|
1287
|
+
_validate_link_free_parent(path)
|
|
1288
|
+
if create_parents:
|
|
1289
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1290
|
+
if not _is_windows():
|
|
1291
|
+
_atomic_write_owner_only_posix(path, payload)
|
|
1292
|
+
return
|
|
1293
|
+
fd, tmp_path = _create_windows_owner_only_temp(path)
|
|
1294
|
+
published = False
|
|
1295
|
+
# os.replace preserves the file object, so the temp identity is also the
|
|
1296
|
+
# published target's identity for every cleanup below.
|
|
1297
|
+
written_identity: os.stat_result | None = None
|
|
1298
|
+
with contextlib.suppress(OSError):
|
|
1299
|
+
written_identity = os.fstat(fd)
|
|
1300
|
+
try:
|
|
1301
|
+
with os.fdopen(fd, "wb") as file:
|
|
1302
|
+
fd = -1
|
|
1303
|
+
file.write(payload)
|
|
1304
|
+
file.flush()
|
|
1305
|
+
os.fsync(file.fileno())
|
|
1306
|
+
replace_with_retry(tmp_path, path)
|
|
1307
|
+
published = True
|
|
1308
|
+
verify_fd = secure_open_owner_only(path, read=True)
|
|
1309
|
+
os.close(verify_fd)
|
|
1310
|
+
fsync_directory(path.parent)
|
|
1311
|
+
except BaseException:
|
|
1312
|
+
if fd >= 0:
|
|
1313
|
+
os.close(fd)
|
|
1314
|
+
# Never unlink by name here: the parent may have been junction-swapped
|
|
1315
|
+
# after the replace, and a name-based unlink would follow it onto an
|
|
1316
|
+
# unrelated file. Deletion goes through a verified handle or not at all.
|
|
1317
|
+
if published:
|
|
1318
|
+
_windows_secure_unlink(path, written_identity)
|
|
1319
|
+
_windows_secure_unlink(tmp_path, written_identity)
|
|
1320
|
+
raise
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
def _create_windows_owner_only_temp(path: Path) -> tuple[int, Path]:
|
|
1324
|
+
for _attempt in range(128):
|
|
1325
|
+
temp_path = path.parent / f".{path.name}.{secrets.token_hex(_ATOMIC_NAME_TOKEN_BYTES)}.tmp"
|
|
1326
|
+
try:
|
|
1327
|
+
return secure_open_owner_only(temp_path, write=True, create=True), temp_path
|
|
1328
|
+
except SecureFileError as exc:
|
|
1329
|
+
if exc.errno not in {80, 183}:
|
|
1330
|
+
raise
|
|
1331
|
+
raise SecureFileError("Unable to allocate an owner-only Windows temporary file.")
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
def _unlink_verified_posix(name: str, directory_fd: int, identity: os.stat_result | None) -> None:
|
|
1335
|
+
"""Unlink *name* only while it is still the exact file this writer created.
|
|
1336
|
+
|
|
1337
|
+
POSIX has no unlink-by-handle, so the stat-to-unlink window is an accepted
|
|
1338
|
+
residual — deleting a concurrently substituted entry through an unverified
|
|
1339
|
+
name would be worse than leaving debris behind.
|
|
1340
|
+
"""
|
|
1341
|
+
if identity is None:
|
|
1342
|
+
return
|
|
1343
|
+
with contextlib.suppress(OSError):
|
|
1344
|
+
entry = os.stat(name, dir_fd=directory_fd, follow_symlinks=False)
|
|
1345
|
+
if entry.st_dev == identity.st_dev and entry.st_ino == identity.st_ino:
|
|
1346
|
+
os.unlink(name, dir_fd=directory_fd)
|
|
1347
|
+
|
|
1348
|
+
|
|
1349
|
+
def _atomic_write_owner_only_posix(path: Path, payload: bytes) -> None:
|
|
1350
|
+
directory_fd, filename = _open_posix_parent(path)
|
|
1351
|
+
temp_name = ""
|
|
1352
|
+
fd = -1
|
|
1353
|
+
published = False
|
|
1354
|
+
written_identity: os.stat_result | None = None
|
|
1355
|
+
try:
|
|
1356
|
+
for _attempt in range(128):
|
|
1357
|
+
candidate = f".{filename}.{secrets.token_hex(_ATOMIC_NAME_TOKEN_BYTES)}.tmp"
|
|
1358
|
+
try:
|
|
1359
|
+
fd = os.open(
|
|
1360
|
+
candidate,
|
|
1361
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0),
|
|
1362
|
+
_OWNER_ONLY_MODE,
|
|
1363
|
+
dir_fd=directory_fd,
|
|
1364
|
+
)
|
|
1365
|
+
except FileExistsError:
|
|
1366
|
+
continue
|
|
1367
|
+
temp_name = candidate
|
|
1368
|
+
break
|
|
1369
|
+
if fd < 0:
|
|
1370
|
+
raise SecureFileError("Unable to allocate an owner-only temporary file.")
|
|
1371
|
+
# os.replace preserves the inode, so the O_EXCL-created temp identity
|
|
1372
|
+
# is also the published target's identity for every cleanup below.
|
|
1373
|
+
written_identity = os.fstat(fd)
|
|
1374
|
+
os.fchmod(fd, _OWNER_ONLY_MODE)
|
|
1375
|
+
_strip_created_posix_acl(fd)
|
|
1376
|
+
verify_owner_only_fd(fd)
|
|
1377
|
+
with os.fdopen(fd, "wb") as file:
|
|
1378
|
+
fd = -1
|
|
1379
|
+
file.write(payload)
|
|
1380
|
+
file.flush()
|
|
1381
|
+
os.fsync(file.fileno())
|
|
1382
|
+
os.replace(temp_name, filename, src_dir_fd=directory_fd, dst_dir_fd=directory_fd)
|
|
1383
|
+
temp_name = ""
|
|
1384
|
+
published = True
|
|
1385
|
+
verify_fd = os.open(
|
|
1386
|
+
filename,
|
|
1387
|
+
os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NONBLOCK", 0),
|
|
1388
|
+
dir_fd=directory_fd,
|
|
1389
|
+
)
|
|
1390
|
+
try:
|
|
1391
|
+
verify_owner_only_fd(verify_fd)
|
|
1392
|
+
finally:
|
|
1393
|
+
os.close(verify_fd)
|
|
1394
|
+
with contextlib.suppress(OSError):
|
|
1395
|
+
os.fsync(directory_fd)
|
|
1396
|
+
except BaseException:
|
|
1397
|
+
if published:
|
|
1398
|
+
_unlink_verified_posix(filename, directory_fd, written_identity)
|
|
1399
|
+
raise
|
|
1400
|
+
finally:
|
|
1401
|
+
if fd >= 0:
|
|
1402
|
+
os.close(fd)
|
|
1403
|
+
if temp_name:
|
|
1404
|
+
_unlink_verified_posix(temp_name, directory_fd, written_identity)
|
|
1405
|
+
os.close(directory_fd)
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
def is_utf8_encodable(text: str) -> bool:
|
|
1409
|
+
"""Return whether *text* can be encoded by strict UTF-8."""
|
|
1410
|
+
try:
|
|
1411
|
+
text.encode("utf-8")
|
|
1412
|
+
except UnicodeEncodeError:
|
|
1413
|
+
return False
|
|
1414
|
+
return True
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
def surrogate_safe_text(text: str) -> str:
|
|
1418
|
+
"""Neutralize unpaired UTF-16 surrogates so *text* survives a strict write.
|
|
1419
|
+
|
|
1420
|
+
``os.fsdecode`` of a filesystem path that contains an undecodable byte yields
|
|
1421
|
+
a lone surrogate (``\\udcXX``); carried through ``json.dumps(ensure_ascii=False)``
|
|
1422
|
+
it lands in the serialized string as a legal ``str`` that is NOT strict-UTF-8
|
|
1423
|
+
encodable, so ``atomic_write_owner_only_text`` (and any strict ``.encode()``)
|
|
1424
|
+
would raise ``UnicodeEncodeError``. ``backslashreplace`` rewrites each lone
|
|
1425
|
+
surrogate as its plain-ASCII ``\\udcXX`` escape — a no-op on all valid text —
|
|
1426
|
+
keeping the result surrogate-free and round-trippable through a strict read.
|
|
1427
|
+
|
|
1428
|
+
The returned text is NOT an equivalent filesystem path: replacing a surrogate
|
|
1429
|
+
changes the bytes produced by ``os.fsencode``. Keep the raw surrogateescaped
|
|
1430
|
+
value for filesystem access and subprocess ``cwd``; call this helper only on a
|
|
1431
|
+
derived display or non-identity-bearing serialization copy. Identity-bearing
|
|
1432
|
+
serialization and hash keys must encode the raw value injectively instead (for
|
|
1433
|
+
example, JSON with ``ensure_ascii=True`` or an encoding of ``os.fsencode``).
|
|
1434
|
+
|
|
1435
|
+
The general crash-safety guarantee lives at the write SINK
|
|
1436
|
+
(``atomic_write_text`` / ``atomic_write_owner_only_text`` both encode with
|
|
1437
|
+
``errors="backslashreplace"``), so callers do NOT need to wrap ``json.dumps``
|
|
1438
|
+
with this. Use it only when a SPECIFIC field must STAY surrogate-free through
|
|
1439
|
+
a read — apply it BEFORE ``json.dumps`` so the escape's backslash is itself
|
|
1440
|
+
JSON-escaped and does not re-parse to the surrogate (e.g. the HMAC-adjacent
|
|
1441
|
+
display command/args, whose stored form must read back surrogate-free). A
|
|
1442
|
+
sink-escaped value, by contrast, re-parses to the original lone surrogate on
|
|
1443
|
+
read-back (the ``\\udcXX`` lands as a bare JSON unicode escape) — harmless
|
|
1444
|
+
because every persistence path re-encodes through the same total sink.
|
|
1445
|
+
"""
|
|
1446
|
+
return text.encode("utf-8", "backslashreplace").decode("utf-8")
|
|
1447
|
+
|
|
1448
|
+
|
|
1449
|
+
def atomic_write_owner_only_text(path: Path, payload: str, *, encoding: str = "utf-8") -> None:
|
|
1450
|
+
"""Atomically replace *path* with owner-only encoded text.
|
|
1451
|
+
|
|
1452
|
+
``errors="backslashreplace"`` makes the encode TOTAL against lone surrogates,
|
|
1453
|
+
exactly like ``atomic_write_text`` — this is the owner-only sink for ACP audit
|
|
1454
|
+
and pending artifacts, which carry surrogateescaped filesystem paths.
|
|
1455
|
+
"""
|
|
1456
|
+
atomic_write_owner_only_bytes(path, payload.encode(encoding, errors="backslashreplace"))
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
def read_owner_verified_bounded(path: Path, *, max_bytes: int) -> bytes:
|
|
1460
|
+
"""Read at most the byte ceiling through an owner-verified descriptor."""
|
|
1461
|
+
with secure_open_owner_verified_binary(path) as handle:
|
|
1462
|
+
payload = handle.read(max_bytes + 1)
|
|
1463
|
+
if len(payload) > max_bytes:
|
|
1464
|
+
raise ValueError(f"Artifact exceeds the {max_bytes}-byte read ceiling: {path.name}")
|
|
1465
|
+
return payload
|
|
1466
|
+
|
|
1467
|
+
|
|
1468
|
+
def atomic_write_basename_budget(max_filename_bytes: int = 255) -> int:
|
|
1469
|
+
"""Bytes available to a destination basename, reserving its atomic temporary name."""
|
|
1470
|
+
return max_filename_bytes - (2 + 2 * _ATOMIC_NAME_TOKEN_BYTES + len(".tmp"))
|