zjcode 0.0.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.
Files changed (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. zjcode-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,547 @@
1
+ """Trust store for skill directories that resolve outside trusted roots.
2
+
3
+ `load_skill_content` refuses to read a `SKILL.md` whose resolved path falls
4
+ outside every trusted skill root — this stops a symlink inside a skill
5
+ directory from reading arbitrary files. The static escape hatch is the
6
+ `DEEPAGENTS_CODE_EXTRA_SKILLS_DIRS` env var / `[skills].extra_allowed_dirs`
7
+ config allowlist.
8
+
9
+ This module adds an in-the-moment, persistent approval path (mirroring
10
+ `deepagents_code.mcp_trust`): when a skill resolves outside the trusted roots,
11
+ the user is asked once to allow the resolved target directory, and the decision
12
+ is remembered. Trust is keyed by the approved target directory — the canonical
13
+ path resolved and shown to the user at approval time, stored as-is and never
14
+ re-resolved.
15
+
16
+ Two distinct post-approval swaps are caught by two distinct layers, so neither
17
+ grants access the user never approved:
18
+
19
+ * Re-pointing the *discovery* symlink (the `SKILL.md` path) at a new target is
20
+ caught by containment enforcement in `load_skill_content`: the new target is
21
+ not on the allowlist, so the read is refused and the user is re-prompted.
22
+ The stored trust entry — the original resolved target — is untouched.
23
+ * Replacing the *stored* directory itself (or one of its parents) with a symlink
24
+ is caught by the `resolve()`-to-self re-verification in
25
+ `load_trusted_skill_dirs`, which drops the stale entry rather than following
26
+ the injected symlink to a directory the user never approved.
27
+
28
+ Trust entries are app-managed bookkeeping (a set of approved directories), not
29
+ user-facing configuration, so they live alongside the other state files under
30
+ `~/.deepagents/.state/skill_trust.json` rather than in the hand-editable
31
+ `config.toml`.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import contextlib
37
+ import json
38
+ import logging
39
+ import os
40
+ import tempfile
41
+ from datetime import UTC, datetime
42
+ from enum import Enum
43
+ from pathlib import Path
44
+ from typing import TYPE_CHECKING, Any, TypedDict
45
+
46
+ if TYPE_CHECKING:
47
+ from collections.abc import Mapping
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+ _STORAGE_VERSION = 1
52
+ """Schema version stamped into `skill_trust.json`; bump on incompatible changes."""
53
+
54
+
55
+ class _TrustEntry(TypedDict):
56
+ """One trusted-directory record in the store's `dirs` map."""
57
+
58
+ trusted_at: str
59
+ """ISO-8601 UTC timestamp of when the directory was approved."""
60
+
61
+
62
+ class _TrustStore(TypedDict):
63
+ """On-disk shape of `skill_trust.json`."""
64
+
65
+ version: int
66
+ """Schema version written to the store file."""
67
+
68
+ dirs: dict[str, _TrustEntry]
69
+ """Trusted directories keyed by their approved absolute path."""
70
+
71
+
72
+ class RevokeResult(Enum):
73
+ """Outcome of a `revoke_skill_dir_trust` call.
74
+
75
+ Distinguishing `NOT_FOUND` from `REMOVED` lets the CLI print an honest
76
+ message instead of a false success when the target was never trusted (a
77
+ plain bool collapsed the two).
78
+ """
79
+
80
+ REMOVED = "removed"
81
+ """An entry existed and was removed from the store."""
82
+
83
+ NOT_FOUND = "not_found"
84
+ """No matching entry existed; the store was left unchanged."""
85
+
86
+ ERROR = "error"
87
+ """The store could not be read or the removal could not be persisted."""
88
+
89
+
90
+ def _default_store_path() -> Path:
91
+ """Return `~/.deepagents/.state/skill_trust.json`.
92
+
93
+ Resolved at call time (not import time) so tests can redirect storage by
94
+ monkeypatching `deepagents_code.model_config.DEFAULT_STATE_DIR` — the same
95
+ pattern `deepagents_code.mcp_trust._default_store_path` uses.
96
+ """
97
+ from deepagents_code.model_config import DEFAULT_STATE_DIR
98
+
99
+ return DEFAULT_STATE_DIR / "skill_trust.json"
100
+
101
+
102
+ def _normalize(target_dir: Path | str) -> str:
103
+ """Return the resolved absolute string form of a directory key."""
104
+ return str(Path(target_dir).expanduser().resolve())
105
+
106
+
107
+ def _approved_key(target_dir: Path | str) -> str:
108
+ """Return the already-approved directory key without resolving again."""
109
+ return str(Path(target_dir).expanduser())
110
+
111
+
112
+ def _load_store(store_path: Path, *, strict: bool = False) -> dict[str, Any]:
113
+ """Read the JSON trust store file.
114
+
115
+ Args:
116
+ store_path: Path to the trust store file.
117
+ strict: When `True`, a store that exists but cannot be read or parsed
118
+ re-raises instead of degrading to `{}`. Read/modify/write callers
119
+ pass `strict=True` so a transient read error aborts the write
120
+ rather than silently rebuilding the store from an empty dict (which
121
+ would clobber every prior approval). The audit path passes it too so
122
+ it can report an unreadable store instead of claiming nothing is
123
+ trusted. Enforcement callers leave it `False` to stay fail-closed.
124
+
125
+ Returns:
126
+ Parsed JSON data, or an empty dict when the file is missing, or (only
127
+ when `strict` is `False`) when it is unreadable or corrupt.
128
+ A corrupt store degrades to "nothing trusted" so a bad file can't
129
+ crash startup. It is *not* self-healed on the next approval:
130
+ ordinary writes read with `strict=True` and refuse rather than
131
+ clobber a store they can't parse, so recovery from a corrupt file
132
+ requires `skills trust clear` (or `clear_trusted_skill_dirs`,
133
+ the only writer that overwrites blindly).
134
+
135
+ Raises:
136
+ OSError: When `strict` and an existing store cannot be read.
137
+ json.JSONDecodeError: When `strict` and an existing store is not valid
138
+ JSON.
139
+ ValueError: When `strict` and the store's top-level value is not a JSON
140
+ object, or its `version` is unrecognized (non-integer, or newer than
141
+ this build understands).
142
+ """
143
+ # A missing store is a normal first-run state, never an error — return
144
+ # empty even under `strict` so callers don't have to special-case it.
145
+ if not store_path.exists():
146
+ return {}
147
+ try:
148
+ data = json.loads(store_path.read_text(encoding="utf-8"))
149
+ except json.JSONDecodeError as exc:
150
+ if strict:
151
+ raise
152
+ # A corrupt store silently drops every prior approval and forces a
153
+ # re-prompt, so log at WARNING (not DEBUG) to leave a breadcrumb for
154
+ # the otherwise-unexplained re-prompt.
155
+ logger.warning(
156
+ "Skill trust store %s is corrupt; treating as empty: %s", store_path, exc
157
+ )
158
+ return {}
159
+ except OSError as exc:
160
+ if strict:
161
+ raise
162
+ logger.warning(
163
+ "Could not read skill trust store %s; treating as empty: %s",
164
+ store_path,
165
+ exc,
166
+ )
167
+ return {}
168
+ if not isinstance(data, dict):
169
+ if strict:
170
+ msg = f"Skill trust store {store_path} is not a JSON object"
171
+ raise ValueError(msg)
172
+ logger.warning(
173
+ "Skill trust store %s is not a JSON object; ignoring", store_path
174
+ )
175
+ return {}
176
+ # A store written by a newer build may carry an incompatible schema. Reading
177
+ # its `dirs` regardless could misinterpret entries, so refuse: fail-closed
178
+ # (treat as nothing trusted) for enforcement, and surface the error for the
179
+ # audit path. A present-but-non-integer `version` is unrecognized in the same
180
+ # way (only tampering or a corrupt write produces it, since every writer
181
+ # stamps an int), so it is refused too rather than falling through and
182
+ # trusting `dirs`. A missing `version` stays tolerated: an empty `{}` file
183
+ # has no `dirs` to trust anyway. Together this makes the `_STORAGE_VERSION`
184
+ # "bump on incompatible changes" contract enforceable rather than
185
+ # aspirational.
186
+ version = data.get("version")
187
+ if version is not None and (
188
+ not isinstance(version, int) or version > _STORAGE_VERSION
189
+ ):
190
+ if strict:
191
+ msg = (
192
+ f"Skill trust store {store_path} has an unrecognized schema "
193
+ f"version {version!r} (this build understands <= {_STORAGE_VERSION}); "
194
+ f"refusing to read it"
195
+ )
196
+ raise ValueError(msg)
197
+ logger.warning(
198
+ "Skill trust store %s has an unrecognized schema version %r "
199
+ "(this build understands <= %s); treating as empty",
200
+ store_path,
201
+ version,
202
+ _STORAGE_VERSION,
203
+ )
204
+ return {}
205
+ return data
206
+
207
+
208
+ def _save_store(data: Mapping[str, Any], store_path: Path) -> bool:
209
+ """Atomic write of JSON trust data to `store_path`.
210
+
211
+ Uses `tempfile.mkstemp` + `Path.replace` for crash safety.
212
+
213
+ Args:
214
+ data: Full store dict to write.
215
+ store_path: Destination path.
216
+
217
+ Returns:
218
+ `True` on success, `False` on I/O failure.
219
+ """
220
+ try:
221
+ store_path.parent.mkdir(parents=True, exist_ok=True)
222
+ fd, tmp_path = tempfile.mkstemp(dir=store_path.parent, suffix=".tmp")
223
+ # Wrap the raw fd in a file object in its own stage: if `os.fdopen`
224
+ # raises, it did not take ownership, so the fd is still open and must be
225
+ # closed explicitly (otherwise it leaks). Only close it here — once
226
+ # `fdopen` succeeds the `with` below owns and closes it exactly once, and
227
+ # a bare `os.close(fd)` in the outer handler could race a recycled fd
228
+ # (this runs under `asyncio.to_thread`).
229
+ try:
230
+ handle = os.fdopen(fd, "w", encoding="utf-8")
231
+ except BaseException:
232
+ with contextlib.suppress(OSError):
233
+ os.close(fd)
234
+ with contextlib.suppress(OSError):
235
+ Path(tmp_path).unlink()
236
+ raise
237
+ try:
238
+ with handle as f:
239
+ json.dump(data, f, indent=2)
240
+ Path(tmp_path).replace(store_path)
241
+ except BaseException:
242
+ with contextlib.suppress(OSError):
243
+ Path(tmp_path).unlink()
244
+ raise
245
+ except (OSError, ValueError):
246
+ logger.exception("Failed to save skill trust store to %s", store_path)
247
+ return False
248
+ return True
249
+
250
+
251
+ def _read_dirs(store_path: Path, *, strict: bool = False) -> dict[str, Any]:
252
+ """Return the `dirs` mapping from the store, or an empty dict.
253
+
254
+ Args:
255
+ store_path: Path to the trust store file.
256
+ strict: Propagated to `_load_store`; see its docstring.
257
+ """
258
+ dirs = _load_store(store_path, strict=strict).get("dirs", {})
259
+ return dirs if isinstance(dirs, dict) else {}
260
+
261
+
262
+ def is_skill_dir_trusted(
263
+ target_dir: Path | str,
264
+ *,
265
+ store_path: Path | None = None,
266
+ ) -> bool:
267
+ """Check whether a resolved skill directory has been trusted.
268
+
269
+ Warning:
270
+ This resolves `target_dir` and checks raw membership; it does NOT do
271
+ the `resolve()`-to-self re-verification that `load_trusted_skill_dirs`
272
+ performs on each stored entry. It is therefore **not** the
273
+ containment-enforcement primitive — enforcement builds the allowlist
274
+ from `load_trusted_skill_dirs`, which drops post-approval symlink swaps.
275
+ Use this only for informational "is this exact resolved dir on record?"
276
+ checks.
277
+
278
+ Note that this check happens to fail *closed*, not open: because it
279
+ resolves the query `target_dir`, a stored directory later swapped for a
280
+ symlink is reported **not** trusted (the query resolves to the swap
281
+ target, which is not the stored key), forcing a re-prompt — the safe
282
+ direction. It is excluded from enforcement for being an exact-membership
283
+ test that skips the resolve-to-self recheck, not because it could grant
284
+ access the user never approved.
285
+
286
+ The lookup resolves `target_dir` (via `_normalize`), but `trust_skill_dir`
287
+ stores the expanduser-only `_approved_key`. In the live flow the two
288
+ coincide because callers approve an already-resolved path, so the keys
289
+ are identical. A caller that trusted a *non-canonical* path would see a
290
+ false negative here (the only failure direction, and the safe one). Pass
291
+ an already-resolved directory to keep the check meaningful.
292
+
293
+ Args:
294
+ target_dir: Directory to check; resolved before lookup.
295
+ store_path: Path to the trust store file. Defaults to
296
+ `~/.deepagents/.state/skill_trust.json`.
297
+
298
+ Returns:
299
+ `True` if the resolved directory is present in the store.
300
+ """
301
+ if store_path is None:
302
+ store_path = _default_store_path()
303
+ return _normalize(target_dir) in _read_dirs(store_path)
304
+
305
+
306
+ def trust_skill_dir(
307
+ target_dir: Path | str,
308
+ *,
309
+ store_path: Path | None = None,
310
+ ) -> bool:
311
+ """Persist trust for a resolved skill directory.
312
+
313
+ Args:
314
+ target_dir: Canonical directory to trust. This is expected to be the
315
+ already-resolved path shown to the user, and is not resolved again
316
+ before storing so a post-approval symlink swap cannot change what
317
+ gets persisted.
318
+ store_path: Path to the trust store file. Defaults to
319
+ `~/.deepagents/.state/skill_trust.json`.
320
+
321
+ Returns:
322
+ `True` if the entry was saved successfully.
323
+ """
324
+ if store_path is None:
325
+ store_path = _default_store_path()
326
+
327
+ # Read strictly: if an existing store can't be read, abort rather than
328
+ # rebuild it from `{}` and overwrite (which would drop every prior
329
+ # approval). A transient read error should re-prompt next time, not
330
+ # silently erase the store.
331
+ try:
332
+ data = _load_store(store_path, strict=True)
333
+ except (OSError, ValueError):
334
+ logger.exception(
335
+ "Refusing to persist skill trust: could not read existing store %s",
336
+ store_path,
337
+ )
338
+ return False
339
+ # The key is stored expanduser-only and never re-resolved (that is the
340
+ # anti-symlink-swap property). That only holds the invariant "the stored key
341
+ # is the canonical dir the user approved" if the caller already passed a
342
+ # canonical path. If it did not, `load_trusted_skill_dirs` will later drop
343
+ # the entry at its resolve()-to-self check and the approval silently never
344
+ # persists (re-prompt every session). Warn at the write boundary so that
345
+ # caller bug surfaces here instead of as a mysterious never-remembered trust.
346
+ key = _approved_key(target_dir)
347
+ try:
348
+ is_canonical = key == _normalize(target_dir)
349
+ except OSError:
350
+ # Resolving for the diagnostic failed; skip the warning rather than
351
+ # abort the write. The read-time resolve()-to-self check is the actual
352
+ # safety net, not this best-effort boundary hint.
353
+ is_canonical = True
354
+ if not is_canonical:
355
+ logger.warning(
356
+ "trust_skill_dir called with a non-canonical path %r; the stored "
357
+ "entry will be dropped at read time. Pass an already-resolved "
358
+ "directory.",
359
+ target_dir,
360
+ )
361
+
362
+ dirs = data.get("dirs")
363
+ if not isinstance(dirs, dict):
364
+ dirs = {}
365
+ dirs[key] = _TrustEntry(trusted_at=datetime.now(UTC).isoformat())
366
+ return _save_store(_TrustStore(version=_STORAGE_VERSION, dirs=dirs), store_path)
367
+
368
+
369
+ def revoke_skill_dir_trust(
370
+ target_dir: Path | str,
371
+ *,
372
+ store_path: Path | None = None,
373
+ ) -> RevokeResult:
374
+ """Remove trust for a skill directory.
375
+
376
+ Matches on both the approved (expanduser-only) key form that
377
+ `trust_skill_dir` stores and the fully-resolved form, so a caller can
378
+ revoke either by the path they see in `skills trust list` or by the
379
+ original symlink path.
380
+
381
+ Args:
382
+ target_dir: Directory to revoke.
383
+ store_path: Path to the trust store file. Defaults to
384
+ `~/.deepagents/.state/skill_trust.json`.
385
+
386
+ Returns:
387
+ `RevokeResult.REMOVED` if a matching entry was removed and persisted,
388
+ `RevokeResult.NOT_FOUND` if no entry matched (store left unchanged), or
389
+ `RevokeResult.ERROR` if the store could not be read or the write failed.
390
+ """
391
+ if store_path is None:
392
+ store_path = _default_store_path()
393
+
394
+ # Read strictly so a transient read error aborts rather than rebuilding
395
+ # from `{}` and dropping the other entries on the next save.
396
+ try:
397
+ data = _load_store(store_path, strict=True)
398
+ except (OSError, ValueError):
399
+ logger.exception(
400
+ "Refusing to revoke skill trust: could not read existing store %s",
401
+ store_path,
402
+ )
403
+ return RevokeResult.ERROR
404
+ dirs = data.get("dirs")
405
+ if not isinstance(dirs, dict):
406
+ return RevokeResult.NOT_FOUND
407
+ keys = {_approved_key(target_dir), _normalize(target_dir)}
408
+ removed = False
409
+ for key in keys:
410
+ if key in dirs:
411
+ del dirs[key]
412
+ removed = True
413
+ if not removed:
414
+ return RevokeResult.NOT_FOUND
415
+ data["version"] = _STORAGE_VERSION
416
+ data["dirs"] = dirs
417
+ return RevokeResult.REMOVED if _save_store(data, store_path) else RevokeResult.ERROR
418
+
419
+
420
+ def clear_trusted_skill_dirs(*, store_path: Path | None = None) -> bool:
421
+ """Remove all trusted skill directories.
422
+
423
+ Args:
424
+ store_path: Path to the trust store file. Defaults to
425
+ `~/.deepagents/.state/skill_trust.json`.
426
+
427
+ Returns:
428
+ `True` if the store was cleared (or was already empty).
429
+ """
430
+ if store_path is None:
431
+ store_path = _default_store_path()
432
+
433
+ if not store_path.exists():
434
+ return True
435
+ return _save_store(_TrustStore(version=_STORAGE_VERSION, dirs={}), store_path)
436
+
437
+
438
+ def list_trusted_skill_dirs(
439
+ *,
440
+ store_path: Path | None = None,
441
+ strict: bool = False,
442
+ ) -> list[str]:
443
+ """Return the sorted list of trusted skill directory paths.
444
+
445
+ Args:
446
+ store_path: Path to the trust store file. Defaults to
447
+ `~/.deepagents/.state/skill_trust.json`.
448
+ strict: When `True`, an existing-but-unreadable store re-raises instead
449
+ of degrading to an empty list. The audit command (`skills trust
450
+ list`) passes `strict=True` so it can report an error rather than
451
+ falsely printing "No trusted skill directories" while entries the
452
+ user cannot then see or revoke sit in an unreadable file.
453
+
454
+ Returns:
455
+ Sorted absolute directory paths previously trusted.
456
+
457
+ When `strict`, an existing-but-unreadable or corrupt store propagates
458
+ the underlying error (`OSError` / `json.JSONDecodeError` / `ValueError`)
459
+ from `_load_store` instead of returning a list.
460
+ """
461
+ if store_path is None:
462
+ store_path = _default_store_path()
463
+ return sorted(_read_dirs(store_path, strict=strict))
464
+
465
+
466
+ def list_trusted_skill_dir_entries(
467
+ *,
468
+ store_path: Path | None = None,
469
+ strict: bool = False,
470
+ ) -> list[tuple[str, str]]:
471
+ """Return trusted directories paired with their approval timestamps.
472
+
473
+ The audit surface for the `trusted_at` metadata that `trust_skill_dir`
474
+ records: `list_trusted_skill_dirs` returns only paths (all enforcement
475
+ needs), so this is the one reader of the timestamp, used by `skills trust
476
+ list` to show *when* each directory was approved.
477
+
478
+ Args:
479
+ store_path: Path to the trust store file. Defaults to
480
+ `~/.deepagents/.state/skill_trust.json`.
481
+ strict: Propagated to `_load_store`; see `list_trusted_skill_dirs`.
482
+
483
+ Returns:
484
+ `(path, trusted_at)` tuples sorted by path. `trusted_at` is the stored
485
+ ISO-8601 string, or `""` when a hand-edited entry omitted
486
+ or malformed it (the path is still listed so it remains
487
+ visible and revocable).
488
+ """
489
+ if store_path is None:
490
+ store_path = _default_store_path()
491
+ entries: list[tuple[str, str]] = []
492
+ for path, entry in _read_dirs(store_path, strict=strict).items():
493
+ trusted_at = entry.get("trusted_at", "") if isinstance(entry, dict) else ""
494
+ entries.append((path, trusted_at if isinstance(trusted_at, str) else ""))
495
+ return sorted(entries)
496
+
497
+
498
+ def load_trusted_skill_dirs(*, store_path: Path | None = None) -> list[Path]:
499
+ """Return verified trusted skill directories as canonical `Path` objects.
500
+
501
+ Used to extend the containment allowlist passed to `load_skill_content`.
502
+
503
+ Stored entries are the exact canonical directory the user approved (already
504
+ resolved at trust time). Each entry is re-verified here rather than blindly
505
+ re-resolved: if a stored path no longer resolves to itself — because it, or
506
+ a parent component, was replaced with a symlink after approval — the current
507
+ resolution would point somewhere the user never approved. Such entries are
508
+ dropped (and logged) instead of silently allowlisting the swapped target, so
509
+ a post-approval symlink swap re-prompts rather than granting access.
510
+
511
+ Args:
512
+ store_path: Path to the trust store file. Defaults to
513
+ `~/.deepagents/.state/skill_trust.json`.
514
+
515
+ Returns:
516
+ Canonical directory paths that still resolve to themselves; empty when
517
+ nothing is trusted.
518
+ """
519
+ verified: list[Path] = []
520
+ for entry in list_trusted_skill_dirs(store_path=store_path):
521
+ stored = Path(entry)
522
+ try:
523
+ resolves_to_self = stored.resolve() == stored
524
+ except (OSError, RuntimeError):
525
+ # A single unresolvable entry (e.g. a symlink cycle introduced under
526
+ # the stored path) must not abort discovery of every other skill.
527
+ # Drop it like the swap case below. `RuntimeError` is caught
528
+ # alongside `OSError` to match the resolve guard in
529
+ # `app._prompt_skill_trust_and_retry` (some Python builds surface a
530
+ # symlink loop as `RuntimeError`).
531
+ logger.warning(
532
+ "Trusted skill directory %s could not be resolved; "
533
+ "ignoring the trust entry.",
534
+ entry,
535
+ exc_info=True,
536
+ )
537
+ continue
538
+ if resolves_to_self:
539
+ verified.append(stored)
540
+ else:
541
+ logger.warning(
542
+ "Trusted skill directory %s no longer resolves to itself "
543
+ "(a symlink may have been introduced since approval); "
544
+ "ignoring the stale trust entry.",
545
+ entry,
546
+ )
547
+ return verified
@@ -0,0 +1,136 @@
1
+ """One-time migration of legacy state files into `~/.deepagents/.state/`.
2
+
3
+ Earlier versions wrote internal state directly under `~/.deepagents/`,
4
+ mixing it with user-facing agent directories (so e.g. `mcp-tokens/`
5
+ showed up in `deepagents agents list`). State now lives in a dedicated
6
+ `.state/` subdirectory; this module moves any legacy files into place
7
+ on startup.
8
+
9
+ The migration is best-effort and idempotent: it skips entries whose
10
+ destination already exists, logs and continues on per-entry failures,
11
+ and never blocks startup on I/O errors.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from typing import TYPE_CHECKING
18
+
19
+ from deepagents_code.model_config import DEFAULT_CONFIG_DIR, DEFAULT_STATE_DIR
20
+ from deepagents_code.onboarding import ONBOARDING_MARKER_FILENAME
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Iterable
24
+ from pathlib import Path
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ _LEGACY_NAMES: tuple[str, ...] = (
30
+ "mcp-tokens",
31
+ "sessions.db",
32
+ "sessions.db-wal",
33
+ "sessions.db-shm",
34
+ "latest_version.json",
35
+ "update_state.json",
36
+ "history.jsonl",
37
+ ONBOARDING_MARKER_FILENAME,
38
+ )
39
+ """Names directly under `~/.deepagents/` that now live in `.state/`.
40
+
41
+ `sessions.db-wal` and `sessions.db-shm` are SQLite sidecar files that may
42
+ or may not be present depending on whether the database was opened in WAL
43
+ mode and whether a checkpoint had run before shutdown.
44
+ """
45
+
46
+
47
+ def _iter_migrations(
48
+ config_dir: Path,
49
+ state_dir: Path,
50
+ names: Iterable[str],
51
+ ) -> Iterable[tuple[Path, Path]]:
52
+ for name in names:
53
+ yield config_dir / name, state_dir / name
54
+
55
+
56
+ def migrate_legacy_state(
57
+ *,
58
+ config_dir: Path = DEFAULT_CONFIG_DIR,
59
+ state_dir: Path = DEFAULT_STATE_DIR,
60
+ ) -> None:
61
+ """Move legacy state entries from `config_dir` into `state_dir`.
62
+
63
+ Idempotent: each entry is skipped when the destination already exists
64
+ (the migration ran on a prior invocation) or when the source does not
65
+ exist (nothing to move). Errors on individual entries are logged and
66
+ swallowed so a single unmovable file does not block the rest.
67
+
68
+ Args:
69
+ config_dir: Directory holding legacy state. Defaults to
70
+ `~/.deepagents/`.
71
+ state_dir: Destination directory for state files. Defaults to
72
+ `~/.deepagents/.state/`.
73
+ """
74
+ try:
75
+ if not config_dir.is_dir():
76
+ return
77
+ except OSError:
78
+ logger.debug(
79
+ "Could not stat %s; skipping state migration",
80
+ config_dir,
81
+ exc_info=True,
82
+ )
83
+ return
84
+
85
+ pending: list[tuple[Path, Path]] = []
86
+ for src, dst in _iter_migrations(config_dir, state_dir, _LEGACY_NAMES):
87
+ try:
88
+ src_exists = src.exists()
89
+ except OSError:
90
+ continue
91
+ if not src_exists:
92
+ continue
93
+ try:
94
+ dst_exists = dst.exists()
95
+ except OSError:
96
+ continue
97
+ if dst_exists:
98
+ # Both exist — typically an app downgrade after the migration
99
+ # already ran once (the older version recreates a fresh file
100
+ # at the legacy path) or a manually pre-populated `.state/`.
101
+ # Clobbering either copy could lose data, so skip and warn so
102
+ # the user can resolve it.
103
+ logger.warning(
104
+ "Cannot migrate %s -> %s: destination already exists. "
105
+ "Inspect both files and either delete the obsolete one "
106
+ "or move the legacy file in manually.",
107
+ src,
108
+ dst,
109
+ )
110
+ continue
111
+ pending.append((src, dst))
112
+ if not pending:
113
+ return
114
+
115
+ try:
116
+ state_dir.mkdir(parents=True, exist_ok=True)
117
+ except OSError:
118
+ logger.warning(
119
+ "Could not create state directory %s; skipping state migration",
120
+ state_dir,
121
+ exc_info=True,
122
+ )
123
+ return
124
+
125
+ for src, dst in pending:
126
+ try:
127
+ src.rename(dst)
128
+ except OSError:
129
+ logger.warning(
130
+ "Failed to migrate %s -> %s; leaving legacy file in place",
131
+ src,
132
+ dst,
133
+ exc_info=True,
134
+ )
135
+ continue
136
+ logger.info("Migrated %s -> %s", src, dst)