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.
Files changed (114) hide show
  1. seedcode/__init__.py +14 -0
  2. seedcode/__main__.py +12 -0
  3. seedcode/app.py +508 -0
  4. seedcode/apps/__init__.py +32 -0
  5. seedcode/apps/discovery.py +241 -0
  6. seedcode/apps/installer.py +164 -0
  7. seedcode/apps/launcher.py +156 -0
  8. seedcode/apps/verifier.py +119 -0
  9. seedcode/assets/logo.txt +15 -0
  10. seedcode/cli.py +95 -0
  11. seedcode/commands/__init__.py +81 -0
  12. seedcode/commands/about.py +34 -0
  13. seedcode/commands/agent.py +94 -0
  14. seedcode/commands/assist.py +201 -0
  15. seedcode/commands/clear.py +20 -0
  16. seedcode/commands/desktop.py +104 -0
  17. seedcode/commands/doctor.py +152 -0
  18. seedcode/commands/help.py +61 -0
  19. seedcode/commands/history.py +365 -0
  20. seedcode/commands/palette.py +100 -0
  21. seedcode/commands/provider.py +451 -0
  22. seedcode/commands/theme.py +76 -0
  23. seedcode/computer/__init__.py +98 -0
  24. seedcode/computer/browser.py +276 -0
  25. seedcode/computer/browser_cdp.py +567 -0
  26. seedcode/computer/browser_engine.py +546 -0
  27. seedcode/computer/browser_extract.py +301 -0
  28. seedcode/computer/browser_popups.py +329 -0
  29. seedcode/computer/browser_selenium.py +209 -0
  30. seedcode/computer/browser_skills.py +245 -0
  31. seedcode/computer/catalog.py +200 -0
  32. seedcode/computer/controller.py +324 -0
  33. seedcode/computer/dispatcher.py +272 -0
  34. seedcode/computer/dpi.py +185 -0
  35. seedcode/computer/engine.py +105 -0
  36. seedcode/computer/keyboard.py +101 -0
  37. seedcode/computer/logbook.py +104 -0
  38. seedcode/computer/mouse.py +48 -0
  39. seedcode/computer/ocr.py +213 -0
  40. seedcode/computer/operator_skills.py +577 -0
  41. seedcode/computer/permissions.py +203 -0
  42. seedcode/computer/recovery.py +115 -0
  43. seedcode/computer/registry.py +107 -0
  44. seedcode/computer/resolver.py +434 -0
  45. seedcode/computer/screen.py +130 -0
  46. seedcode/computer/screen_state.py +412 -0
  47. seedcode/computer/selfguard.py +197 -0
  48. seedcode/computer/semantic.py +100 -0
  49. seedcode/computer/skills.py +139 -0
  50. seedcode/computer/state.py +199 -0
  51. seedcode/computer/verifier.py +177 -0
  52. seedcode/computer/vision.py +327 -0
  53. seedcode/computer/windows.py +217 -0
  54. seedcode/config/__init__.py +8 -0
  55. seedcode/config/defaults.py +22 -0
  56. seedcode/config/manager.py +62 -0
  57. seedcode/core/__init__.py +31 -0
  58. seedcode/core/agent.py +534 -0
  59. seedcode/core/chat.py +128 -0
  60. seedcode/core/client.py +9 -0
  61. seedcode/core/errors.py +199 -0
  62. seedcode/core/identity.py +66 -0
  63. seedcode/core/identity_store.py +119 -0
  64. seedcode/core/lifecycle.py +240 -0
  65. seedcode/core/limits.py +35 -0
  66. seedcode/core/models.py +347 -0
  67. seedcode/core/project.py +96 -0
  68. seedcode/core/providers/__init__.py +58 -0
  69. seedcode/core/providers/aerolink.py +324 -0
  70. seedcode/core/providers/base.py +230 -0
  71. seedcode/core/providers/freemodel.py +931 -0
  72. seedcode/core/providers/ollama.py +262 -0
  73. seedcode/core/providers/openrouter.py +393 -0
  74. seedcode/core/streaming.py +21 -0
  75. seedcode/memory/__init__.py +8 -0
  76. seedcode/memory/manager.py +47 -0
  77. seedcode/memory/storage.py +38 -0
  78. seedcode/memory/store.py +257 -0
  79. seedcode/tools/__init__.py +35 -0
  80. seedcode/tools/base.py +179 -0
  81. seedcode/tools/desktop.py +371 -0
  82. seedcode/tools/filesystem.py +309 -0
  83. seedcode/tools/git.py +72 -0
  84. seedcode/tools/patch.py +170 -0
  85. seedcode/tools/permissions.py +288 -0
  86. seedcode/tools/search.py +137 -0
  87. seedcode/tools/terminal.py +200 -0
  88. seedcode/tools/textio.py +59 -0
  89. seedcode/ui/__init__.py +164 -0
  90. seedcode/ui/badges.py +64 -0
  91. seedcode/ui/banner.py +78 -0
  92. seedcode/ui/dashboard.py +197 -0
  93. seedcode/ui/dialog.py +62 -0
  94. seedcode/ui/fuzzy.py +128 -0
  95. seedcode/ui/layout.py +54 -0
  96. seedcode/ui/menu.py +61 -0
  97. seedcode/ui/palette.py +40 -0
  98. seedcode/ui/progress.py +41 -0
  99. seedcode/ui/prompts.py +16 -0
  100. seedcode/ui/renderer.py +36 -0
  101. seedcode/ui/searchbox.py +70 -0
  102. seedcode/ui/selector.py +514 -0
  103. seedcode/ui/statusbar.py +38 -0
  104. seedcode/ui/textbox.py +61 -0
  105. seedcode/ui/theme.py +204 -0
  106. seedcode/ui/tree.py +91 -0
  107. seedcode/utils/__init__.py +22 -0
  108. seedcode/utils/helpers.py +97 -0
  109. seedcode/utils/logger.py +65 -0
  110. seedcode_cli-6.1.5.dist-info/METADATA +368 -0
  111. seedcode_cli-6.1.5.dist-info/RECORD +114 -0
  112. seedcode_cli-6.1.5.dist-info/WHEEL +4 -0
  113. seedcode_cli-6.1.5.dist-info/entry_points.txt +2 -0
  114. 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('"', '\\"') + '"'
@@ -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)
@@ -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))