rlmness-cli 0.2.0__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.
- rlmagent/__init__.py +1 -0
- rlmagent/__main__.py +8 -0
- rlmagent/acp/__init__.py +1 -0
- rlmagent/acp/runtime.py +71 -0
- rlmagent/acp/server.py +598 -0
- rlmagent/acp/updates.py +128 -0
- rlmagent/agentnet.py +237 -0
- rlmagent/agents/__init__.py +1 -0
- rlmagent/agents/approval.py +90 -0
- rlmagent/agents/bridge.py +109 -0
- rlmagent/agents/budget.py +212 -0
- rlmagent/agents/child_handles.py +46 -0
- rlmagent/agents/child_mailbox.py +30 -0
- rlmagent/agents/kernel_api.py +156 -0
- rlmagent/agents/output.py +34 -0
- rlmagent/agents/patch.py +107 -0
- rlmagent/agents/prompts.py +156 -0
- rlmagent/agents/trace.py +290 -0
- rlmagent/agents/tree.py +682 -0
- rlmagent/agents/wasm_api.py +105 -0
- rlmagent/agents/workspace.py +357 -0
- rlmagent/autopilot.py +447 -0
- rlmagent/backend.py +946 -0
- rlmagent/backend_context.py +74 -0
- rlmagent/cli/__init__.py +5 -0
- rlmagent/cli/config_cmd.py +177 -0
- rlmagent/cli/health.py +156 -0
- rlmagent/cli/main.py +1194 -0
- rlmagent/cli/update.py +188 -0
- rlmagent/clipboard.py +63 -0
- rlmagent/config/__init__.py +37 -0
- rlmagent/config/config.py +297 -0
- rlmagent/config/loader.py +119 -0
- rlmagent/config/prompts.py +124 -0
- rlmagent/config/secure_input.py +213 -0
- rlmagent/config/store.py +275 -0
- rlmagent/context/__init__.py +47 -0
- rlmagent/context/budget.py +557 -0
- rlmagent/contract.py +311 -0
- rlmagent/contract_run.py +232 -0
- rlmagent/conversation.py +2547 -0
- rlmagent/cron.py +77 -0
- rlmagent/directives.py +893 -0
- rlmagent/discovery.py +356 -0
- rlmagent/execution_log.py +66 -0
- rlmagent/factory.py +189 -0
- rlmagent/factory_graph.py +413 -0
- rlmagent/harness/__init__.py +63 -0
- rlmagent/harness/contracts/__init__.py +1 -0
- rlmagent/harness/contracts/stream.py +129 -0
- rlmagent/harness/contracts/tooling.py +127 -0
- rlmagent/harness/contracts/transcript/__init__.py +68 -0
- rlmagent/harness/contracts/transcript/_base.py +29 -0
- rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
- rlmagent/harness/contracts/transcript/entries.py +180 -0
- rlmagent/harness/contracts/transcript/segments.py +64 -0
- rlmagent/harness/contracts/values.py +6 -0
- rlmagent/harness/driver.py +397 -0
- rlmagent/harness/engine.py +489 -0
- rlmagent/harness/mending.py +153 -0
- rlmagent/harness/provider/__init__.py +1 -0
- rlmagent/harness/provider/base.py +27 -0
- rlmagent/harness/provider/wire.py +189 -0
- rlmagent/harness/py.typed +0 -0
- rlmagent/harness/session/__init__.py +5 -0
- rlmagent/harness/session/index.py +218 -0
- rlmagent/harness/session/lease.py +66 -0
- rlmagent/harness/session/records.py +118 -0
- rlmagent/harness/session/replay.py +165 -0
- rlmagent/harness/session/store.py +155 -0
- rlmagent/harness/session/summary.py +46 -0
- rlmagent/hints.py +47 -0
- rlmagent/history.py +92 -0
- rlmagent/hooks/__init__.py +1 -0
- rlmagent/hooks/hooks.py +1 -0
- rlmagent/instructions.py +157 -0
- rlmagent/job_runner.py +117 -0
- rlmagent/jobs.py +365 -0
- rlmagent/kept_agents.py +397 -0
- rlmagent/kernel/__init__.py +5 -0
- rlmagent/kernel/session.py +205 -0
- rlmagent/kernel/wasm.py +169 -0
- rlmagent/logs.py +208 -0
- rlmagent/managed_release.py +368 -0
- rlmagent/mcp.py +411 -0
- rlmagent/mcp_oauth.py +213 -0
- rlmagent/model/__init__.py +29 -0
- rlmagent/model/_claude/__init__.py +1 -0
- rlmagent/model/_claude/courier.py +117 -0
- rlmagent/model/_claude/emitter.py +378 -0
- rlmagent/model/_oai/__init__.py +1 -0
- rlmagent/model/_oai/helpers.py +256 -0
- rlmagent/model/_oai/normalize.py +263 -0
- rlmagent/model/_oai/parsers.py +317 -0
- rlmagent/model/_oai/payloads.py +226 -0
- rlmagent/model/_oai/transport.py +127 -0
- rlmagent/model/claude.py +552 -0
- rlmagent/model/copilot.py +142 -0
- rlmagent/model/correlation.py +21 -0
- rlmagent/model/deadline.py +19 -0
- rlmagent/model/discovery.py +181 -0
- rlmagent/model/external/__init__.py +11 -0
- rlmagent/model/external/acp.py +202 -0
- rlmagent/model/external/acp_install.py +109 -0
- rlmagent/model/external/cli.py +248 -0
- rlmagent/model/external/textcalls.py +57 -0
- rlmagent/model/failover.py +129 -0
- rlmagent/model/google.py +199 -0
- rlmagent/model/limits.py +46 -0
- rlmagent/model/oai_compatible.py +414 -0
- rlmagent/model/preferences.py +56 -0
- rlmagent/model/py.typed +0 -0
- rlmagent/model/registered_oauth.py +195 -0
- rlmagent/model/scripted.py +55 -0
- rlmagent/model/settings.py +271 -0
- rlmagent/model/subscription.py +398 -0
- rlmagent/model/token_bounds.py +15 -0
- rlmagent/model/transport/__init__.py +1 -0
- rlmagent/model/transport/backoff.py +87 -0
- rlmagent/model/transport/client.py +87 -0
- rlmagent/model/transport/faults.py +65 -0
- rlmagent/model/transport/responses_socket.py +223 -0
- rlmagent/model/vertex.py +93 -0
- rlmagent/network.py +123 -0
- rlmagent/packages.py +246 -0
- rlmagent/planning.py +84 -0
- rlmagent/playbook/__init__.py +1 -0
- rlmagent/playbook/apply.py +106 -0
- rlmagent/playbook/digest.py +63 -0
- rlmagent/playbook/improver.py +272 -0
- rlmagent/playbook/plan.py +99 -0
- rlmagent/playbook/store.py +67 -0
- rlmagent/plugins.py +327 -0
- rlmagent/policy.py +105 -0
- rlmagent/prompt_refs.py +57 -0
- rlmagent/prompts.py +331 -0
- rlmagent/prune.py +60 -0
- rlmagent/py.typed +0 -0
- rlmagent/reasoning.py +135 -0
- rlmagent/refinement/__init__.py +1 -0
- rlmagent/refinement/controller.py +448 -0
- rlmagent/refinement/engine.py +220 -0
- rlmagent/refinement/evidence.py +131 -0
- rlmagent/refinement/scoped.py +90 -0
- rlmagent/refinement/store.py +193 -0
- rlmagent/refresh.py +202 -0
- rlmagent/routing.py +52 -0
- rlmagent/runtime.py +575 -0
- rlmagent/safety.py +267 -0
- rlmagent/session_html.py +57 -0
- rlmagent/session_import.py +52 -0
- rlmagent/session_share.py +55 -0
- rlmagent/sessions.py +249 -0
- rlmagent/shell_sandbox.py +88 -0
- rlmagent/skillset.py +320 -0
- rlmagent/steering.py +148 -0
- rlmagent/stops.py +62 -0
- rlmagent/subscriptions.py +219 -0
- rlmagent/tasks.py +98 -0
- rlmagent/toolhost.py +310 -0
- rlmagent/tools/__init__.py +56 -0
- rlmagent/tools/_shared.py +210 -0
- rlmagent/tools/files.py +462 -0
- rlmagent/tools/kernel.py +93 -0
- rlmagent/tools/patch_tool.py +68 -0
- rlmagent/tools/search.py +188 -0
- rlmagent/trust.py +383 -0
- rlmagent/tui/__init__.py +1 -0
- rlmagent/tui/adapter.py +506 -0
- rlmagent/tui/app.py +711 -0
- rlmagent/tui/approval_screen.py +57 -0
- rlmagent/tui/choice_screen.py +47 -0
- rlmagent/tui/completions.py +116 -0
- rlmagent/tui/components.py +25 -0
- rlmagent/tui/composer.py +40 -0
- rlmagent/tui/factory_view.py +86 -0
- rlmagent/tui/inspect.py +193 -0
- rlmagent/tui/messages.py +215 -0
- rlmagent/tui/model_screen.py +307 -0
- rlmagent/tui/prompt.py +98 -0
- rlmagent/tui/question_screen.py +52 -0
- rlmagent/tui/replay.py +141 -0
- rlmagent/tui/rlm_agent.tcss +405 -0
- rlmagent/tui/search_box.py +91 -0
- rlmagent/tui/sidebar.py +182 -0
- rlmagent/tui/status.py +49 -0
- rlmagent/tui/steps.py +283 -0
- rlmagent/tui/suggest.py +778 -0
- rlmagent/tui/transcript.py +172 -0
- rlmagent/tui/trust_dialog.py +58 -0
- rlmagent/tui/worker_attach.py +141 -0
- rlmagent/ui/__init__.py +1 -0
- rlmagent/ui/render.py +211 -0
- rlmagent/undo.py +109 -0
- rlmagent/utils/__init__.py +1 -0
- rlmagent/websearch.py +75 -0
- rlmagent/worker.py +1275 -0
- rlmagent/worker_service.py +203 -0
- rlmness_cli-0.2.0.dist-info/METADATA +882 -0
- rlmness_cli-0.2.0.dist-info/RECORD +203 -0
- rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
- rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
- rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
rlmagent/trust.py
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""Project trust: whether a folder's own instructions and resources may load."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from collections.abc import Awaitable, Callable
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
Verdict = Literal["trusted", "untrusted"]
|
|
15
|
+
DefaultPolicy = Literal["ask", "always", "never"]
|
|
16
|
+
|
|
17
|
+
_STORE_NAME = "trust.json"
|
|
18
|
+
_STORE_VERSION = 1
|
|
19
|
+
_VERDICTS: frozenset[str] = frozenset({"trusted", "untrusted"})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TrustStoreError(Exception):
|
|
23
|
+
"""The saved-decision file is unreadable or not in the expected shape."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class StartupCancelled(Exception):
|
|
27
|
+
"""The user dismissed the trust question instead of answering it."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Choice(Enum):
|
|
31
|
+
"""Answers a frontend may give to a trust question."""
|
|
32
|
+
|
|
33
|
+
TRUST_FOLDER = "trust-folder"
|
|
34
|
+
TRUST_PARENT = "trust-parent"
|
|
35
|
+
TRUST_ONCE = "trust-once"
|
|
36
|
+
DISTRUST_FOLDER = "distrust-folder"
|
|
37
|
+
DISTRUST_ONCE = "distrust-once"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Paths
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def canonical_folder(cwd: str | os.PathLike[str]) -> Path:
|
|
46
|
+
"""Absolute, symlink-free form of an existing directory."""
|
|
47
|
+
folder = Path(cwd).expanduser().resolve(strict=True)
|
|
48
|
+
if not folder.is_dir():
|
|
49
|
+
raise NotADirectoryError(str(folder))
|
|
50
|
+
return folder
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _key(folder: Path) -> str:
|
|
54
|
+
# Case-insensitive filesystems compare paths case-insensitively.
|
|
55
|
+
return os.path.normcase(str(folder))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Detection (names and file types only; contents are never read)
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class ProjectInputs:
|
|
65
|
+
"""Protected inputs present in a folder, counted by kind."""
|
|
66
|
+
|
|
67
|
+
folder: Path
|
|
68
|
+
counts: dict[str, int] = field(default_factory=dict)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def empty(self) -> bool:
|
|
72
|
+
return not any(self.counts.values())
|
|
73
|
+
|
|
74
|
+
def describe(self) -> str:
|
|
75
|
+
"""Short human summary, e.g. ``2 instruction files, 1 skill``."""
|
|
76
|
+
parts = [
|
|
77
|
+
f"{n} {kind if n == 1 else kind + 's'}"
|
|
78
|
+
for kind, n in self.counts.items() if n
|
|
79
|
+
]
|
|
80
|
+
return ", ".join(parts) or "nothing"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _entries(directory: Path) -> list[Path]:
|
|
84
|
+
try:
|
|
85
|
+
return list(directory.iterdir())
|
|
86
|
+
except OSError:
|
|
87
|
+
return []
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def scan_project_inputs(folder: Path) -> ProjectInputs:
|
|
91
|
+
"""Count protected inputs by name and type alone."""
|
|
92
|
+
counts = {
|
|
93
|
+
"instruction file": sum(
|
|
94
|
+
1 for p in (folder / "AGENTS.md", folder / ".rlm-agent" / "AGENTS.md",
|
|
95
|
+
folder / ".agents" / "AGENTS.md")
|
|
96
|
+
if p.is_symlink() or p.exists()
|
|
97
|
+
),
|
|
98
|
+
"skill": sum(
|
|
99
|
+
1
|
|
100
|
+
for base in (folder / ".rlm-agent" / "skills", folder / ".agents" / "skills")
|
|
101
|
+
for entry in _entries(base)
|
|
102
|
+
if (entry / "SKILL.md").is_symlink() or (entry / "SKILL.md").exists()
|
|
103
|
+
),
|
|
104
|
+
"prompt template": sum(
|
|
105
|
+
1
|
|
106
|
+
for base in (folder / ".rlm-agent" / "prompts", folder / ".agents" / "prompts")
|
|
107
|
+
for entry in _entries(base)
|
|
108
|
+
if entry.suffix == ".md" and not entry.name.startswith(".")
|
|
109
|
+
),
|
|
110
|
+
"theme": sum(1 for e in _entries(folder / ".rlm-agent" / "themes") if e.suffix == ".json"),
|
|
111
|
+
"settings file": int((folder / ".rlm-agent" / "settings.toml").is_file()),
|
|
112
|
+
"plugin": sum(
|
|
113
|
+
1
|
|
114
|
+
for entry in _entries(folder / ".rlm-agent" / "extensions")
|
|
115
|
+
if not entry.name.startswith(("_", "."))
|
|
116
|
+
and (entry.suffix == ".py" or (entry / "extension.py").exists())
|
|
117
|
+
),
|
|
118
|
+
}
|
|
119
|
+
return ProjectInputs(folder=folder, counts=counts)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
# Saved decisions
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class TrustStore:
|
|
128
|
+
"""Saved per-folder decisions in ``<rlm-agent home>/trust.json``."""
|
|
129
|
+
|
|
130
|
+
def __init__(self, path: Path) -> None:
|
|
131
|
+
self.path = path
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def default(cls) -> TrustStore:
|
|
135
|
+
from rlmagent.discovery import default_paths
|
|
136
|
+
|
|
137
|
+
return cls(default_paths().home / _STORE_NAME)
|
|
138
|
+
|
|
139
|
+
def decisions(self) -> dict[Path, Verdict]:
|
|
140
|
+
if not self.path.exists():
|
|
141
|
+
return {}
|
|
142
|
+
try:
|
|
143
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
144
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
145
|
+
raise TrustStoreError(f"{self.path} is unreadable: {exc}") from exc
|
|
146
|
+
if not isinstance(data, dict) or set(data) != {"version", "decisions"}:
|
|
147
|
+
raise TrustStoreError(f"{self.path} does not have the expected fields")
|
|
148
|
+
if data["version"] != _STORE_VERSION:
|
|
149
|
+
raise TrustStoreError(f"{self.path} has unsupported version {data['version']!r}")
|
|
150
|
+
if not isinstance(data["decisions"], list):
|
|
151
|
+
raise TrustStoreError(f"{self.path}: 'decisions' must be a list")
|
|
152
|
+
found: dict[Path, Verdict] = {}
|
|
153
|
+
seen: set[str] = set()
|
|
154
|
+
for item in data["decisions"]:
|
|
155
|
+
if not isinstance(item, dict) or set(item) != {"path", "decision"}:
|
|
156
|
+
raise TrustStoreError(f"{self.path}: malformed decision {item!r}")
|
|
157
|
+
raw, verdict = item["path"], item["decision"]
|
|
158
|
+
if not isinstance(raw, str) or not os.path.isabs(raw):
|
|
159
|
+
raise TrustStoreError(f"{self.path}: path must be absolute: {raw!r}")
|
|
160
|
+
if os.path.normpath(raw) != raw:
|
|
161
|
+
raise TrustStoreError(f"{self.path}: path is not normalized: {raw!r}")
|
|
162
|
+
if verdict not in _VERDICTS:
|
|
163
|
+
raise TrustStoreError(f"{self.path}: unknown decision {verdict!r}")
|
|
164
|
+
if _key(Path(raw)) in seen:
|
|
165
|
+
raise TrustStoreError(f"{self.path}: duplicate path {raw!r}")
|
|
166
|
+
seen.add(_key(Path(raw)))
|
|
167
|
+
found[Path(raw)] = verdict
|
|
168
|
+
return found
|
|
169
|
+
|
|
170
|
+
def nearest(self, folder: Path) -> tuple[Path, Verdict] | None:
|
|
171
|
+
"""The saved decision for ``folder`` or its closest ancestor."""
|
|
172
|
+
saved = {_key(p): (p, v) for p, v in self.decisions().items()}
|
|
173
|
+
for candidate in (folder, *folder.parents):
|
|
174
|
+
hit = saved.get(_key(candidate))
|
|
175
|
+
if hit is not None:
|
|
176
|
+
return hit
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
def save(self, folder: Path, verdict: Verdict, *, replacing: Path | None = None) -> None:
|
|
180
|
+
"""Record a decision atomically, optionally dropping one other entry."""
|
|
181
|
+
current = self.decisions() # refuses to build on an unusable file
|
|
182
|
+
updated = {_key(p): (p, v) for p, v in current.items()}
|
|
183
|
+
if replacing is not None:
|
|
184
|
+
updated.pop(_key(replacing), None)
|
|
185
|
+
updated[_key(folder)] = (folder, verdict)
|
|
186
|
+
body = {
|
|
187
|
+
"version": _STORE_VERSION,
|
|
188
|
+
"decisions": [
|
|
189
|
+
{"path": str(p), "decision": v}
|
|
190
|
+
for p, v in sorted(updated.values(), key=lambda pair: str(pair[0]))
|
|
191
|
+
],
|
|
192
|
+
}
|
|
193
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
fd, staged = tempfile.mkstemp(dir=self.path.parent, prefix=".trust-", suffix=".json")
|
|
195
|
+
try:
|
|
196
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
197
|
+
json.dump(body, handle, indent=2)
|
|
198
|
+
handle.write("\n")
|
|
199
|
+
handle.flush()
|
|
200
|
+
os.fsync(handle.fileno())
|
|
201
|
+
try:
|
|
202
|
+
os.chmod(staged, 0o600)
|
|
203
|
+
except OSError:
|
|
204
|
+
pass
|
|
205
|
+
os.replace(staged, self.path)
|
|
206
|
+
except BaseException:
|
|
207
|
+
Path(staged).unlink(missing_ok=True)
|
|
208
|
+
raise
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
# Resolution
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclass(frozen=True, slots=True)
|
|
217
|
+
class TrustRequest:
|
|
218
|
+
"""What a frontend shows when asking: the folder and what it would load."""
|
|
219
|
+
|
|
220
|
+
folder: Path
|
|
221
|
+
inputs: ProjectInputs
|
|
222
|
+
store_problem: str | None = None
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def parent(self) -> Path | None:
|
|
226
|
+
parent = self.folder.parent
|
|
227
|
+
return None if parent == self.folder else parent
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@dataclass(frozen=True, slots=True)
|
|
231
|
+
class TrustOutcome:
|
|
232
|
+
"""The resolved decision for one folder in this run."""
|
|
233
|
+
|
|
234
|
+
folder: Path | None
|
|
235
|
+
trusted: bool
|
|
236
|
+
reason: str
|
|
237
|
+
inputs: ProjectInputs | None = None
|
|
238
|
+
notice: str | None = None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
Asker = Callable[[TrustRequest], Awaitable[Choice | None]]
|
|
242
|
+
|
|
243
|
+
# Completed resolutions for this process, keyed by canonical folder.
|
|
244
|
+
_resolved: dict[str, TrustOutcome] = {}
|
|
245
|
+
_engaged = False
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def reset_trust_cache() -> None:
|
|
249
|
+
"""Forget every resolution made in this process (tests, reload)."""
|
|
250
|
+
global _engaged
|
|
251
|
+
_resolved.clear()
|
|
252
|
+
_engaged = False
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
async def resolve_project_trust(
|
|
256
|
+
cwd: str | os.PathLike[str],
|
|
257
|
+
*,
|
|
258
|
+
override: bool | None = None,
|
|
259
|
+
default: DefaultPolicy = "ask",
|
|
260
|
+
ask: Asker | None = None,
|
|
261
|
+
store: TrustStore | None = None,
|
|
262
|
+
) -> TrustOutcome:
|
|
263
|
+
"""Decide whether ``cwd``'s project inputs may load; see module docs."""
|
|
264
|
+
global _engaged
|
|
265
|
+
_engaged = True
|
|
266
|
+
try:
|
|
267
|
+
folder = canonical_folder(cwd)
|
|
268
|
+
except OSError as exc:
|
|
269
|
+
return TrustOutcome(None, False, f"working folder could not be resolved ({exc})")
|
|
270
|
+
|
|
271
|
+
cached = _resolved.get(_key(folder))
|
|
272
|
+
if cached is not None:
|
|
273
|
+
return cached
|
|
274
|
+
|
|
275
|
+
inputs = scan_project_inputs(folder)
|
|
276
|
+
if override is not None:
|
|
277
|
+
flag = "--approve" if override else "--no-approve"
|
|
278
|
+
outcome = TrustOutcome(folder, override, f"{flag} for this run", inputs)
|
|
279
|
+
elif inputs.empty:
|
|
280
|
+
# Nothing to protect. Not cached: files added later must still ask.
|
|
281
|
+
return TrustOutcome(folder, True, "no project inputs", inputs)
|
|
282
|
+
else:
|
|
283
|
+
outcome = await _decide(folder, inputs, default, ask, store or TrustStore.default())
|
|
284
|
+
|
|
285
|
+
_resolved[_key(folder)] = outcome
|
|
286
|
+
return outcome
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
async def _decide(
|
|
290
|
+
folder: Path,
|
|
291
|
+
inputs: ProjectInputs,
|
|
292
|
+
default: DefaultPolicy,
|
|
293
|
+
ask: Asker | None,
|
|
294
|
+
store: TrustStore,
|
|
295
|
+
) -> TrustOutcome:
|
|
296
|
+
problem: str | None = None
|
|
297
|
+
try:
|
|
298
|
+
saved = store.nearest(folder)
|
|
299
|
+
except TrustStoreError as exc:
|
|
300
|
+
saved, problem = None, str(exc)
|
|
301
|
+
|
|
302
|
+
if saved is not None:
|
|
303
|
+
where, verdict = saved
|
|
304
|
+
scope = "this folder" if where == folder else f"parent {where}"
|
|
305
|
+
return TrustOutcome(folder, verdict == "trusted", f"saved decision for {scope}", inputs)
|
|
306
|
+
# An unusable store can grant nothing implicitly; only a run-only
|
|
307
|
+
# answer or --approve can load project inputs until it is fixed.
|
|
308
|
+
if problem is None and default == "always":
|
|
309
|
+
return TrustOutcome(folder, True, "user default: always trust", inputs)
|
|
310
|
+
if problem is None and default == "never":
|
|
311
|
+
return TrustOutcome(folder, False, "user default: never trust", inputs)
|
|
312
|
+
if ask is None:
|
|
313
|
+
reason = problem or "no saved decision and no way to ask"
|
|
314
|
+
return TrustOutcome(folder, False, reason, inputs)
|
|
315
|
+
|
|
316
|
+
choice = await ask(TrustRequest(folder, inputs, problem))
|
|
317
|
+
if choice is None:
|
|
318
|
+
raise StartupCancelled(f"No trust decision for {folder}")
|
|
319
|
+
return _apply(choice, folder, inputs, store)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _apply(choice: Choice, folder: Path, inputs: ProjectInputs, store: TrustStore) -> TrustOutcome:
|
|
323
|
+
if choice is Choice.TRUST_ONCE:
|
|
324
|
+
return TrustOutcome(folder, True, "trusted for this run", inputs)
|
|
325
|
+
if choice is Choice.DISTRUST_ONCE:
|
|
326
|
+
return TrustOutcome(folder, False, "not trusted for this run", inputs)
|
|
327
|
+
|
|
328
|
+
target, verdict, replacing = folder, "trusted", None
|
|
329
|
+
if choice is Choice.TRUST_PARENT and folder.parent != folder:
|
|
330
|
+
target, replacing = folder.parent, folder
|
|
331
|
+
elif choice is Choice.DISTRUST_FOLDER:
|
|
332
|
+
verdict = "untrusted"
|
|
333
|
+
try:
|
|
334
|
+
store.save(target, verdict, replacing=replacing) # type: ignore[arg-type]
|
|
335
|
+
except (OSError, TrustStoreError) as exc:
|
|
336
|
+
# A remembered grant only counts once it is actually remembered.
|
|
337
|
+
notice = f"Could not save trust decision ({exc}); project inputs stay off."
|
|
338
|
+
return TrustOutcome(folder, False, "decision not saved", inputs, notice)
|
|
339
|
+
trusted = verdict == "trusted"
|
|
340
|
+
scope = "this folder" if target == folder else f"parent {target}"
|
|
341
|
+
return TrustOutcome(folder, trusted, f"saved: {verdict} ({scope})", inputs)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def project_inputs_allowed(project: str | os.PathLike[str]) -> bool:
|
|
345
|
+
"""Whether loaders may read ``project``'s own inputs right now."""
|
|
346
|
+
if not _engaged:
|
|
347
|
+
return True
|
|
348
|
+
try:
|
|
349
|
+
folder = canonical_folder(project)
|
|
350
|
+
except OSError:
|
|
351
|
+
return False
|
|
352
|
+
outcome = _resolved.get(_key(folder))
|
|
353
|
+
if outcome is not None:
|
|
354
|
+
return outcome.trusted
|
|
355
|
+
return scan_project_inputs(folder).empty
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def project_settings_allowed(project: str | os.PathLike[str]) -> bool:
|
|
359
|
+
"""Whether a project's settings file may be applied.
|
|
360
|
+
|
|
361
|
+
Stricter than :func:`project_inputs_allowed`: before the trust decision
|
|
362
|
+
has been made for this process, the answer is no. Settings choose the
|
|
363
|
+
provider and model, so they wait for the folder to be trusted.
|
|
364
|
+
"""
|
|
365
|
+
return _engaged and project_inputs_allowed(project)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
__all__ = [
|
|
369
|
+
"Choice",
|
|
370
|
+
"project_settings_allowed",
|
|
371
|
+
"DefaultPolicy",
|
|
372
|
+
"ProjectInputs",
|
|
373
|
+
"StartupCancelled",
|
|
374
|
+
"TrustOutcome",
|
|
375
|
+
"TrustRequest",
|
|
376
|
+
"TrustStore",
|
|
377
|
+
"TrustStoreError",
|
|
378
|
+
"canonical_folder",
|
|
379
|
+
"project_inputs_allowed",
|
|
380
|
+
"reset_trust_cache",
|
|
381
|
+
"resolve_project_trust",
|
|
382
|
+
"scan_project_inputs",
|
|
383
|
+
]
|
rlmagent/tui/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Textual TUI, consumed strictly behind an adapter boundary"""
|