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.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- 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)
|