agentd 0.9.2__tar.gz → 0.9.3__tar.gz
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.
- {agentd-0.9.2 → agentd-0.9.3}/PKG-INFO +1 -1
- {agentd-0.9.2 → agentd-0.9.3}/agentd/devices/browser.py +39 -3
- {agentd-0.9.2 → agentd-0.9.3}/agentd/egress/__init__.py +25 -3
- {agentd-0.9.2 → agentd-0.9.3}/agentd/egress/approvals.py +100 -18
- {agentd-0.9.2 → agentd-0.9.3}/agentd/egress/policy.py +7 -32
- {agentd-0.9.2 → agentd-0.9.3}/agentd/egress/proxy.py +46 -3
- agentd-0.9.3/agentd/fnox.py +212 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/secrets.py +16 -18
- {agentd-0.9.2 → agentd-0.9.3}/agentd/serve/app.py +16 -2
- {agentd-0.9.2 → agentd-0.9.3}/docs/egress-and-secrets.md +7 -3
- {agentd-0.9.2 → agentd-0.9.3}/pyproject.toml +1 -1
- agentd-0.9.3/test/fake_fnox.py +75 -0
- {agentd-0.9.2 → agentd-0.9.3}/test/test_approvals.py +15 -1
- {agentd-0.9.2 → agentd-0.9.3}/test/test_browser.py +8 -2
- {agentd-0.9.2 → agentd-0.9.3}/test/test_egress.py +0 -3
- agentd-0.9.3/test/test_fnox.py +145 -0
- agentd-0.9.3/test/test_unlock.py +198 -0
- {agentd-0.9.2 → agentd-0.9.3}/uv.lock +1 -1
- {agentd-0.9.2 → agentd-0.9.3}/.gitignore +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/.python-version +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/LICENSE +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/README.md +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/__init__.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/app.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/availability.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/conversation_logger.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/devices/__init__.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/devices/android.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/egress/ca.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/egress/http2.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/harness/__init__.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/harness/chat.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/harness/claude_cli.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/harness/claude_code.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/harness/codex.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/harness/events.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/harness/omp.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/harness/opencode.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/harness/responses.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/harness/routes.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/harness/tool_events.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/harness/transcripts.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/llm_dispatch.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/mcp_bridge.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/model/__init__.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/model/config.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/model_proxy.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/patch.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/ptc.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/remote.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/remote_skills.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/.gitignore +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/__init__.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/base.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/build.sh +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/cli.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/colima.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/colima_relay.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/docker.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/entitlements.plist +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/executor.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/images/agents/Dockerfile +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/krun.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/launcher.c +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/linux.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/mux.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/net/.gitignore +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/net/Cargo.lock +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/net/Cargo.toml +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/net/src/main.rs +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/prebuilt.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/rootfs.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/sandboxd.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/session.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/tls.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/serve/__init__.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/serve/cli.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/serve/config.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/serve/legacy.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/serve/paging.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/serve/pool.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/serve/schedules.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/serve/store.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/serve/turns.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/agentd/tool_decorator.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/artifacts/bin-darwin/agentd-krun +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/artifacts/bin-darwin/agentd-net +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/artifacts/bin-darwin/prebuilt.json +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/artifacts/bin-linux-aarch64/agentd-krun-linux-aarch64 +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/artifacts/bin-linux-aarch64/agentd-net-linux-aarch64 +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/artifacts/bin-linux-aarch64/libkrun-linux-aarch64.so +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/artifacts/bin-linux-aarch64/prebuilt.json +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/artifacts/bin-linux-x86_64/agentd-krun-linux-x86_64 +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/artifacts/bin-linux-x86_64/agentd-net-linux-x86_64 +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/artifacts/bin-linux-x86_64/libkrun-linux-x86_64.so +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/artifacts/bin-linux-x86_64/prebuilt.json +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/config/facts.yaml +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/config/filesystem.yaml +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/config/ycombinator-config.yaml +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/docs/agentd-serve.md +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/docs/harnesses.md +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/docs/ptc.md +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/docs/releasing.md +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/docs/sandboxes.md +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/docs/tool-calling.md +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/docs/upgrading-0.9.md +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/examples/code_interpreter.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/examples/file_retrieval_complete.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/examples/ptc_streaming.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/examples/ptc_with_mcp.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/examples/ptc_with_tools.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/examples/sample_document.txt +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/hatch_build.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/scripts/build-binaries.sh +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/scripts/build-wheels.sh +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/scripts/check-wheel.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/test/__init__.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/test/conftest.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/test/test_android.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/test/test_available.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/test/test_colima.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/test/test_compare_streaming_events.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/test/test_harness.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/test/test_harness_open.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/test/test_harness_responses.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/test/test_harness_streaming.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/test/test_linux.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/test/test_manual_tool_execution.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/test/test_mcp_bridge.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/test/test_mixed_tools.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/test/test_patch.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/test/test_prebuilt.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/test/test_ptc.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/test/test_ptc_responses.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/test/test_remote.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/test/test_responses.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/test/test_responses_stream.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/test/test_sandbox.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/test/test_secrets.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/test/test_serve.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/test/test_transcripts.py +0 -0
- {agentd-0.9.2 → agentd-0.9.3}/test/test_upstream.py +0 -0
|
@@ -50,6 +50,7 @@ from pathlib import Path
|
|
|
50
50
|
from typing import Any
|
|
51
51
|
from urllib.parse import urlsplit
|
|
52
52
|
|
|
53
|
+
from agentd import fnox
|
|
53
54
|
from agentd import secrets as host_secrets
|
|
54
55
|
|
|
55
56
|
CHROME_PATHS = (
|
|
@@ -260,13 +261,47 @@ class Browser:
|
|
|
260
261
|
return {"status": "allowed"}
|
|
261
262
|
if self.approvals is None:
|
|
262
263
|
raise BrowserAccessError("logins need an approver (none is configured)")
|
|
263
|
-
|
|
264
|
+
names = self._login_secrets(site)
|
|
265
|
+
missing = self._uncached(names) # locked in fnox: approving will need the master password
|
|
264
266
|
approval = self.approvals.request("browser_login", "", {
|
|
265
|
-
"site": site, "host": hosts[0], "hosts": hosts,
|
|
266
|
-
|
|
267
|
+
"site": site, "host": hosts[0], "hosts": hosts, "secrets": names}, reason,
|
|
268
|
+
unlock=(missing, self._fill) if missing else None)
|
|
267
269
|
self._login_pending[site] = approval.id
|
|
268
270
|
return {"id": approval.id, "status": approval.status}
|
|
269
271
|
|
|
272
|
+
def _login_secrets(self, site: str) -> list[str]:
|
|
273
|
+
conf = self.logins[site]
|
|
274
|
+
return [conf[k] for k in ("username", "password", "totp") if conf.get(k)]
|
|
275
|
+
|
|
276
|
+
def _fnox_dir(self) -> Path:
|
|
277
|
+
return Path(self.fnox_cwd or os.getcwd()).resolve()
|
|
278
|
+
|
|
279
|
+
def _uncached(self, names: list[str]) -> list[str]:
|
|
280
|
+
return fnox.uncached(names, self._fnox_dir())
|
|
281
|
+
|
|
282
|
+
def _fill(self, names: list[str], password: bytearray) -> dict[str, str]:
|
|
283
|
+
return fnox.fill(names, password, cwd=self._fnox_dir())
|
|
284
|
+
|
|
285
|
+
async def _ensure_unlocked(self, site: str) -> None:
|
|
286
|
+
"""A login approved earlier (always, session, by host code) whose secrets are locked in
|
|
287
|
+
fnox asks for an unlock (the human's master password) and waits for it."""
|
|
288
|
+
missing = await asyncio.to_thread(self._uncached, self._login_secrets(site))
|
|
289
|
+
if not missing:
|
|
290
|
+
return
|
|
291
|
+
if self.approvals is None:
|
|
292
|
+
raise BrowserAccessError(f"{site}'s credentials ({', '.join(missing)}) are locked in fnox, "
|
|
293
|
+
"and no approver is configured to unlock them")
|
|
294
|
+
approval = self.approvals.request("unlock", "", {"site": site, "host": self._login_hosts(site)[0],
|
|
295
|
+
"secrets": missing}, f"log in to {site}",
|
|
296
|
+
unlock=(missing, self._fill))
|
|
297
|
+
status = await self.approvals.wait(approval.id)
|
|
298
|
+
if status not in ("once", "session", "always"):
|
|
299
|
+
raise BrowserAccessError(f"{site}'s credentials are locked in fnox: unlock approval {approval.id} is "
|
|
300
|
+
f"{status}" + ("" if status == "deny" else "; call browser_login again once "
|
|
301
|
+
"it's approved"))
|
|
302
|
+
if await asyncio.to_thread(self._uncached, missing):
|
|
303
|
+
raise BrowserAccessError(f"{site}'s credentials are still locked in fnox")
|
|
304
|
+
|
|
270
305
|
def _use_login(self, site: str, spend: bool = True) -> None:
|
|
271
306
|
"""Raise unless ``site``'s login is approved; a 'once' approval is spent here (with ``spend``)."""
|
|
272
307
|
if self._login_granted(site):
|
|
@@ -486,6 +521,7 @@ class Browser:
|
|
|
486
521
|
"""Fill and submit a site's login form from fnox, on the host (only on its login hosts)."""
|
|
487
522
|
await self._ready()
|
|
488
523
|
self._use_login(site, spend=False) # a 'once' is spent only when credentials are typed
|
|
524
|
+
await self._ensure_unlocked(site)
|
|
489
525
|
conf = self.logins[site]
|
|
490
526
|
kw = {"cwd": self.fnox_cwd} if self.fnox_cwd else {}
|
|
491
527
|
# Already on the site's login form (the agent got there itself): stay. Otherwise
|
|
@@ -17,10 +17,12 @@ from dataclasses import dataclass, field
|
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
from typing import Any
|
|
19
19
|
|
|
20
|
+
from agentd import fnox
|
|
20
21
|
from agentd.egress.ca import SessionCA
|
|
21
22
|
from agentd.egress.policy import (Allow, Policy, SecretRule, fnox_config_files, fnox_get, load_rules,
|
|
22
23
|
load_secret_names, make_placeholder, opaque_placeholder)
|
|
23
24
|
from agentd.egress.proxy import EgressProxy
|
|
25
|
+
from agentd.fnox import SecretMissing
|
|
24
26
|
from agentd.sandbox.base import DEFAULT_HOME
|
|
25
27
|
|
|
26
28
|
logger = logging.getLogger(__name__)
|
|
@@ -84,13 +86,18 @@ class EgressSession:
|
|
|
84
86
|
self.config_files = await asyncio.to_thread(
|
|
85
87
|
fnox_config_files, self.workspace, fnox=e.fnox_bin, profile=e.fnox_profile)
|
|
86
88
|
rules = load_rules(self.config_files)
|
|
89
|
+
locked: set[str] = set()
|
|
87
90
|
for name in dict.fromkeys(r.secret for r in rules):
|
|
91
|
+
# Read now only what fnox has unlocked (for format-preserving placeholders);
|
|
92
|
+
# a locked secret is read, or unlocked, when a request first needs it.
|
|
88
93
|
try:
|
|
89
94
|
secrets[name] = await asyncio.to_thread(
|
|
90
95
|
fnox_get, name, self.workspace, fnox=e.fnox_bin, profile=e.fnox_profile)
|
|
96
|
+
except SecretMissing:
|
|
97
|
+
locked.add(name)
|
|
91
98
|
except (RuntimeError, OSError) as err:
|
|
92
99
|
logger.warning("egress: %s (its rules are disabled)", err)
|
|
93
|
-
rules = [r for r in rules if r.secret in secrets]
|
|
100
|
+
rules = [r for r in rules if r.secret in secrets or r.secret in locked]
|
|
94
101
|
# Every other secret the sandbox may ask for gets a placeholder now; its
|
|
95
102
|
# value is read from fnox only once a rule or approval lets it be sent.
|
|
96
103
|
names = load_secret_names(self.config_files, e.fnox_profile)
|
|
@@ -98,7 +105,8 @@ class EgressSession:
|
|
|
98
105
|
self.descriptions[name] = names[name]
|
|
99
106
|
for r in rules:
|
|
100
107
|
if r.secret not in self.placeholders:
|
|
101
|
-
ph = r.placeholder or make_placeholder(secrets[r.secret])
|
|
108
|
+
ph = r.placeholder or (make_placeholder(secrets[r.secret]) if r.secret in secrets
|
|
109
|
+
else opaque_placeholder())
|
|
102
110
|
self.placeholders[r.secret] = ph
|
|
103
111
|
self.descriptions.setdefault(r.secret, "")
|
|
104
112
|
for name in self.descriptions:
|
|
@@ -113,7 +121,8 @@ class EgressSession:
|
|
|
113
121
|
await self.proxy.start()
|
|
114
122
|
|
|
115
123
|
async def _load(self, name: str) -> None:
|
|
116
|
-
"""Read a secret from fnox for the proxy, the first time it may be sent
|
|
124
|
+
"""Read a secret from fnox for the proxy, the first time it may be sent
|
|
125
|
+
(:class:`SecretMissing` if it's locked in fnox)."""
|
|
117
126
|
e = self.egress
|
|
118
127
|
try:
|
|
119
128
|
value = await asyncio.to_thread(fnox_get, name, self.workspace, fnox=e.fnox_bin, profile=e.fnox_profile)
|
|
@@ -123,6 +132,19 @@ class EgressSession:
|
|
|
123
132
|
if self.proxy is not None:
|
|
124
133
|
self.proxy.add_secret(name, value)
|
|
125
134
|
|
|
135
|
+
def loaded(self, name: str) -> bool:
|
|
136
|
+
return self.proxy is not None and name in self.proxy.secrets
|
|
137
|
+
|
|
138
|
+
def uncached(self, names: list[str]) -> list[str]:
|
|
139
|
+
"""Which of ``names`` fnox can't read without an unlock (blocking)."""
|
|
140
|
+
e = self.egress
|
|
141
|
+
return fnox.uncached(names, self.workspace, fnox=e.fnox_bin, profile=e.fnox_profile) if e.fnox else []
|
|
142
|
+
|
|
143
|
+
def fill(self, names: list[str], password: bytearray) -> dict[str, str]:
|
|
144
|
+
"""Unlock ``names`` in fnox with the master password (blocking; zeroes it)."""
|
|
145
|
+
e = self.egress
|
|
146
|
+
return fnox.fill(names, password, cwd=self.workspace, fnox=e.fnox_bin, profile=e.fnox_profile)
|
|
147
|
+
|
|
126
148
|
def list_secrets(self) -> list[dict[str, Any]]:
|
|
127
149
|
"""What the sandbox can use or ask for: names, descriptions, env vars and rules (never values)."""
|
|
128
150
|
policy = self.proxy.policy if self.proxy is not None else Policy()
|
|
@@ -10,11 +10,19 @@ sha256=<HMAC of the body>``). The proxy holds the request for ``hold``
|
|
|
10
10
|
seconds: approved in time, it goes through; otherwise the agent gets a 403
|
|
11
11
|
saying the approval is pending (``id``, ``retry_after``) and retries later.
|
|
12
12
|
|
|
13
|
+
Secrets locked in fnox (a vault whose master password fnox's daemon doesn't
|
|
14
|
+
have yet) are listed in the approval's ``details["unlock"]``; allowing it then
|
|
15
|
+
needs the master password (``{"decision": ..., "password": ...}``), which
|
|
16
|
+
unlocks them (:func:`agentd.fnox.fill`) before the decision applies. A wrong
|
|
17
|
+
password leaves the approval pending. A pre-approved use that finds its secret
|
|
18
|
+
locked asks with an approval of kind ``unlock``.
|
|
19
|
+
|
|
13
20
|
Decisions (``agentd serve``: ``POST /v1/approvals/{id}``, or :meth:`decide`):
|
|
14
21
|
``once`` (the held request, or if none is held, the next matching one),
|
|
15
22
|
``session`` (until the sandbox stops), ``always``
|
|
16
|
-
(persisted
|
|
17
|
-
|
|
23
|
+
(persisted in ``~/.agentd/egress/allow.toml``: host allowances, secret rules
|
|
24
|
+
and browser logins; not in the fnox config, whose contents are part of fnox's
|
|
25
|
+
cache key, so editing it would re-lock every unlocked secret), or ``deny``.
|
|
18
26
|
"""
|
|
19
27
|
from __future__ import annotations
|
|
20
28
|
|
|
@@ -27,9 +35,10 @@ import os
|
|
|
27
35
|
import time
|
|
28
36
|
from dataclasses import asdict, dataclass, field
|
|
29
37
|
from pathlib import Path
|
|
30
|
-
from typing import TYPE_CHECKING, Any
|
|
38
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
31
39
|
|
|
32
|
-
from agentd
|
|
40
|
+
from agentd import fnox
|
|
41
|
+
from agentd.egress.policy import Allow, Policy, SecretRule
|
|
33
42
|
from agentd.sandbox.base import DEFAULT_HOME
|
|
34
43
|
|
|
35
44
|
if TYPE_CHECKING:
|
|
@@ -44,7 +53,7 @@ DECISIONS = ("once", "session", "always", "deny")
|
|
|
44
53
|
@dataclass
|
|
45
54
|
class Approval:
|
|
46
55
|
id: str
|
|
47
|
-
kind: str #
|
|
56
|
+
kind: str # connect | secret | unlock | browser | browser_login | device
|
|
48
57
|
session: str
|
|
49
58
|
details: dict[str, Any]
|
|
50
59
|
reason: str = ""
|
|
@@ -71,12 +80,13 @@ class Approvals:
|
|
|
71
80
|
self._holding: dict[str, int] = {} # approval id -> requests held for it right now
|
|
72
81
|
self._sessions: dict[str, tuple["EgressSession", Policy]] = {}
|
|
73
82
|
self._dedupe: dict[tuple, str] = {}
|
|
83
|
+
self._fillers: dict[str, Callable[[list[str], bytearray], dict[str, str]]] = {} # approval id -> unlock
|
|
74
84
|
|
|
75
85
|
# ------------------------------------------------------------------ #
|
|
76
|
-
# Persistent
|
|
86
|
+
# Persistent grants ("always")
|
|
77
87
|
# ------------------------------------------------------------------ #
|
|
78
88
|
|
|
79
|
-
def _saved(self) -> dict[str, list
|
|
89
|
+
def _saved(self) -> dict[str, list]:
|
|
80
90
|
try:
|
|
81
91
|
import tomllib
|
|
82
92
|
except ModuleNotFoundError:
|
|
@@ -94,14 +104,27 @@ class Approvals:
|
|
|
94
104
|
"""Browser logins approved "always", as ``site@host``."""
|
|
95
105
|
return self._saved().get("browser_logins", [])
|
|
96
106
|
|
|
97
|
-
def
|
|
107
|
+
def saved_rules(self) -> list[SecretRule]:
|
|
108
|
+
"""Secret rules approved "always" (``[[secret_rules]]``: secret, domain, header, methods, paths)."""
|
|
109
|
+
rules = []
|
|
110
|
+
for r in self._saved().get("secret_rules", []):
|
|
111
|
+
if isinstance(r, dict) and r.get("secret") and r.get("domain"):
|
|
112
|
+
rules.append(SecretRule(secret=r["secret"], domain=str(r["domain"]).lower(),
|
|
113
|
+
header=str(r.get("header", "authorization")).lower(),
|
|
114
|
+
methods=tuple(r.get("methods") or ()), paths=tuple(r.get("paths") or ())))
|
|
115
|
+
return rules
|
|
116
|
+
|
|
117
|
+
def _save(self, key: str, value: str | dict) -> None:
|
|
98
118
|
saved = self._saved()
|
|
99
119
|
if value in saved.get(key, []):
|
|
100
120
|
return
|
|
101
121
|
saved.setdefault(key, []).append(value)
|
|
122
|
+
lists = [f"{k} = [" + ", ".join(json.dumps(s) for s in v) + "]\n"
|
|
123
|
+
for k, v in saved.items() if all(isinstance(s, str) for s in v)]
|
|
124
|
+
tables = [f"\n[[{k}]]\n" + "".join(f"{f} = {json.dumps(x)}\n" for f, x in t.items())
|
|
125
|
+
for k, v in saved.items() if not all(isinstance(s, str) for s in v) for t in v]
|
|
102
126
|
self.allow_file.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
-
self.allow_file.write_text("".join(
|
|
104
|
-
for k, v in saved.items()))
|
|
127
|
+
self.allow_file.write_text("".join(lists + tables))
|
|
105
128
|
|
|
106
129
|
def _save_allow(self, spec: str) -> None:
|
|
107
130
|
self._save("allow", spec)
|
|
@@ -114,9 +137,18 @@ class Approvals:
|
|
|
114
137
|
"""The proxy's hook for one session: ``await ask(kind, **details) -> (approved, approval id, status)``."""
|
|
115
138
|
self._sessions[egress.session] = (egress, policy)
|
|
116
139
|
policy.allows.extend(Allow.parse(s) for s in self.saved_allows())
|
|
140
|
+
policy.rules.extend(r for r in self.saved_rules() if r.secret in egress.placeholders)
|
|
117
141
|
|
|
118
142
|
async def ask(kind: str, **details: Any) -> tuple[bool, str]:
|
|
119
|
-
|
|
143
|
+
names = details.get("secrets") if kind == "unlock" else \
|
|
144
|
+
[details["secret"]] if kind == "secret" and details.get("secret") else []
|
|
145
|
+
unlock = None
|
|
146
|
+
if names:
|
|
147
|
+
missing = await asyncio.to_thread(egress.uncached, [n for n in names if not egress.loaded(n)])
|
|
148
|
+
if kind == "unlock" and not missing:
|
|
149
|
+
return True, "", "unlocked" # unlocked meanwhile: nothing to ask
|
|
150
|
+
unlock = (missing, egress.fill) if missing else None
|
|
151
|
+
approval = self.request(kind, egress.session, details, unlock=unlock)
|
|
120
152
|
if approval.status != "pending":
|
|
121
153
|
return approval.status != "deny", approval.id, approval.status
|
|
122
154
|
fut = self._waiters.setdefault(approval.id, asyncio.get_running_loop().create_future())
|
|
@@ -140,9 +172,15 @@ class Approvals:
|
|
|
140
172
|
# Requests and decisions
|
|
141
173
|
# ------------------------------------------------------------------ #
|
|
142
174
|
|
|
143
|
-
def request(self, kind: str, session: str, details: dict[str, Any], reason: str = ""
|
|
144
|
-
|
|
175
|
+
def request(self, kind: str, session: str, details: dict[str, Any], reason: str = "",
|
|
176
|
+
unlock: tuple[list[str], Callable[[list[str], bytearray], dict[str, str]]] | None = None) -> Approval:
|
|
177
|
+
"""A pending approval (an identical pending one is reused) announced to the webhook.
|
|
178
|
+
|
|
179
|
+
``unlock``: (secret names locked in fnox, a function that unlocks them with
|
|
180
|
+
the master password); allowing the approval will need that password."""
|
|
145
181
|
self._expire_old()
|
|
182
|
+
if unlock and unlock[0]:
|
|
183
|
+
details = {**details, "unlock": list(unlock[0])}
|
|
146
184
|
key = (kind, session, json.dumps(details, sort_keys=True))
|
|
147
185
|
existing = self._dedupe.get(key)
|
|
148
186
|
if existing and existing in self.items and self.items[existing].status == "pending":
|
|
@@ -151,10 +189,43 @@ class Approvals:
|
|
|
151
189
|
reason=reason)
|
|
152
190
|
self.items[approval.id] = approval
|
|
153
191
|
self._dedupe[key] = approval.id
|
|
192
|
+
if unlock and unlock[0]:
|
|
193
|
+
self._fillers[approval.id] = unlock[1]
|
|
154
194
|
self._notify(approval)
|
|
155
195
|
return approval
|
|
156
196
|
|
|
157
|
-
def
|
|
197
|
+
def unlock(self, approval_id: str, password: bytearray | str) -> None:
|
|
198
|
+
"""Unlock the approval's locked secrets with the master password (zeroed afterwards).
|
|
199
|
+
|
|
200
|
+
The approval stays pending if it fails: :class:`agentd.fnox.WrongPassword` (a
|
|
201
|
+
ValueError: ask again) or :class:`agentd.fnox.UnlockFailed` (unlocked, but fnox
|
|
202
|
+
couldn't read a secret: fix the vault or the fnox config, or deny). Blocking:
|
|
203
|
+
fnox derives the vault key per secret; agentd serve runs it in a thread."""
|
|
204
|
+
pw = password if isinstance(password, bytearray) else bytearray(str(password).encode())
|
|
205
|
+
try:
|
|
206
|
+
approval = self.items.get(approval_id)
|
|
207
|
+
if approval is None:
|
|
208
|
+
raise KeyError(approval_id)
|
|
209
|
+
names = approval.details.get("unlock") or []
|
|
210
|
+
if not names:
|
|
211
|
+
return
|
|
212
|
+
failed = self._fillers[approval_id](names, pw)
|
|
213
|
+
if any(why == fnox.WRONG_PASSWORD for why in failed.values()):
|
|
214
|
+
raise fnox.WrongPassword("master password didn't unlock the vault")
|
|
215
|
+
if failed:
|
|
216
|
+
raise fnox.UnlockFailed("the vault unlocked, but fnox couldn't read "
|
|
217
|
+
+ "; ".join(f"{name}: {why}" for name, why in failed.items())
|
|
218
|
+
+ " (renamed or deleted in the vault? fix it there or in the fnox config, "
|
|
219
|
+
"or deny)")
|
|
220
|
+
approval.details["unlock"] = []
|
|
221
|
+
self._fillers.pop(approval_id, None)
|
|
222
|
+
finally:
|
|
223
|
+
for i in range(len(pw)):
|
|
224
|
+
pw[i] = 0
|
|
225
|
+
|
|
226
|
+
def decide(self, approval_id: str, decision: str, by: str = "local",
|
|
227
|
+
password: bytearray | str | None = None) -> Approval:
|
|
228
|
+
"""Decide an approval. One that lists secrets to unlock needs ``password`` to be allowed."""
|
|
158
229
|
if decision not in DECISIONS:
|
|
159
230
|
raise ValueError(f"decision must be one of {DECISIONS}")
|
|
160
231
|
approval = self.items.get(approval_id)
|
|
@@ -162,6 +233,13 @@ class Approvals:
|
|
|
162
233
|
raise KeyError(approval_id)
|
|
163
234
|
if approval.status != "pending":
|
|
164
235
|
return approval
|
|
236
|
+
if decision != "deny" and approval.details.get("unlock"):
|
|
237
|
+
if password is None:
|
|
238
|
+
raise ValueError(f"approving this unlocks {', '.join(approval.details['unlock'])} in fnox: "
|
|
239
|
+
"it needs the master password")
|
|
240
|
+
self.unlock(approval_id, password)
|
|
241
|
+
elif isinstance(password, bytearray):
|
|
242
|
+
password[:] = bytes(len(password))
|
|
165
243
|
approval.status, approval.decided_by, approval.decided = decision, by, time.time()
|
|
166
244
|
if decision in ("session", "always"):
|
|
167
245
|
self._grant(approval, persist=decision == "always")
|
|
@@ -195,9 +273,9 @@ class Approvals:
|
|
|
195
273
|
for egress, policy in targets:
|
|
196
274
|
if rule.secret in egress.placeholders:
|
|
197
275
|
(policy.once_rules if once else policy.rules).append(rule)
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
276
|
+
if persist:
|
|
277
|
+
self._save("secret_rules", {"secret": rule.secret, "domain": rule.domain, "header": rule.header,
|
|
278
|
+
"methods": list(rule.methods), "paths": list(rule.paths)})
|
|
201
279
|
|
|
202
280
|
def _expire_old(self) -> None:
|
|
203
281
|
now = time.time()
|
|
@@ -349,7 +427,11 @@ async def request_access(host: str, reason: str, port: int = 443, secret: str =
|
|
|
349
427
|
if secret:
|
|
350
428
|
details = {"secret": secret, "host": host.lower(), "method": method.upper(), "path": path,
|
|
351
429
|
"header": "authorization"}
|
|
352
|
-
|
|
430
|
+
unlock = None
|
|
431
|
+
if egress is not None and not egress.loaded(secret):
|
|
432
|
+
missing = await asyncio.to_thread(egress.uncached, [secret])
|
|
433
|
+
unlock = (missing, egress.fill) if missing else None
|
|
434
|
+
approval = _ACTIVE.request("secret", session, details, reason, unlock=unlock)
|
|
353
435
|
else:
|
|
354
436
|
approval = _ACTIVE.request("connect", session, {"host": host.lower(), "ip": "", "port": port}, reason)
|
|
355
437
|
return {"id": approval.id, "status": await _ACTIVE.wait(approval.id)}
|
|
@@ -124,14 +124,9 @@ class Policy:
|
|
|
124
124
|
|
|
125
125
|
def fnox_config_files(cwd: Path, *, fnox: str = "fnox", profile: str | None = None) -> list[Path]:
|
|
126
126
|
"""The config files fnox would load from ``cwd`` (its own discovery)."""
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
except (OSError, subprocess.SubprocessError):
|
|
131
|
-
return []
|
|
132
|
-
if r.returncode != 0:
|
|
133
|
-
return []
|
|
134
|
-
return [Path(line.strip()) for line in r.stdout.splitlines() if line.strip().endswith(".toml")]
|
|
127
|
+
from agentd import fnox as _fnox
|
|
128
|
+
|
|
129
|
+
return _fnox.config_files(cwd, fnox=fnox, profile=profile)
|
|
135
130
|
|
|
136
131
|
|
|
137
132
|
def load_rules(files: list[Path]) -> list[SecretRule]:
|
|
@@ -175,13 +170,10 @@ def load_secret_names(files: list[Path], profile: str | None = None) -> dict[str
|
|
|
175
170
|
|
|
176
171
|
|
|
177
172
|
def fnox_get(name: str, cwd: Path, *, fnox: str = "fnox", profile: str | None = None) -> str:
|
|
178
|
-
"""One secret's value, from fnox on the host (no prompts)."""
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if r.returncode != 0:
|
|
183
|
-
raise RuntimeError(f"fnox couldn't get {name}: {(r.stderr or r.stdout).strip()[:300]}")
|
|
184
|
-
return r.stdout.rstrip("\n")
|
|
173
|
+
"""One secret's value, from fnox on the host (no prompts; :class:`agentd.fnox.SecretMissing` if locked)."""
|
|
174
|
+
from agentd import fnox as _fnox
|
|
175
|
+
|
|
176
|
+
return _fnox.get(name, cwd, fnox=fnox, profile=profile)
|
|
185
177
|
|
|
186
178
|
|
|
187
179
|
def make_placeholder(value: str) -> str:
|
|
@@ -201,23 +193,6 @@ def opaque_placeholder() -> str:
|
|
|
201
193
|
return "agentd_ph_" + "".join(_secrets.choice(alphabet) for _ in range(24))
|
|
202
194
|
|
|
203
195
|
|
|
204
|
-
def add_rule_to_fnox(config: Path, rule: SecretRule) -> None:
|
|
205
|
-
"""Append a rule to a fnox config ("always" grants live in fnox's config)."""
|
|
206
|
-
def q(s: str) -> str:
|
|
207
|
-
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
208
|
-
|
|
209
|
-
lines = ["", "[[proxy.rules]]", f"secret = {q(rule.secret)}", f"domain = {q(rule.domain)}",
|
|
210
|
-
f"header = {q(rule.header)}"]
|
|
211
|
-
if rule.methods:
|
|
212
|
-
lines.append("methods = [" + ", ".join(q(m) for m in rule.methods) + "]")
|
|
213
|
-
if rule.paths:
|
|
214
|
-
lines.append("paths = [" + ", ".join(q(p) for p in rule.paths) + "]")
|
|
215
|
-
if rule.placeholder:
|
|
216
|
-
lines.append(f"placeholder = {q(rule.placeholder)}")
|
|
217
|
-
with open(config, "a") as f:
|
|
218
|
-
f.write("\n".join(lines) + "\n")
|
|
219
|
-
|
|
220
|
-
|
|
221
196
|
def rules_summary(rules: list[SecretRule]) -> list[dict[str, Any]]:
|
|
222
197
|
return [{"secret": r.secret, "domain": r.domain, "header": r.header, "methods": list(r.methods) or ["*"],
|
|
223
198
|
"paths": list(r.paths) or ["*"]} for r in rules]
|
|
@@ -131,12 +131,31 @@ class EgressProxy:
|
|
|
131
131
|
self.masks: dict[bytes, bytes] = {}
|
|
132
132
|
for name, value in secrets.items():
|
|
133
133
|
self._mask(name, value)
|
|
134
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
135
|
+
if load is not None:
|
|
136
|
+
from agentd import fnox
|
|
137
|
+
|
|
138
|
+
fnox.on_clear(self) # a vault changed: read again (or unlock) on next use
|
|
134
139
|
self._server: asyncio.base_events.Server | None = None
|
|
135
140
|
|
|
136
141
|
def _mask(self, name: str, value: str) -> None:
|
|
137
142
|
ph = self.placeholders.get(name, "")
|
|
138
143
|
self.masks[value.encode()] = ph.encode() if len(ph) == len(value) else b"*" * len(value)
|
|
139
144
|
|
|
145
|
+
def forget_secrets(self) -> None:
|
|
146
|
+
"""Drop the values read so far (fnox.clear(): the vault changed). Masks stay, so
|
|
147
|
+
responses are still scrubbed of old values."""
|
|
148
|
+
loop = self._loop
|
|
149
|
+
if loop is not None and loop.is_running():
|
|
150
|
+
try:
|
|
151
|
+
running = asyncio.get_running_loop()
|
|
152
|
+
except RuntimeError:
|
|
153
|
+
running = None
|
|
154
|
+
if running is not loop:
|
|
155
|
+
loop.call_soon_threadsafe(self.secrets.clear)
|
|
156
|
+
return
|
|
157
|
+
self.secrets.clear()
|
|
158
|
+
|
|
140
159
|
def add_secret(self, name: str, value: str) -> None:
|
|
141
160
|
"""A secret read after the session started (responses are scrubbed of it from now on)."""
|
|
142
161
|
self.secrets[name] = value
|
|
@@ -147,6 +166,7 @@ class EgressProxy:
|
|
|
147
166
|
async def start(self) -> None:
|
|
148
167
|
self.socket_path.parent.mkdir(parents=True, exist_ok=True)
|
|
149
168
|
self.socket_path.unlink(missing_ok=True)
|
|
169
|
+
self._loop = asyncio.get_running_loop()
|
|
150
170
|
self._server = await asyncio.start_unix_server(self._on_stream, path=str(self.socket_path))
|
|
151
171
|
self.socket_path.chmod(0o600)
|
|
152
172
|
|
|
@@ -322,7 +342,10 @@ class EgressProxy:
|
|
|
322
342
|
|
|
323
343
|
async def _load_needed(self, host: str, method: str, path: str, headers: list[tuple[bytes, bytes]],
|
|
324
344
|
once: frozenset[str] = frozenset()) -> None:
|
|
325
|
-
"""Read from fnox the secrets this request may send that weren't read yet.
|
|
345
|
+
"""Read from fnox the secrets this request may send that weren't read yet. One locked in
|
|
346
|
+
fnox is asked about (an ``unlock`` approval: the human's master password unlocks it)."""
|
|
347
|
+
from agentd.fnox import SecretMissing
|
|
348
|
+
|
|
326
349
|
if self.load is None:
|
|
327
350
|
return
|
|
328
351
|
rules = self.policy.rules_for(host)
|
|
@@ -331,9 +354,29 @@ class EgressProxy:
|
|
|
331
354
|
for secret, ph in list(self.placeholders.items()):
|
|
332
355
|
if secret in self.secrets or not ph or ph.encode() not in value:
|
|
333
356
|
continue
|
|
334
|
-
if secret in once or any(r.secret == secret and r.header == lname and r.matches(method, path)
|
|
335
|
-
|
|
357
|
+
if not (secret in once or any(r.secret == secret and r.header == lname and r.matches(method, path)
|
|
358
|
+
for r in rules)):
|
|
359
|
+
continue
|
|
360
|
+
try:
|
|
336
361
|
await self.load(secret)
|
|
362
|
+
continue
|
|
363
|
+
except SecretMissing:
|
|
364
|
+
pass
|
|
365
|
+
detail = {"secret": secret, "host": host, "method": method, "path": path, "header": lname,
|
|
366
|
+
"locked": True}
|
|
367
|
+
message = f"agentd egress: {secret} is locked in fnox"
|
|
368
|
+
if self.ask is None:
|
|
369
|
+
raise Refused(message + " (and no approver is configured to unlock it)", detail)
|
|
370
|
+
approved, approval_id, status = await self.ask("unlock", secrets=[secret], host=host)
|
|
371
|
+
if approved:
|
|
372
|
+
try:
|
|
373
|
+
await self.load(secret)
|
|
374
|
+
continue
|
|
375
|
+
except SecretMissing:
|
|
376
|
+
pass
|
|
377
|
+
e = Refused(message, detail)
|
|
378
|
+
e.approval_id, e.approval_status = approval_id, status
|
|
379
|
+
raise e
|
|
337
380
|
|
|
338
381
|
async def inject_or_ask(self, host: str, method: str, path: str, headers: list[tuple[bytes, bytes]]):
|
|
339
382
|
"""inject(), and when refused, hold for an approval: (headers, used) or Refused (with .approval_id
|