deepy-cli 0.2.23__tar.gz → 0.2.24__tar.gz

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.
Files changed (111) hide show
  1. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/PKG-INFO +9 -1
  2. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/README.md +8 -0
  3. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/pyproject.toml +1 -1
  4. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/__init__.py +1 -1
  5. deepy_cli-0.2.24/src/deepy/audit.py +158 -0
  6. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/config/__init__.py +4 -0
  7. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/config/settings.py +34 -0
  8. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/llm/agent.py +6 -0
  9. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/llm/runner.py +202 -43
  10. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/mcp.py +12 -0
  11. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/status.py +8 -0
  12. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/tools/agents.py +19 -0
  13. deepy_cli-0.2.24/src/deepy/ui/audit_approval_panel.py +401 -0
  14. deepy_cli-0.2.24/src/deepy/ui/audit_approval_picker.py +295 -0
  15. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/ui/message_view.py +25 -3
  16. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/ui/prompt_input.py +9 -0
  17. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/ui/terminal.py +392 -7
  18. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/__main__.py +0 -0
  19. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/background_tasks.py +0 -0
  20. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/cli.py +0 -0
  21. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/data/__init__.py +0 -0
  22. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/data/skills/skill-creator/SKILL.md +0 -0
  23. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/data/skills/skill-installer/SKILL.md +0 -0
  24. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/data/tools/AskUserQuestion.md +0 -0
  25. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/data/tools/Read.md +0 -0
  26. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/data/tools/Search.md +0 -0
  27. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/data/tools/Update.md +0 -0
  28. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/data/tools/WebFetch.md +0 -0
  29. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/data/tools/WebSearch.md +0 -0
  30. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/data/tools/Write.md +0 -0
  31. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/data/tools/__init__.py +0 -0
  32. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/data/tools/shell.md +0 -0
  33. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/data/tools/task_list.md +0 -0
  34. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/data/tools/task_output.md +0 -0
  35. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/data/tools/task_stop.md +0 -0
  36. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/data/tools/test_shell.md +0 -0
  37. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/data/tools/todo_write.md +0 -0
  38. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/errors.py +0 -0
  39. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/input_suggestions.py +0 -0
  40. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/llm/__init__.py +0 -0
  41. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/llm/cache_context.py +0 -0
  42. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/llm/compaction.py +0 -0
  43. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/llm/context.py +0 -0
  44. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/llm/events.py +0 -0
  45. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/llm/model_capabilities.py +0 -0
  46. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/llm/provider.py +0 -0
  47. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/llm/replay.py +0 -0
  48. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/llm/thinking.py +0 -0
  49. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/prompts/__init__.py +0 -0
  50. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/prompts/compact.py +0 -0
  51. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/prompts/init_agents.py +0 -0
  52. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/prompts/rules.py +0 -0
  53. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/prompts/runtime_context.py +0 -0
  54. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/prompts/system.py +0 -0
  55. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/prompts/tool_docs.py +0 -0
  56. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/session_cost.py +0 -0
  57. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/sessions/__init__.py +0 -0
  58. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/sessions/index.py +0 -0
  59. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/sessions/manager.py +0 -0
  60. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/sessions/session.py +0 -0
  61. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/sessions/store_helpers.py +0 -0
  62. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/skill_market.py +0 -0
  63. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/skills.py +0 -0
  64. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/subagents.py +0 -0
  65. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/todos.py +0 -0
  66. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/tools/__init__.py +0 -0
  67. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/tools/builtin.py +0 -0
  68. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/tools/file_state.py +0 -0
  69. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/tools/result.py +0 -0
  70. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/tools/search.py +0 -0
  71. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/tools/shell_output.py +0 -0
  72. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/tools/shell_utils.py +0 -0
  73. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/tools/test_shell.py +0 -0
  74. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/tui/__init__.py +0 -0
  75. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/tui/app.py +0 -0
  76. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/tui/commands.py +0 -0
  77. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/tui/compat.py +0 -0
  78. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/tui/diff.py +0 -0
  79. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/tui/runner.py +0 -0
  80. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/tui/screens.py +0 -0
  81. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/tui/state.py +0 -0
  82. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/tui/widgets.py +0 -0
  83. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/types/__init__.py +0 -0
  84. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/types/sdk.py +0 -0
  85. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/types/tool_payloads.py +0 -0
  86. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/ui/__init__.py +0 -0
  87. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/ui/app.py +0 -0
  88. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/ui/ask_user_question.py +0 -0
  89. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/ui/exit_summary.py +0 -0
  90. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/ui/file_mentions.py +0 -0
  91. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/ui/loading_text.py +0 -0
  92. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/ui/local_command.py +0 -0
  93. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/ui/markdown.py +0 -0
  94. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/ui/model_picker.py +0 -0
  95. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/ui/prompt_buffer.py +0 -0
  96. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/ui/session_list.py +0 -0
  97. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/ui/session_picker.py +0 -0
  98. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/ui/skill_picker.py +0 -0
  99. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/ui/slash_commands.py +0 -0
  100. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/ui/status_footer.py +0 -0
  101. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/ui/styles.py +0 -0
  102. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/ui/theme_picker.py +0 -0
  103. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/ui/thinking_state.py +0 -0
  104. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/ui/welcome.py +0 -0
  105. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/update_check.py +0 -0
  106. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/usage.py +0 -0
  107. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/utils/__init__.py +0 -0
  108. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/utils/debug_logger.py +0 -0
  109. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/utils/error_logger.py +0 -0
  110. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/utils/json.py +0 -0
  111. {deepy_cli-0.2.23 → deepy_cli-0.2.24}/src/deepy/utils/notify.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: deepy-cli
3
- Version: 0.2.23
3
+ Version: 0.2.24
4
4
  Summary: Deepy - Vibe coding for DeepSeek models in your terminal
5
5
  Keywords: deepseek,coding-agent,terminal,cli,agents
6
6
  Author: kirineko
@@ -327,10 +327,18 @@ compact_trigger_ratio = 0.8
327
327
  reserved_context_tokens = 50000
328
328
  compact_preserve_recent_messages = 2
329
329
 
330
+ [audit]
331
+ mode = "yolo" # normal, auto, or yolo
332
+
330
333
  [ui]
331
334
  theme = "dark" # dark or light
332
335
  ```
333
336
 
337
+ Audit modes control side-effect approval. `normal` asks before managed text
338
+ writes, shell commands, background task stops, and MCP tool calls. `auto`
339
+ auto-approves managed text writes but still asks for commands and untrusted MCP
340
+ tools. `yolo` preserves the high-autonomy default.
341
+
334
342
  Manual configuration commands:
335
343
 
336
344
  ```bash
@@ -295,10 +295,18 @@ compact_trigger_ratio = 0.8
295
295
  reserved_context_tokens = 50000
296
296
  compact_preserve_recent_messages = 2
297
297
 
298
+ [audit]
299
+ mode = "yolo" # normal, auto, or yolo
300
+
298
301
  [ui]
299
302
  theme = "dark" # dark or light
300
303
  ```
301
304
 
305
+ Audit modes control side-effect approval. `normal` asks before managed text
306
+ writes, shell commands, background task stops, and MCP tool calls. `auto`
307
+ auto-approves managed text writes but still asks for commands and untrusted MCP
308
+ tools. `yolo` preserves the high-autonomy default.
309
+
302
310
  Manual configuration commands:
303
311
 
304
312
  ```bash
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deepy-cli"
3
- version = "0.2.23"
3
+ version = "0.2.24"
4
4
  description = "Deepy - Vibe coding for DeepSeek models in your terminal"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.2.23"
3
+ __version__ = "0.2.24"
4
4
 
5
5
 
6
6
  def main() -> None:
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Mapping
4
+ from dataclasses import dataclass, field
5
+ from enum import StrEnum
6
+ from typing import Any, Literal, cast
7
+
8
+
9
+ class AuditMode(StrEnum):
10
+ NORMAL = "normal"
11
+ AUTO = "auto"
12
+ YOLO = "yolo"
13
+
14
+
15
+ AUDIT_MODES = {mode.value for mode in AuditMode}
16
+ DEFAULT_AUDIT_MODE = AuditMode.YOLO
17
+ AuditAction = Literal["text_write", "command", "background_task_control", "mcp_tool"]
18
+ ApprovalOutcome = Literal["approve", "reject"]
19
+
20
+
21
+ def parse_audit_mode(value: object, *, default: AuditMode = DEFAULT_AUDIT_MODE) -> AuditMode:
22
+ if isinstance(value, AuditMode):
23
+ return value
24
+ if isinstance(value, str):
25
+ normalized = value.strip().lower()
26
+ if normalized in AUDIT_MODES:
27
+ return AuditMode(normalized)
28
+ return default
29
+
30
+
31
+ def is_valid_audit_mode(value: str) -> bool:
32
+ return value in AUDIT_MODES
33
+
34
+
35
+ def next_audit_mode(mode: AuditMode | str) -> AuditMode:
36
+ current = parse_audit_mode(mode)
37
+ order = (AuditMode.NORMAL, AuditMode.AUTO, AuditMode.YOLO)
38
+ index = order.index(current)
39
+ return order[(index + 1) % len(order)]
40
+
41
+
42
+ @dataclass
43
+ class AuditModeState:
44
+ mode: AuditMode = DEFAULT_AUDIT_MODE
45
+
46
+ @classmethod
47
+ def from_value(cls, value: object) -> "AuditModeState":
48
+ return cls(parse_audit_mode(value))
49
+
50
+ def set(self, value: AuditMode | str) -> AuditMode:
51
+ self.mode = parse_audit_mode(value, default=self.mode)
52
+ return self.mode
53
+
54
+ def cycle(self) -> AuditMode:
55
+ self.mode = next_audit_mode(self.mode)
56
+ return self.mode
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class McpSafeTool:
61
+ server: str
62
+ tool: str
63
+
64
+ @classmethod
65
+ def from_mapping(cls, value: Mapping[str, Any]) -> "McpSafeTool | None":
66
+ server = value.get("server")
67
+ tool = value.get("tool")
68
+ if not isinstance(server, str) or not server.strip():
69
+ return None
70
+ if not isinstance(tool, str) or not tool.strip():
71
+ return None
72
+ return cls(server=server.strip(), tool=tool.strip())
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class AuditConfig:
77
+ mode: AuditMode = DEFAULT_AUDIT_MODE
78
+ mcp_safe_tools: tuple[McpSafeTool, ...] = ()
79
+ invalid_mode: str | None = None
80
+
81
+ @classmethod
82
+ def from_mapping(cls, raw: Mapping[str, Any]) -> "AuditConfig":
83
+ raw_mode = raw.get("mode")
84
+ mode = parse_audit_mode(raw_mode)
85
+ invalid_mode = (
86
+ str(raw_mode)
87
+ if isinstance(raw_mode, str) and raw_mode.strip() and raw_mode.strip().lower() not in AUDIT_MODES
88
+ else None
89
+ )
90
+ safe_tools: list[McpSafeTool] = []
91
+ for item in _safe_tool_entries(raw.get("mcp_safe_tools")):
92
+ parsed = McpSafeTool.from_mapping(item)
93
+ if parsed is not None:
94
+ safe_tools.append(parsed)
95
+ return cls(mode=mode, mcp_safe_tools=tuple(safe_tools), invalid_mode=invalid_mode)
96
+
97
+ def is_mcp_tool_safe(self, server: str, tool: str) -> bool:
98
+ return any(entry.server == server and entry.tool == tool for entry in self.mcp_safe_tools)
99
+
100
+
101
+ @dataclass
102
+ class AuditPolicy:
103
+ mode_getter: Callable[[], AuditMode]
104
+ config: AuditConfig = field(default_factory=AuditConfig)
105
+
106
+ @classmethod
107
+ def from_mode(cls, mode: AuditMode | str, config: AuditConfig | None = None) -> "AuditPolicy":
108
+ parsed = parse_audit_mode(mode)
109
+ return cls(lambda: parsed, config=config or AuditConfig(mode=parsed))
110
+
111
+ def active_mode(self) -> AuditMode:
112
+ return parse_audit_mode(self.mode_getter())
113
+
114
+ def needs_approval(self, action: AuditAction) -> bool:
115
+ mode = self.active_mode()
116
+ if mode == AuditMode.YOLO:
117
+ return False
118
+ if mode == AuditMode.NORMAL:
119
+ return action in {"text_write", "command", "background_task_control", "mcp_tool"}
120
+ if mode == AuditMode.AUTO:
121
+ return action in {"command", "background_task_control", "mcp_tool"}
122
+ return True
123
+
124
+ def needs_mcp_approval(self, *, server: str, tool: str) -> bool:
125
+ mode = self.active_mode()
126
+ if mode == AuditMode.YOLO:
127
+ return False
128
+ if mode == AuditMode.AUTO and self.config.is_mcp_tool_safe(server, tool):
129
+ return False
130
+ return True
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class PendingApproval:
135
+ index: int
136
+ name: str
137
+ tool_name: str
138
+ arguments: str
139
+ agent_name: str = ""
140
+ action_kind: str = "tool"
141
+ server_name: str = ""
142
+
143
+
144
+ @dataclass(frozen=True)
145
+ class ApprovalDecision:
146
+ outcome: ApprovalOutcome
147
+ always: bool = False
148
+ rejection_message: str | None = None
149
+
150
+
151
+ def _safe_tool_entries(value: object) -> list[Mapping[str, Any]]:
152
+ if not isinstance(value, list):
153
+ return []
154
+ entries: list[Mapping[str, Any]] = []
155
+ for item in value:
156
+ if isinstance(item, Mapping):
157
+ entries.append(cast(Mapping[str, Any], item))
158
+ return entries
@@ -47,6 +47,7 @@ from .settings import (
47
47
  is_valid_ui_theme,
48
48
  is_valid_ui_view_mode,
49
49
  is_valid_reasoning_mode,
50
+ is_valid_config_audit_mode,
50
51
  load_settings,
51
52
  mask_secret,
52
53
  provider_info_for,
@@ -54,6 +55,7 @@ from .settings import (
54
55
  settings_to_toml_dict,
55
56
  thinking_modes_for_provider,
56
57
  update_config_model_settings,
58
+ update_config_audit_mode,
57
59
  update_config_input_suggestions_enabled,
58
60
  update_config_theme,
59
61
  update_config_view_mode,
@@ -109,6 +111,7 @@ __all__ = [
109
111
  "is_valid_ui_theme",
110
112
  "is_valid_ui_view_mode",
111
113
  "is_valid_reasoning_mode",
114
+ "is_valid_config_audit_mode",
112
115
  "load_settings",
113
116
  "mask_secret",
114
117
  "provider_info_for",
@@ -116,6 +119,7 @@ __all__ = [
116
119
  "settings_to_toml_dict",
117
120
  "thinking_modes_for_provider",
118
121
  "update_config_model_settings",
122
+ "update_config_audit_mode",
119
123
  "update_config_input_suggestions_enabled",
120
124
  "update_config_theme",
121
125
  "update_config_view_mode",
@@ -9,6 +9,8 @@ from typing import Any, Mapping, Self
9
9
 
10
10
  import tomli_w
11
11
 
12
+ from deepy.audit import AuditConfig, AuditMode, DEFAULT_AUDIT_MODE, is_valid_audit_mode
13
+
12
14
  DEFAULT_MODEL = "deepseek-v4-pro"
13
15
  DEFAULT_BASE_URL = "https://api.deepseek.com"
14
16
  DEFAULT_CONTEXT_WINDOW_TOKENS = 1_048_576
@@ -595,6 +597,7 @@ class UiConfig:
595
597
 
596
598
  @dataclass(frozen=True)
597
599
  class Settings:
600
+ audit: AuditConfig = field(default_factory=AuditConfig)
598
601
  model: ModelConfig = field(default_factory=ModelConfig)
599
602
  context: ContextConfig = field(default_factory=ContextConfig)
600
603
  logging: LoggingConfig = field(default_factory=LoggingConfig)
@@ -613,6 +616,7 @@ class Settings:
613
616
  env: Mapping[str, str] | None = None,
614
617
  ) -> Self:
615
618
  return cls(
619
+ audit=AuditConfig.from_mapping(_as_mapping(raw.get("audit"))),
616
620
  model=ModelConfig.from_mapping(_as_mapping(raw.get("model")), env=env),
617
621
  context=ContextConfig.from_mapping(_as_mapping(raw.get("context"))),
618
622
  logging=LoggingConfig.from_mapping(_as_mapping(raw.get("logging"))),
@@ -646,6 +650,14 @@ def settings_to_toml_dict(settings: Settings, *, reveal_secret: bool = False) ->
646
650
  data.pop("path", None)
647
651
  if "ui" in data:
648
652
  data["ui"].pop("theme_configured", None)
653
+ if "audit" in data:
654
+ data["audit"].pop("invalid_mode", None)
655
+ if "mode" in data["audit"] and isinstance(settings.audit.mode, AuditMode):
656
+ data["audit"]["mode"] = settings.audit.mode.value
657
+ if "mcp_safe_tools" in data["audit"]:
658
+ data["audit"]["mcp_safe_tools"] = [
659
+ {"server": item.server, "tool": item.tool} for item in settings.audit.mcp_safe_tools
660
+ ]
649
661
  api_key = settings.model.api_key
650
662
  if api_key:
651
663
  data["model"]["api_key"] = api_key if reveal_secret else mask_secret(api_key)
@@ -661,6 +673,10 @@ def is_valid_ui_view_mode(value: str) -> bool:
661
673
  return value in UI_VIEW_MODES
662
674
 
663
675
 
676
+ def is_valid_config_audit_mode(value: str) -> bool:
677
+ return is_valid_audit_mode(value)
678
+
679
+
664
680
  def is_supported_deepseek_model(value: str) -> bool:
665
681
  return value in SUPPORTED_DEEPSEEK_MODELS
666
682
 
@@ -734,6 +750,10 @@ def write_config(
734
750
  "thinking": thinking_enabled_for_mode(mode, provider),
735
751
  "reasoning_effort": reasoning_effort_for_mode(mode, provider),
736
752
  },
753
+ "audit": {
754
+ "mode": DEFAULT_AUDIT_MODE.value,
755
+ "mcp_safe_tools": [],
756
+ },
737
757
  "context": {
738
758
  "window_tokens": DEFAULT_CONTEXT_WINDOW_TOKENS,
739
759
  "compact_trigger_ratio": DEFAULT_COMPACT_TRIGGER_RATIO,
@@ -873,6 +893,20 @@ def update_config_view_mode(config_path: Path, view_mode: str) -> None:
873
893
  _write_private_toml(path, raw)
874
894
 
875
895
 
896
+ def update_config_audit_mode(config_path: Path, audit_mode: str) -> None:
897
+ if not is_valid_config_audit_mode(audit_mode):
898
+ raise ValueError("Audit mode must be one of: normal, auto, yolo.")
899
+ path = config_path.expanduser()
900
+ if path.suffix == ".json":
901
+ raise ValueError("Deepy only supports TOML config files; JSON config is not supported.")
902
+ raw = _read_toml_mapping(path)
903
+ audit = raw.get("audit")
904
+ audit_map = dict(audit) if isinstance(audit, Mapping) else {}
905
+ audit_map["mode"] = audit_mode
906
+ raw["audit"] = audit_map
907
+ _write_private_toml(path, raw)
908
+
909
+
876
910
  def _read_toml_mapping(path: Path) -> dict[str, Any]:
877
911
  if not path.exists():
878
912
  return {}
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  from pathlib import Path
4
4
  from typing import TYPE_CHECKING, Any
5
5
 
6
+ from deepy.audit import AuditPolicy
6
7
  from deepy.config import Settings
7
8
  from deepy.mcp import sdk_mcp_tool_name
8
9
  from deepy.prompts import build_system_prompt
@@ -28,6 +29,7 @@ def build_deepy_agent(
28
29
  mcp_servers: list[MCPServer] | None = None,
29
30
  preferred_mcp_web_search_tools: list[str] | None = None,
30
31
  emit_event: Any | None = None,
32
+ audit_policy: AuditPolicy | None = None,
31
33
  ):
32
34
  from agents import Agent
33
35
 
@@ -39,6 +41,7 @@ def build_deepy_agent(
39
41
  settings.model.name,
40
42
  ),
41
43
  preferred_mcp_web_search_tools=preferred_mcp_web_search_tools,
44
+ audit_policy=audit_policy,
42
45
  )
43
46
  subagent_tools = build_subagent_tools(
44
47
  settings,
@@ -52,6 +55,7 @@ def build_deepy_agent(
52
55
  settings.model.name,
53
56
  ),
54
57
  emit_event=emit_event,
58
+ audit_policy=audit_policy,
55
59
  )
56
60
  return Agent(
57
61
  name="Deepy",
@@ -89,6 +93,7 @@ def build_subagent_tools(
89
93
  preferred_mcp_web_search_tools: list[str],
90
94
  mimo_schema_compatibility: bool = False,
91
95
  emit_event: Any | None = None,
96
+ audit_policy: AuditPolicy | None = None,
92
97
  ) -> list[Any]:
93
98
  from agents import Agent
94
99
 
@@ -105,6 +110,7 @@ def build_subagent_tools(
105
110
  mimo_schema_compatibility=mimo_schema_compatibility,
106
111
  preferred_mcp_web_search_tools=preferred_mcp_web_search_tools,
107
112
  include_tools=set(definition.tools),
113
+ audit_policy=audit_policy,
108
114
  ),
109
115
  mcp_servers=_search_mcp_servers_for_subagent(
110
116
  definition,
@@ -2,12 +2,14 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import contextlib
5
+ import inspect
5
6
  import time
6
- from collections.abc import Callable
7
+ from collections.abc import Awaitable, Callable
7
8
  from dataclasses import dataclass, field
8
9
  from pathlib import Path
9
- from typing import Any, Literal
10
+ from typing import Any, Literal, cast
10
11
 
12
+ from deepy.audit import ApprovalDecision, AuditMode, AuditModeState, AuditPolicy, PendingApproval, parse_audit_mode
11
13
  from deepy.config import Settings, load_settings
12
14
  from deepy.sessions import DeepySession
13
15
  from deepy.skills import find_skill
@@ -65,6 +67,12 @@ async def run_prompt_once(
65
67
  background_tasks: BackgroundTaskManager | None = None,
66
68
  should_interrupt: Callable[[], bool] | None = None,
67
69
  cancel_mode: Literal["immediate", "after_turn"] = "immediate",
70
+ audit_mode: AuditMode | str | AuditModeState | None = None,
71
+ approval_resolver: Callable[
72
+ [list[PendingApproval]],
73
+ list[ApprovalDecision] | Awaitable[list[ApprovalDecision]],
74
+ ]
75
+ | None = None,
68
76
  ) -> RunSummary:
69
77
  from agents import RunConfig, Runner
70
78
  from agents.exceptions import MaxTurnsExceeded, ModelBehaviorError
@@ -73,6 +81,10 @@ async def run_prompt_once(
73
81
  root = (project_root or Path.cwd()).resolve()
74
82
  resolved_settings = settings or load_settings()
75
83
  resolved_provider = provider or build_provider_bundle(resolved_settings)
84
+ audit_state = audit_mode if isinstance(audit_mode, AuditModeState) else AuditModeState(
85
+ parse_audit_mode(audit_mode, default=resolved_settings.audit.mode)
86
+ )
87
+ audit_policy = AuditPolicy(lambda: audit_state.mode, resolved_settings.audit)
76
88
  session = DeepySession.open(root, session_id) if session_id else DeepySession.create(root)
77
89
  initial_todos, _ = normalize_todo_items(session.todo_state())
78
90
  runtime = ToolRuntime(
@@ -84,7 +96,11 @@ async def run_prompt_once(
84
96
  )
85
97
  created_mcp_runtime: DeepyMcpRuntime | None = None
86
98
  if mcp_runtime is None:
87
- created_mcp_runtime = DeepyMcpRuntime(resolved_settings, project_root=root)
99
+ created_mcp_runtime = DeepyMcpRuntime(
100
+ resolved_settings,
101
+ project_root=root,
102
+ audit_policy=audit_policy,
103
+ )
88
104
  mcp_runtime = created_mcp_runtime
89
105
  await mcp_runtime.connect()
90
106
  loaded_skills = _resolve_loaded_skills(root, prompt, skill_names)
@@ -97,6 +113,7 @@ async def run_prompt_once(
97
113
  mcp_servers=mcp_runtime.active_servers,
98
114
  preferred_mcp_web_search_tools=mcp_runtime.preferred_web_search_tools,
99
115
  emit_event=emit_event,
116
+ audit_policy=audit_policy,
100
117
  )
101
118
  prefix_snapshot = build_cache_prefix_snapshot(
102
119
  resolved_settings,
@@ -158,49 +175,74 @@ async def run_prompt_once(
158
175
  prefix_token: Any | None = None
159
176
  try:
160
177
  prefix_token = set_current_cache_prefix_snapshot(prefix_snapshot)
161
- result = Runner.run_streamed(
162
- agent,
163
- input=prompt,
164
- max_turns=max_turns,
165
- run_config=run_config,
166
- session=session, # ty: ignore[invalid-argument-type] - DeepySession matches the SDK Session protocol at runtime.
167
- )
168
- if should_interrupt is not None:
169
- interrupt_task = asyncio.create_task(
170
- _watch_stream_interrupt(
171
- result,
172
- should_interrupt=should_interrupt,
173
- cancel_mode=cancel_mode,
174
- )
178
+ run_input: Any = prompt
179
+ while True:
180
+ result = Runner.run_streamed(
181
+ agent,
182
+ input=run_input,
183
+ max_turns=max_turns,
184
+ run_config=run_config,
185
+ session=session, # ty: ignore[invalid-argument-type] - DeepySession matches the SDK Session protocol at runtime.
175
186
  )
176
- async for event in result.stream_events():
177
- if should_interrupt is not None and should_interrupt():
178
- _cancel_stream_result(result, mode=cancel_mode)
179
- interrupted = True
180
- break
181
- normalized = normalize_stream_event(event)
182
- if normalized is None:
183
- continue
184
- if normalized.kind == "usage":
185
- usage = merge_usage(usage, normalize_usage(normalized.payload.get("usage")))
186
- if emit_event is not None:
187
- emit_event(normalized)
188
- if normalized.kind == "tool_output":
189
- questions = _pending_questions_from_tool_output(normalized.text)
190
- if questions:
191
- pending_questions = questions
192
- waiting_for_user = True
193
- _cancel_stream_result(result, mode="after_turn")
187
+ if should_interrupt is not None:
188
+ interrupt_task = asyncio.create_task(
189
+ _watch_stream_interrupt(
190
+ result,
191
+ should_interrupt=should_interrupt,
192
+ cancel_mode=cancel_mode,
193
+ )
194
+ )
195
+ async for event in result.stream_events():
196
+ if should_interrupt is not None and should_interrupt():
197
+ _cancel_stream_result(result, mode=cancel_mode)
198
+ interrupted = True
194
199
  break
195
- if normalized.kind != "text_delta" or not normalized.text:
196
- continue
197
- chunks.append(normalized.text)
198
- if emit is not None:
199
- emit(normalized.text)
200
- if should_interrupt is not None and should_interrupt():
201
- _cancel_stream_result(result, mode=cancel_mode)
202
- interrupted = True
200
+ normalized = normalize_stream_event(event)
201
+ if normalized is None:
202
+ continue
203
+ if normalized.kind == "usage":
204
+ usage = merge_usage(usage, normalize_usage(normalized.payload.get("usage")))
205
+ if emit_event is not None:
206
+ emit_event(normalized)
207
+ if normalized.kind == "tool_output":
208
+ questions = _pending_questions_from_tool_output(normalized.text)
209
+ if questions:
210
+ pending_questions = questions
211
+ waiting_for_user = True
212
+ _cancel_stream_result(result, mode="after_turn")
213
+ break
214
+ if normalized.kind != "text_delta" or not normalized.text:
215
+ continue
216
+ chunks.append(normalized.text)
217
+ if emit is not None:
218
+ emit(normalized.text)
219
+ if should_interrupt is not None and should_interrupt():
220
+ _cancel_stream_result(result, mode=cancel_mode)
221
+ interrupted = True
222
+ break
223
+ interrupted = interrupted or await _finish_interrupt_task(interrupt_task)
224
+ interrupt_task = None
225
+ if interrupted or waiting_for_user:
203
226
  break
227
+ interruptions = list(getattr(result, "interruptions", []) or [])
228
+ if not interruptions:
229
+ break
230
+ state = result.to_state()
231
+ decisions = await _approval_decisions(
232
+ interruptions,
233
+ approval_resolver=approval_resolver,
234
+ )
235
+ for interruption, decision in zip(interruptions, decisions, strict=False):
236
+ if decision.outcome == "approve":
237
+ state.approve(interruption, always_approve=decision.always)
238
+ else:
239
+ state.reject(
240
+ interruption,
241
+ always_reject=decision.always,
242
+ rejection_message=decision.rejection_message
243
+ or "Tool execution was rejected by the user audit approval decision.",
244
+ )
245
+ run_input = state
204
246
  if prefix_token is not None:
205
247
  reset_current_cache_prefix_snapshot(prefix_token)
206
248
  prefix_token = None
@@ -375,6 +417,123 @@ async def _cleanup_created_mcp(mcp_runtime: DeepyMcpRuntime | None) -> None:
375
417
  await mcp_runtime.cleanup()
376
418
 
377
419
 
420
+ async def _approval_decisions(
421
+ interruptions: list[Any],
422
+ *,
423
+ approval_resolver: Callable[
424
+ [list[PendingApproval]],
425
+ list[ApprovalDecision] | Awaitable[list[ApprovalDecision]],
426
+ ]
427
+ | None,
428
+ ) -> list[ApprovalDecision]:
429
+ pending = [_pending_approval_from_interruption(index, item) for index, item in enumerate(interruptions)]
430
+ if approval_resolver is None:
431
+ return [
432
+ ApprovalDecision(
433
+ outcome="reject",
434
+ rejection_message="Tool execution requires audit approval, but no approval UI is available.",
435
+ )
436
+ for _ in pending
437
+ ]
438
+ resolved = approval_resolver(pending)
439
+ if inspect.isawaitable(resolved):
440
+ resolved = await resolved
441
+ decisions = list(cast(list[ApprovalDecision], resolved))
442
+ if len(decisions) < len(pending):
443
+ decisions = [
444
+ *decisions,
445
+ *[
446
+ ApprovalDecision(
447
+ outcome="reject",
448
+ rejection_message="Tool execution was rejected because no audit decision was provided.",
449
+ )
450
+ for _ in range(len(pending) - len(decisions))
451
+ ],
452
+ ]
453
+ return decisions[: len(pending)]
454
+
455
+
456
+ def _pending_approval_from_interruption(index: int, item: Any) -> PendingApproval:
457
+ raw_item = getattr(item, "raw_item", None)
458
+ tool_name = _approval_tool_name(item, raw_item)
459
+ arguments = _approval_arguments(item, raw_item)
460
+ agent = getattr(item, "agent", None)
461
+ agent_name = str(getattr(agent, "name", "") or "")
462
+ server_name = _approval_server_name(raw_item, tool_name)
463
+ return PendingApproval(
464
+ index=index,
465
+ name=str(getattr(item, "name", "") or tool_name or "tool"),
466
+ tool_name=tool_name,
467
+ arguments=arguments,
468
+ agent_name=agent_name,
469
+ action_kind="mcp_tool" if server_name else _approval_action_kind(tool_name),
470
+ server_name=server_name,
471
+ )
472
+
473
+
474
+ def _approval_tool_name(item: Any, raw_item: Any) -> str:
475
+ for value in (
476
+ getattr(item, "tool_name", None),
477
+ getattr(item, "name", None),
478
+ getattr(raw_item, "name", None),
479
+ ):
480
+ if isinstance(value, str) and value:
481
+ return value
482
+ if isinstance(raw_item, dict):
483
+ value = raw_item.get("name")
484
+ if isinstance(value, str):
485
+ return value
486
+ function = raw_item.get("function")
487
+ if isinstance(function, dict) and isinstance(function.get("name"), str):
488
+ return function["name"]
489
+ return ""
490
+
491
+
492
+ def _approval_arguments(item: Any, raw_item: Any) -> str:
493
+ for value in (getattr(item, "arguments", None), getattr(raw_item, "arguments", None)):
494
+ if isinstance(value, str):
495
+ return value
496
+ if isinstance(raw_item, dict):
497
+ value = raw_item.get("arguments")
498
+ if isinstance(value, str):
499
+ return value
500
+ function = raw_item.get("function")
501
+ if isinstance(function, dict) and isinstance(function.get("arguments"), str):
502
+ return function["arguments"]
503
+ arguments = raw_item.get("arguments_json") or raw_item.get("input")
504
+ if arguments is not None:
505
+ return json_utils.dumps(arguments)
506
+ arguments = getattr(raw_item, "arguments_json", None)
507
+ if arguments is not None:
508
+ return json_utils.dumps(arguments)
509
+ return ""
510
+
511
+
512
+ def _approval_server_name(raw_item: Any, tool_name: str) -> str:
513
+ for attr in ("server_label", "server_name"):
514
+ value = getattr(raw_item, attr, None)
515
+ if isinstance(value, str) and value:
516
+ return value
517
+ if isinstance(raw_item, dict):
518
+ for key in ("server_label", "server_name"):
519
+ value = raw_item.get(key)
520
+ if isinstance(value, str) and value:
521
+ return value
522
+ if "__" in tool_name:
523
+ return tool_name.split("__", 1)[0]
524
+ return ""
525
+
526
+
527
+ def _approval_action_kind(tool_name: str) -> str:
528
+ if tool_name in {"Write", "Update"}:
529
+ return "text_write"
530
+ if tool_name == "shell":
531
+ return "command"
532
+ if tool_name == "task_stop":
533
+ return "background_task_control"
534
+ return "tool"
535
+
536
+
378
537
  def _resolve_loaded_skills(
379
538
  root: Path,
380
539
  prompt: str,