seedcode-cli 6.1.5__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.
- seedcode/__init__.py +14 -0
- seedcode/__main__.py +12 -0
- seedcode/app.py +508 -0
- seedcode/apps/__init__.py +32 -0
- seedcode/apps/discovery.py +241 -0
- seedcode/apps/installer.py +164 -0
- seedcode/apps/launcher.py +156 -0
- seedcode/apps/verifier.py +119 -0
- seedcode/assets/logo.txt +15 -0
- seedcode/cli.py +95 -0
- seedcode/commands/__init__.py +81 -0
- seedcode/commands/about.py +34 -0
- seedcode/commands/agent.py +94 -0
- seedcode/commands/assist.py +201 -0
- seedcode/commands/clear.py +20 -0
- seedcode/commands/desktop.py +104 -0
- seedcode/commands/doctor.py +152 -0
- seedcode/commands/help.py +61 -0
- seedcode/commands/history.py +365 -0
- seedcode/commands/palette.py +100 -0
- seedcode/commands/provider.py +451 -0
- seedcode/commands/theme.py +76 -0
- seedcode/computer/__init__.py +98 -0
- seedcode/computer/browser.py +276 -0
- seedcode/computer/browser_cdp.py +567 -0
- seedcode/computer/browser_engine.py +546 -0
- seedcode/computer/browser_extract.py +301 -0
- seedcode/computer/browser_popups.py +329 -0
- seedcode/computer/browser_selenium.py +209 -0
- seedcode/computer/browser_skills.py +245 -0
- seedcode/computer/catalog.py +200 -0
- seedcode/computer/controller.py +324 -0
- seedcode/computer/dispatcher.py +272 -0
- seedcode/computer/dpi.py +185 -0
- seedcode/computer/engine.py +105 -0
- seedcode/computer/keyboard.py +101 -0
- seedcode/computer/logbook.py +104 -0
- seedcode/computer/mouse.py +48 -0
- seedcode/computer/ocr.py +213 -0
- seedcode/computer/operator_skills.py +577 -0
- seedcode/computer/permissions.py +203 -0
- seedcode/computer/recovery.py +115 -0
- seedcode/computer/registry.py +107 -0
- seedcode/computer/resolver.py +434 -0
- seedcode/computer/screen.py +130 -0
- seedcode/computer/screen_state.py +412 -0
- seedcode/computer/selfguard.py +197 -0
- seedcode/computer/semantic.py +100 -0
- seedcode/computer/skills.py +139 -0
- seedcode/computer/state.py +199 -0
- seedcode/computer/verifier.py +177 -0
- seedcode/computer/vision.py +327 -0
- seedcode/computer/windows.py +217 -0
- seedcode/config/__init__.py +8 -0
- seedcode/config/defaults.py +22 -0
- seedcode/config/manager.py +62 -0
- seedcode/core/__init__.py +31 -0
- seedcode/core/agent.py +534 -0
- seedcode/core/chat.py +128 -0
- seedcode/core/client.py +9 -0
- seedcode/core/errors.py +199 -0
- seedcode/core/identity.py +66 -0
- seedcode/core/identity_store.py +119 -0
- seedcode/core/lifecycle.py +240 -0
- seedcode/core/limits.py +35 -0
- seedcode/core/models.py +347 -0
- seedcode/core/project.py +96 -0
- seedcode/core/providers/__init__.py +58 -0
- seedcode/core/providers/aerolink.py +324 -0
- seedcode/core/providers/base.py +230 -0
- seedcode/core/providers/freemodel.py +931 -0
- seedcode/core/providers/ollama.py +262 -0
- seedcode/core/providers/openrouter.py +393 -0
- seedcode/core/streaming.py +21 -0
- seedcode/memory/__init__.py +8 -0
- seedcode/memory/manager.py +47 -0
- seedcode/memory/storage.py +38 -0
- seedcode/memory/store.py +257 -0
- seedcode/tools/__init__.py +35 -0
- seedcode/tools/base.py +179 -0
- seedcode/tools/desktop.py +371 -0
- seedcode/tools/filesystem.py +309 -0
- seedcode/tools/git.py +72 -0
- seedcode/tools/patch.py +170 -0
- seedcode/tools/permissions.py +288 -0
- seedcode/tools/search.py +137 -0
- seedcode/tools/terminal.py +200 -0
- seedcode/tools/textio.py +59 -0
- seedcode/ui/__init__.py +164 -0
- seedcode/ui/badges.py +64 -0
- seedcode/ui/banner.py +78 -0
- seedcode/ui/dashboard.py +197 -0
- seedcode/ui/dialog.py +62 -0
- seedcode/ui/fuzzy.py +128 -0
- seedcode/ui/layout.py +54 -0
- seedcode/ui/menu.py +61 -0
- seedcode/ui/palette.py +40 -0
- seedcode/ui/progress.py +41 -0
- seedcode/ui/prompts.py +16 -0
- seedcode/ui/renderer.py +36 -0
- seedcode/ui/searchbox.py +70 -0
- seedcode/ui/selector.py +514 -0
- seedcode/ui/statusbar.py +38 -0
- seedcode/ui/textbox.py +61 -0
- seedcode/ui/theme.py +204 -0
- seedcode/ui/tree.py +91 -0
- seedcode/utils/__init__.py +22 -0
- seedcode/utils/helpers.py +97 -0
- seedcode/utils/logger.py +65 -0
- seedcode_cli-6.1.5.dist-info/METADATA +368 -0
- seedcode_cli-6.1.5.dist-info/RECORD +114 -0
- seedcode_cli-6.1.5.dist-info/WHEEL +4 -0
- seedcode_cli-6.1.5.dist-info/entry_points.txt +2 -0
- seedcode_cli-6.1.5.dist-info/licenses/LICENSE +21 -0
seedcode/tools/git.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Git integration tool: a safe subset of git run inside the workspace.
|
|
2
|
+
|
|
3
|
+
Read subcommands (status, log, diff, ...) work in every permission mode;
|
|
4
|
+
mutating ones (add, commit, checkout, ...) pass the execute gate AND the
|
|
5
|
+
user's Y/A/N confirmation, and remote ones (push, pull, fetch) get their own
|
|
6
|
+
confirmation category — Seed Code never commits or pushes silently. Anything
|
|
7
|
+
outside the allow-list is refused so the model cannot smuggle arbitrary
|
|
8
|
+
commands through the git tool (it must use run_command, which is gated too).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import shlex
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
from .base import ToolResult, register
|
|
17
|
+
from .permissions import CATEGORY_GIT_MUTATE, CATEGORY_GIT_REMOTE
|
|
18
|
+
from .terminal import run_command
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from .permissions import PermissionManager
|
|
22
|
+
|
|
23
|
+
_READ_SUBCOMMANDS = {"status", "log", "diff", "show", "branch", "remote", "blame", "shortlog"}
|
|
24
|
+
_WRITE_SUBCOMMANDS = {
|
|
25
|
+
"add", "commit", "checkout", "switch", "restore", "stash",
|
|
26
|
+
"merge", "rebase", "reset", "revert", "init", "tag", "rm", "mv",
|
|
27
|
+
}
|
|
28
|
+
_REMOTE_SUBCOMMANDS = {"push", "pull", "fetch"}
|
|
29
|
+
_GIT_TIMEOUT_S = 60
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@register(
|
|
33
|
+
"git",
|
|
34
|
+
"Run a git subcommand in the workspace (status, log, diff, add, commit, push, pull, ...).",
|
|
35
|
+
{"args": "git arguments, e.g. 'status' or 'commit -m \"message\"'"},
|
|
36
|
+
mutates=True,
|
|
37
|
+
)
|
|
38
|
+
def _git(perm: "PermissionManager", args: dict[str, Any]) -> ToolResult:
|
|
39
|
+
raw = str(args["args"]).strip()
|
|
40
|
+
if raw.startswith("git "):
|
|
41
|
+
raw = raw[4:] # tolerate "git status" as well as "status"
|
|
42
|
+
if not raw:
|
|
43
|
+
return ToolResult(False, "No git subcommand given.")
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
parts = shlex.split(raw)
|
|
47
|
+
except ValueError as exc:
|
|
48
|
+
return ToolResult(False, f"Could not parse git arguments: {exc}")
|
|
49
|
+
sub = parts[0].lower() if parts else ""
|
|
50
|
+
|
|
51
|
+
if sub in _WRITE_SUBCOMMANDS:
|
|
52
|
+
perm.check_execute(f"git {sub}")
|
|
53
|
+
perm.confirm_action(CATEGORY_GIT_MUTATE, f"git {raw}")
|
|
54
|
+
elif sub in _REMOTE_SUBCOMMANDS:
|
|
55
|
+
perm.check_execute(f"git {sub}")
|
|
56
|
+
perm.confirm_action(CATEGORY_GIT_REMOTE, f"git {raw}")
|
|
57
|
+
elif sub not in _READ_SUBCOMMANDS:
|
|
58
|
+
allowed = ", ".join(
|
|
59
|
+
sorted(_READ_SUBCOMMANDS | _WRITE_SUBCOMMANDS | _REMOTE_SUBCOMMANDS)
|
|
60
|
+
)
|
|
61
|
+
return ToolResult(False, f"git subcommand '{sub}' is not allowed. Allowed: {allowed}.")
|
|
62
|
+
|
|
63
|
+
# shlex.split validated the quoting; re-quote for the real shell.
|
|
64
|
+
quoted = " ".join(_quote(p) for p in parts)
|
|
65
|
+
return run_command(perm, f"git {quoted}", _GIT_TIMEOUT_S)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _quote(part: str) -> str:
|
|
69
|
+
"""Minimal cross-platform quoting (shlex.quote is POSIX-only)."""
|
|
70
|
+
if part and all(c.isalnum() or c in "-_=./:@^~+%," for c in part):
|
|
71
|
+
return part
|
|
72
|
+
return '"' + part.replace('"', '\\"') + '"'
|
seedcode/tools/patch.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Patch tools: precise in-place edits by exact text matching.
|
|
2
|
+
|
|
3
|
+
``edit_file`` is the agent's scalpel — replace one exact occurrence of a
|
|
4
|
+
string — and ``insert_in_file`` adds a block before/after an exact anchor;
|
|
5
|
+
``write_file`` (filesystem) is the sledgehammer. Multi-file editing is
|
|
6
|
+
several edit/write calls in one turn; each passes the same permission gate
|
|
7
|
+
independently.
|
|
8
|
+
|
|
9
|
+
Matching happens on ``\\n``-normalized text (see :mod:`seedcode.tools.textio`)
|
|
10
|
+
and the original encoding/line endings are preserved on write, so editing a
|
|
11
|
+
CRLF or latin-1 file never silently converts it.
|
|
12
|
+
|
|
13
|
+
There is deliberately NO unified-diff applier here: model-generated diffs
|
|
14
|
+
malform often (line-number drift), which converts into retry loops that burn
|
|
15
|
+
the agent's step budget, while exact-match editing gives precise failure
|
|
16
|
+
messages the model can act on. edit_file + insert_in_file + write_file cover
|
|
17
|
+
every edit shape the loop needs.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import TYPE_CHECKING, Any
|
|
23
|
+
|
|
24
|
+
from .base import ToolResult, register
|
|
25
|
+
from .textio import read_text_file, write_text_file
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from .permissions import PermissionManager
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@register(
|
|
32
|
+
"edit_file",
|
|
33
|
+
"Replace an exact text snippet in a file (must match exactly once).",
|
|
34
|
+
{
|
|
35
|
+
"path": "file path",
|
|
36
|
+
"old_text": "exact text currently in the file",
|
|
37
|
+
"new_text": "replacement text",
|
|
38
|
+
},
|
|
39
|
+
mutates=True,
|
|
40
|
+
)
|
|
41
|
+
def _edit_file(perm: "PermissionManager", args: dict[str, Any]) -> ToolResult:
|
|
42
|
+
path = perm.resolve(args["path"])
|
|
43
|
+
perm.check_write(path)
|
|
44
|
+
if not path.is_file():
|
|
45
|
+
return ToolResult(False, f"File not found: {path}")
|
|
46
|
+
|
|
47
|
+
old = str(args["old_text"]).replace("\r\n", "\n")
|
|
48
|
+
new = str(args["new_text"]).replace("\r\n", "\n")
|
|
49
|
+
if not old:
|
|
50
|
+
return ToolResult(False, "old_text is empty — use write_file to create content.")
|
|
51
|
+
if old == new:
|
|
52
|
+
return ToolResult(False, "old_text and new_text are identical; nothing to do.")
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
tf = read_text_file(path)
|
|
56
|
+
except OSError as exc:
|
|
57
|
+
return ToolResult(False, f"Could not read {path}: {exc}")
|
|
58
|
+
|
|
59
|
+
count = tf.text.count(old)
|
|
60
|
+
if count == 0:
|
|
61
|
+
return ToolResult(
|
|
62
|
+
False,
|
|
63
|
+
f"old_text was not found in {path}. Read the file again and copy the "
|
|
64
|
+
"text exactly (whitespace matters).",
|
|
65
|
+
)
|
|
66
|
+
if count > 1:
|
|
67
|
+
return ToolResult(
|
|
68
|
+
False,
|
|
69
|
+
f"old_text appears {count} times in {path}. Include more surrounding "
|
|
70
|
+
"lines so it matches exactly once.",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
tf.text = tf.text.replace(old, new, 1)
|
|
74
|
+
expected = tf.text
|
|
75
|
+
try:
|
|
76
|
+
write_text_file(path, tf)
|
|
77
|
+
except OSError as exc:
|
|
78
|
+
return ToolResult(False, f"Could not write {path}: {exc}")
|
|
79
|
+
|
|
80
|
+
# VERIFICATION: the file must read back as exactly what we wrote
|
|
81
|
+
# (comparing full text — substring checks false-positive when new_text
|
|
82
|
+
# contains old_text, e.g. "b = 2" -> "b = 20").
|
|
83
|
+
try:
|
|
84
|
+
updated = read_text_file(path).text
|
|
85
|
+
if updated != expected:
|
|
86
|
+
return ToolResult(
|
|
87
|
+
False,
|
|
88
|
+
f"Edit operation completed but verification failed: {path} does "
|
|
89
|
+
"not match the edited content. The file may have been modified "
|
|
90
|
+
"by another process."
|
|
91
|
+
)
|
|
92
|
+
except OSError as exc:
|
|
93
|
+
return ToolResult(
|
|
94
|
+
False,
|
|
95
|
+
f"Edit completed but verification failed: could not read back {path}: {exc}"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return ToolResult(True, f"Edited {path} (1 replacement, verified).")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@register(
|
|
102
|
+
"insert_in_file",
|
|
103
|
+
"Insert a block of text before or after an exact anchor snippet "
|
|
104
|
+
"(anchor must match exactly once).",
|
|
105
|
+
{
|
|
106
|
+
"path": "file path",
|
|
107
|
+
"anchor": "exact existing text to anchor on",
|
|
108
|
+
"position": "'before' or 'after'",
|
|
109
|
+
"text": "block to insert",
|
|
110
|
+
},
|
|
111
|
+
mutates=True,
|
|
112
|
+
)
|
|
113
|
+
def _insert_in_file(perm: "PermissionManager", args: dict[str, Any]) -> ToolResult:
|
|
114
|
+
path = perm.resolve(args["path"])
|
|
115
|
+
perm.check_write(path)
|
|
116
|
+
if not path.is_file():
|
|
117
|
+
return ToolResult(False, f"File not found: {path}")
|
|
118
|
+
|
|
119
|
+
anchor = str(args["anchor"]).replace("\r\n", "\n")
|
|
120
|
+
block = str(args["text"]).replace("\r\n", "\n")
|
|
121
|
+
position = str(args["position"]).strip().lower()
|
|
122
|
+
if position not in ("before", "after"):
|
|
123
|
+
return ToolResult(False, "position must be 'before' or 'after'.")
|
|
124
|
+
if not anchor:
|
|
125
|
+
return ToolResult(False, "anchor is empty — copy an exact snippet from the file.")
|
|
126
|
+
if not block:
|
|
127
|
+
return ToolResult(False, "text is empty; nothing to insert.")
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
tf = read_text_file(path)
|
|
131
|
+
except OSError as exc:
|
|
132
|
+
return ToolResult(False, f"Could not read {path}: {exc}")
|
|
133
|
+
|
|
134
|
+
count = tf.text.count(anchor)
|
|
135
|
+
if count == 0:
|
|
136
|
+
return ToolResult(
|
|
137
|
+
False,
|
|
138
|
+
f"anchor was not found in {path}. Read the file again and copy the "
|
|
139
|
+
"text exactly (whitespace matters).",
|
|
140
|
+
)
|
|
141
|
+
if count > 1:
|
|
142
|
+
return ToolResult(
|
|
143
|
+
False,
|
|
144
|
+
f"anchor appears {count} times in {path}. Include more surrounding "
|
|
145
|
+
"lines so it matches exactly once.",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
replacement = block + anchor if position == "before" else anchor + block
|
|
149
|
+
tf.text = tf.text.replace(anchor, replacement, 1)
|
|
150
|
+
try:
|
|
151
|
+
write_text_file(path, tf)
|
|
152
|
+
except OSError as exc:
|
|
153
|
+
return ToolResult(False, f"Could not write {path}: {exc}")
|
|
154
|
+
|
|
155
|
+
# VERIFICATION: Read back to confirm the block landed where intended
|
|
156
|
+
try:
|
|
157
|
+
updated = read_text_file(path).text
|
|
158
|
+
if replacement not in updated:
|
|
159
|
+
return ToolResult(
|
|
160
|
+
False,
|
|
161
|
+
f"Insert completed but verification failed: the inserted block was "
|
|
162
|
+
f"not found next to the anchor in {path}.",
|
|
163
|
+
)
|
|
164
|
+
except OSError as exc:
|
|
165
|
+
return ToolResult(
|
|
166
|
+
False,
|
|
167
|
+
f"Insert completed but verification failed: could not read back {path}: {exc}"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
return ToolResult(True, f"Inserted {len(block)} chars {position} the anchor in {path} (verified).")
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Unified permission system for Seed Code.
|
|
2
|
+
|
|
3
|
+
A single hierarchical :class:`PermissionLevel` is the one source of truth for
|
|
4
|
+
what the session may do, checked before every privileged operation:
|
|
5
|
+
|
|
6
|
+
* ``read_only`` — inspect only: no writes, no commands, no git mutations,
|
|
7
|
+
no desktop control.
|
|
8
|
+
* ``workspace`` — read anywhere, mutate only inside the workspace directory
|
|
9
|
+
(the directory Seed Code was started in); still no desktop.
|
|
10
|
+
* ``desktop`` — everything ``workspace`` allows, plus control of the local
|
|
11
|
+
computer through the Computer Engine (mouse, keyboard,
|
|
12
|
+
windows, apps, browser, non-sensitive registry reads).
|
|
13
|
+
* ``full_system`` — no path restriction on mutations and sensitive desktop
|
|
14
|
+
actions (registry writes, secrets, system power, deletes,
|
|
15
|
+
purchases) become available (still confirmed per action).
|
|
16
|
+
|
|
17
|
+
Levels are ordered (``READ_ONLY < WORKSPACE < DESKTOP < FULL_SYSTEM``) so a
|
|
18
|
+
capability check is a single comparison: ``level >= required``. Desktop
|
|
19
|
+
automation is a *capability of a level*, not a separate mode — the old
|
|
20
|
+
``desktop_mode`` flag is gone.
|
|
21
|
+
|
|
22
|
+
On top of the levels, *dangerous* actions (shell commands, file deletes, git
|
|
23
|
+
mutations, writes outside the workspace, and every sensitive desktop action)
|
|
24
|
+
ask the user first — the Y/A/N prompt is [Y] once / [A] always for this
|
|
25
|
+
session / [N] cancel. "Always"/"Deny" answers are remembered per category for
|
|
26
|
+
the session only, never persisted; sensitive categories never remember.
|
|
27
|
+
|
|
28
|
+
The manager is the single gate: tools never check paths or levels themselves,
|
|
29
|
+
they ask :meth:`PermissionManager.require` / :meth:`check_read` /
|
|
30
|
+
:meth:`check_write` / :meth:`check_execute` / :meth:`confirm_action` and let
|
|
31
|
+
the raised error flow back to the model.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from enum import Enum, IntEnum
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import TYPE_CHECKING, Callable
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from ..computer.permissions import DesktopSession
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PermissionError_(Exception):
|
|
46
|
+
"""An action was blocked by the current permission level.
|
|
47
|
+
|
|
48
|
+
Named with a trailing underscore to avoid shadowing the builtin
|
|
49
|
+
``PermissionError`` (raised by the OS for filesystem denials).
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class PermissionLevel(IntEnum):
|
|
54
|
+
"""Hierarchical capability level; higher includes everything lower."""
|
|
55
|
+
|
|
56
|
+
READ_ONLY = 0
|
|
57
|
+
WORKSPACE = 1
|
|
58
|
+
DESKTOP = 2
|
|
59
|
+
FULL_SYSTEM = 3
|
|
60
|
+
# Legacy alias: pre-vNext configs used "full_access" for the top level.
|
|
61
|
+
FULL_ACCESS = 3
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def parse(cls, value: "str | PermissionLevel") -> "PermissionLevel":
|
|
65
|
+
if isinstance(value, PermissionLevel):
|
|
66
|
+
return value
|
|
67
|
+
raw = (str(value) or "").strip().lower().replace("-", "_").replace(" ", "_")
|
|
68
|
+
aliases = {
|
|
69
|
+
"read": cls.READ_ONLY,
|
|
70
|
+
"readonly": cls.READ_ONLY,
|
|
71
|
+
"read_only": cls.READ_ONLY,
|
|
72
|
+
"ws": cls.WORKSPACE,
|
|
73
|
+
"workspace": cls.WORKSPACE,
|
|
74
|
+
"desktop": cls.DESKTOP,
|
|
75
|
+
"computer": cls.DESKTOP,
|
|
76
|
+
"full": cls.FULL_SYSTEM,
|
|
77
|
+
"full_access": cls.FULL_SYSTEM,
|
|
78
|
+
"fullaccess": cls.FULL_SYSTEM,
|
|
79
|
+
"full_system": cls.FULL_SYSTEM,
|
|
80
|
+
"fullsystem": cls.FULL_SYSTEM,
|
|
81
|
+
"system": cls.FULL_SYSTEM,
|
|
82
|
+
}
|
|
83
|
+
level = aliases.get(raw)
|
|
84
|
+
if level is None:
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"Unknown permission level '{value}'. "
|
|
87
|
+
"Choose read_only, workspace, desktop, or full_system."
|
|
88
|
+
)
|
|
89
|
+
return level
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def value_str(self) -> str:
|
|
93
|
+
"""Canonical serialised name for this level."""
|
|
94
|
+
return {
|
|
95
|
+
PermissionLevel.READ_ONLY: "read_only",
|
|
96
|
+
PermissionLevel.WORKSPACE: "workspace",
|
|
97
|
+
PermissionLevel.DESKTOP: "desktop",
|
|
98
|
+
PermissionLevel.FULL_SYSTEM: "full_system",
|
|
99
|
+
}[PermissionLevel(int(self))]
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def label(self) -> str:
|
|
103
|
+
return {
|
|
104
|
+
PermissionLevel.READ_ONLY: "Read Only",
|
|
105
|
+
PermissionLevel.WORKSPACE: "Workspace",
|
|
106
|
+
PermissionLevel.DESKTOP: "Desktop",
|
|
107
|
+
PermissionLevel.FULL_SYSTEM: "Full System",
|
|
108
|
+
}[PermissionLevel(int(self))]
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def allows_desktop(self) -> bool:
|
|
112
|
+
"""Whether this level may drive the Computer Engine at all."""
|
|
113
|
+
return int(self) >= int(PermissionLevel.DESKTOP)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# Backward-compatible name: older modules import ``PermissionMode`` and
|
|
117
|
+
# reference ``PermissionMode.FULL_ACCESS``. Both resolve through the unified
|
|
118
|
+
# enum now, so a single class serves every caller.
|
|
119
|
+
PermissionMode = PermissionLevel
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class ActionGrant(str, Enum):
|
|
123
|
+
"""The user's answer to a dangerous-action prompt."""
|
|
124
|
+
|
|
125
|
+
ONCE = "once"
|
|
126
|
+
ALWAYS = "always"
|
|
127
|
+
DENY = "deny"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# Dangerous-action categories. Grants are remembered per category for the
|
|
131
|
+
# session; the descriptions shown to the user always include the concrete
|
|
132
|
+
# action (the exact command / path / git args).
|
|
133
|
+
CATEGORY_SHELL = "shell" # run a terminal command
|
|
134
|
+
CATEGORY_GIT_MUTATE = "git_mutate" # commit / merge / reset / ...
|
|
135
|
+
CATEGORY_GIT_REMOTE = "git_remote" # push / pull / fetch
|
|
136
|
+
CATEGORY_DELETE = "delete" # delete a file
|
|
137
|
+
CATEGORY_OUTSIDE_WRITE = "outside_write" # write outside the workspace
|
|
138
|
+
|
|
139
|
+
ACTION_LABELS = {
|
|
140
|
+
CATEGORY_SHELL: "Run a shell command",
|
|
141
|
+
CATEGORY_GIT_MUTATE: "Change git state",
|
|
142
|
+
CATEGORY_GIT_REMOTE: "Talk to a git remote",
|
|
143
|
+
CATEGORY_DELETE: "Delete a file",
|
|
144
|
+
CATEGORY_OUTSIDE_WRITE: "Write outside the workspace",
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
# (category, description) -> the user's grant.
|
|
148
|
+
ConfirmAction = Callable[[str, str], "ActionGrant"]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass
|
|
152
|
+
class ActionGate:
|
|
153
|
+
"""Session-scoped Y/A/N confirmation for dangerous core-tool actions.
|
|
154
|
+
|
|
155
|
+
The app layer supplies ``confirm`` (wired to the UI prompt); "Always" and
|
|
156
|
+
"Deny" answers stick for the rest of the session, "Once" asks again next
|
|
157
|
+
time. Nothing is ever persisted.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
confirm: ConfirmAction
|
|
161
|
+
grants: dict[str, "ActionGrant"] = field(default_factory=dict)
|
|
162
|
+
|
|
163
|
+
def check(self, category: str, description: str) -> None:
|
|
164
|
+
granted = self.grants.get(category)
|
|
165
|
+
if granted is ActionGrant.ALWAYS:
|
|
166
|
+
return
|
|
167
|
+
if granted is ActionGrant.DENY:
|
|
168
|
+
label = ACTION_LABELS.get(category, category)
|
|
169
|
+
raise PermissionError_(
|
|
170
|
+
f"Blocked: the user denied '{label}' for this session."
|
|
171
|
+
)
|
|
172
|
+
choice = self.confirm(category, description)
|
|
173
|
+
if choice in (ActionGrant.ALWAYS, ActionGrant.DENY):
|
|
174
|
+
self.grants[category] = choice
|
|
175
|
+
if choice is ActionGrant.DENY:
|
|
176
|
+
raise PermissionError_(
|
|
177
|
+
f"Blocked: the user denied this action ({description})."
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def reset(self) -> None:
|
|
181
|
+
"""Forget all session grants."""
|
|
182
|
+
self.grants.clear()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class PermissionManager:
|
|
186
|
+
"""Gatekeeper consulted by every tool before acting."""
|
|
187
|
+
|
|
188
|
+
def __init__(
|
|
189
|
+
self,
|
|
190
|
+
workspace: Path | None = None,
|
|
191
|
+
mode: "PermissionLevel | str" = PermissionLevel.WORKSPACE,
|
|
192
|
+
level: "PermissionLevel | str | None" = None,
|
|
193
|
+
) -> None:
|
|
194
|
+
self.workspace = (workspace or Path.cwd()).resolve()
|
|
195
|
+
# ``level`` is the canonical field; ``mode`` is accepted as the legacy
|
|
196
|
+
# keyword and both stay in sync through the ``mode`` property below.
|
|
197
|
+
self.level = PermissionLevel.parse(level if level is not None else mode)
|
|
198
|
+
# Desktop Control gate (the Computer Engine); attached by the app layer
|
|
199
|
+
# when the level allows desktop. None means desktop tools refuse.
|
|
200
|
+
self.desktop: "DesktopSession | None" = None
|
|
201
|
+
# Dangerous-action confirmation gate; attached by the app layer. None
|
|
202
|
+
# means no prompting (headless/tests) — the level checks alone decide.
|
|
203
|
+
self.gate: ActionGate | None = None
|
|
204
|
+
# Live command output sink (attached by the app layer): each line a
|
|
205
|
+
# running terminal command prints is echoed here as it arrives.
|
|
206
|
+
self.on_output: Callable[[str], None] | None = None
|
|
207
|
+
|
|
208
|
+
# --- level (with legacy ``mode`` alias) ----------------------------------
|
|
209
|
+
@property
|
|
210
|
+
def mode(self) -> PermissionLevel:
|
|
211
|
+
"""Legacy alias for :attr:`level` (same object)."""
|
|
212
|
+
return self.level
|
|
213
|
+
|
|
214
|
+
@mode.setter
|
|
215
|
+
def mode(self, value: "PermissionLevel | str") -> None:
|
|
216
|
+
self.level = PermissionLevel.parse(value)
|
|
217
|
+
|
|
218
|
+
def require(self, required: "PermissionLevel | str", description: str = "") -> None:
|
|
219
|
+
"""Raise unless the current level meets ``required``.
|
|
220
|
+
|
|
221
|
+
The single capability gate the Computer Engine and every privileged
|
|
222
|
+
tool consults. Elevation prompting is handled by the app layer (which
|
|
223
|
+
may raise the level in response); this method only enforces the
|
|
224
|
+
current level.
|
|
225
|
+
"""
|
|
226
|
+
needed = PermissionLevel.parse(required)
|
|
227
|
+
if self.level >= needed:
|
|
228
|
+
return
|
|
229
|
+
what = f" ({description})" if description else ""
|
|
230
|
+
raise PermissionError_(
|
|
231
|
+
f"Blocked: this needs {needed.label} permission{what}, but the "
|
|
232
|
+
f"current level is {self.level.label}. The user can raise it with "
|
|
233
|
+
"/permission."
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# --- path helpers --------------------------------------------------------
|
|
237
|
+
def resolve(self, raw: str) -> Path:
|
|
238
|
+
"""Resolve a tool-supplied path (relative paths anchor at the workspace)."""
|
|
239
|
+
path = Path(str(raw)).expanduser()
|
|
240
|
+
if not path.is_absolute():
|
|
241
|
+
path = self.workspace / path
|
|
242
|
+
return path.resolve()
|
|
243
|
+
|
|
244
|
+
def in_workspace(self, path: Path) -> bool:
|
|
245
|
+
try:
|
|
246
|
+
path.resolve().relative_to(self.workspace)
|
|
247
|
+
return True
|
|
248
|
+
except ValueError:
|
|
249
|
+
return False
|
|
250
|
+
|
|
251
|
+
# --- checks (raise PermissionError_ when blocked) -------------------------
|
|
252
|
+
def check_read(self, path: Path) -> None:
|
|
253
|
+
"""Reading is allowed at every level."""
|
|
254
|
+
|
|
255
|
+
def check_write(self, path: Path) -> None:
|
|
256
|
+
if self.level < PermissionLevel.WORKSPACE:
|
|
257
|
+
raise PermissionError_(
|
|
258
|
+
"Blocked: permission level is Read Only — no file changes allowed. "
|
|
259
|
+
"The user can raise it with /permission."
|
|
260
|
+
)
|
|
261
|
+
if self.level < PermissionLevel.FULL_SYSTEM and not self.in_workspace(path):
|
|
262
|
+
raise PermissionError_(
|
|
263
|
+
f"Blocked: '{path}' is outside the workspace ({self.workspace}). "
|
|
264
|
+
"Only Full System allows changes outside it — "
|
|
265
|
+
"the user can raise it with /permission full_system."
|
|
266
|
+
)
|
|
267
|
+
if self.level >= PermissionLevel.FULL_SYSTEM and not self.in_workspace(path):
|
|
268
|
+
# Full System allows it, but writing outside the workspace is
|
|
269
|
+
# dangerous enough to confirm with the user first.
|
|
270
|
+
self.confirm_action(CATEGORY_OUTSIDE_WRITE, str(path))
|
|
271
|
+
|
|
272
|
+
def check_execute(self, description: str = "") -> None:
|
|
273
|
+
"""Commands and git mutations need more than Read Only."""
|
|
274
|
+
if self.level < PermissionLevel.WORKSPACE:
|
|
275
|
+
what = f" ({description})" if description else ""
|
|
276
|
+
raise PermissionError_(
|
|
277
|
+
f"Blocked: permission level is Read Only — cannot execute{what}. "
|
|
278
|
+
"The user can raise it with /permission."
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def confirm_action(self, category: str, description: str) -> None:
|
|
282
|
+
"""Ask the user to confirm a dangerous action (no-op without a gate).
|
|
283
|
+
|
|
284
|
+
Raises :class:`PermissionError_` when the user denies; the message
|
|
285
|
+
flows back to the model like any other permission refusal.
|
|
286
|
+
"""
|
|
287
|
+
if self.gate is not None:
|
|
288
|
+
self.gate.check(category, description)
|
seedcode/tools/search.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Search tools: filename glob and text search across the workspace.
|
|
2
|
+
|
|
3
|
+
Pure-Python (pathlib + a linear scan) so search works identically on Windows,
|
|
4
|
+
Linux, and macOS with no external binaries.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import fnmatch
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from .base import ToolResult, register
|
|
15
|
+
from .filesystem import _INDEX_SKIP
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from .permissions import PermissionManager
|
|
19
|
+
|
|
20
|
+
_MAX_MATCHES = 100
|
|
21
|
+
_MAX_FILE_BYTES = 1024 * 1024 # skip anything bigger — likely binary/asset
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _iter_files(root: Path):
|
|
25
|
+
"""Workspace files, skipping caches/VCS dirs and hidden entries."""
|
|
26
|
+
stack = [root]
|
|
27
|
+
while stack:
|
|
28
|
+
current = stack.pop()
|
|
29
|
+
try:
|
|
30
|
+
entries = sorted(current.iterdir(), key=lambda p: p.name.lower())
|
|
31
|
+
except OSError:
|
|
32
|
+
continue
|
|
33
|
+
for entry in entries:
|
|
34
|
+
if entry.name in _INDEX_SKIP or entry.name.startswith("."):
|
|
35
|
+
continue
|
|
36
|
+
if entry.is_dir():
|
|
37
|
+
stack.append(entry)
|
|
38
|
+
elif entry.is_file():
|
|
39
|
+
yield entry
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _glob_to_regex(pattern: str) -> "re.Pattern[str]":
|
|
43
|
+
"""Compile a path glob ('src/**/*.py') to a regex over posix paths.
|
|
44
|
+
|
|
45
|
+
``**/`` matches any directory depth (including none), ``**`` any run of
|
|
46
|
+
characters, ``*`` within one segment, ``?`` one character.
|
|
47
|
+
(``PurePath.full_match`` needs Python 3.13; this supports 3.10+.)
|
|
48
|
+
"""
|
|
49
|
+
out: list[str] = []
|
|
50
|
+
i = 0
|
|
51
|
+
while i < len(pattern):
|
|
52
|
+
ch = pattern[i]
|
|
53
|
+
if pattern.startswith("**/", i):
|
|
54
|
+
out.append(r"(?:[^/]+/)*")
|
|
55
|
+
i += 3
|
|
56
|
+
elif pattern.startswith("**", i):
|
|
57
|
+
out.append(r".*")
|
|
58
|
+
i += 2
|
|
59
|
+
elif ch == "*":
|
|
60
|
+
out.append(r"[^/]*")
|
|
61
|
+
i += 1
|
|
62
|
+
elif ch == "?":
|
|
63
|
+
out.append(r"[^/]")
|
|
64
|
+
i += 1
|
|
65
|
+
else:
|
|
66
|
+
out.append(re.escape(ch))
|
|
67
|
+
i += 1
|
|
68
|
+
return re.compile("".join(out) + r"\Z")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@register(
|
|
72
|
+
"find_files",
|
|
73
|
+
"Find files by glob: a name pattern ('*.py') or a path pattern "
|
|
74
|
+
"('src/**/*.py', relative to the workspace).",
|
|
75
|
+
{"pattern": "glob matched against file names, or paths when it contains '/'"},
|
|
76
|
+
mutates=False,
|
|
77
|
+
)
|
|
78
|
+
def _find_files(perm: "PermissionManager", args: dict[str, Any]) -> ToolResult:
|
|
79
|
+
pattern = str(args["pattern"]).replace("\\", "/")
|
|
80
|
+
path_mode = "/" in pattern
|
|
81
|
+
path_regex = _glob_to_regex(pattern) if path_mode else None
|
|
82
|
+
|
|
83
|
+
matches = []
|
|
84
|
+
for path in _iter_files(perm.workspace):
|
|
85
|
+
if path_mode:
|
|
86
|
+
rel = path.relative_to(perm.workspace).as_posix()
|
|
87
|
+
matched = path_regex.match(rel) is not None
|
|
88
|
+
else:
|
|
89
|
+
matched = fnmatch.fnmatch(path.name, pattern)
|
|
90
|
+
if matched:
|
|
91
|
+
matches.append(str(path.relative_to(perm.workspace)))
|
|
92
|
+
if len(matches) >= _MAX_MATCHES:
|
|
93
|
+
matches.append(f"... (capped at {_MAX_MATCHES})")
|
|
94
|
+
break
|
|
95
|
+
if not matches:
|
|
96
|
+
return ToolResult(True, f"No files matching '{pattern}'.")
|
|
97
|
+
return ToolResult(True, "\n".join(matches))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@register(
|
|
101
|
+
"search_text",
|
|
102
|
+
"Search file contents for a regex; returns file:line matches.",
|
|
103
|
+
{
|
|
104
|
+
"pattern": "regular expression",
|
|
105
|
+
"glob": "(optional) only search files whose name matches this glob",
|
|
106
|
+
},
|
|
107
|
+
mutates=False,
|
|
108
|
+
)
|
|
109
|
+
def _search_text(perm: "PermissionManager", args: dict[str, Any]) -> ToolResult:
|
|
110
|
+
try:
|
|
111
|
+
regex = re.compile(str(args["pattern"]))
|
|
112
|
+
except re.error as exc:
|
|
113
|
+
return ToolResult(False, f"Invalid regex: {exc}")
|
|
114
|
+
name_glob = str(args.get("glob") or "*")
|
|
115
|
+
|
|
116
|
+
hits: list[str] = []
|
|
117
|
+
for path in _iter_files(perm.workspace):
|
|
118
|
+
if not fnmatch.fnmatch(path.name, name_glob):
|
|
119
|
+
continue
|
|
120
|
+
try:
|
|
121
|
+
if path.stat().st_size > _MAX_FILE_BYTES:
|
|
122
|
+
continue
|
|
123
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
124
|
+
except OSError:
|
|
125
|
+
continue
|
|
126
|
+
if "\x00" in text[:1024]: # binary sniff
|
|
127
|
+
continue
|
|
128
|
+
rel = path.relative_to(perm.workspace)
|
|
129
|
+
for lineno, line in enumerate(text.splitlines(), start=1):
|
|
130
|
+
if regex.search(line):
|
|
131
|
+
hits.append(f"{rel}:{lineno}: {line.strip()[:200]}")
|
|
132
|
+
if len(hits) >= _MAX_MATCHES:
|
|
133
|
+
hits.append(f"... (capped at {_MAX_MATCHES})")
|
|
134
|
+
return ToolResult(True, "\n".join(hits))
|
|
135
|
+
if not hits:
|
|
136
|
+
return ToolResult(True, f"No matches for '{args['pattern']}'.")
|
|
137
|
+
return ToolResult(True, "\n".join(hits))
|