agentd 0.9.2__tar.gz → 0.9.4__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.4}/PKG-INFO +1 -1
- {agentd-0.9.2 → agentd-0.9.4}/agentd/devices/browser.py +166 -19
- {agentd-0.9.2 → agentd-0.9.4}/agentd/egress/__init__.py +25 -3
- {agentd-0.9.2 → agentd-0.9.4}/agentd/egress/approvals.py +100 -18
- {agentd-0.9.2 → agentd-0.9.4}/agentd/egress/policy.py +7 -32
- {agentd-0.9.2 → agentd-0.9.4}/agentd/egress/proxy.py +46 -3
- agentd-0.9.4/agentd/fnox.py +212 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/secrets.py +16 -18
- {agentd-0.9.2 → agentd-0.9.4}/agentd/serve/app.py +16 -2
- agentd-0.9.4/docs/browser-logins.md +259 -0
- {agentd-0.9.2 → agentd-0.9.4}/docs/egress-and-secrets.md +8 -4
- {agentd-0.9.2 → agentd-0.9.4}/pyproject.toml +1 -1
- agentd-0.9.4/test/fake_fnox.py +75 -0
- {agentd-0.9.2 → agentd-0.9.4}/test/test_approvals.py +15 -1
- {agentd-0.9.2 → agentd-0.9.4}/test/test_browser.py +84 -2
- {agentd-0.9.2 → agentd-0.9.4}/test/test_egress.py +0 -3
- agentd-0.9.4/test/test_fnox.py +145 -0
- agentd-0.9.4/test/test_unlock.py +198 -0
- {agentd-0.9.2 → agentd-0.9.4}/uv.lock +1 -1
- {agentd-0.9.2 → agentd-0.9.4}/.gitignore +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/.python-version +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/LICENSE +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/README.md +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/__init__.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/app.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/availability.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/conversation_logger.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/devices/__init__.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/devices/android.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/egress/ca.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/egress/http2.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/harness/__init__.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/harness/chat.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/harness/claude_cli.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/harness/claude_code.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/harness/codex.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/harness/events.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/harness/omp.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/harness/opencode.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/harness/responses.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/harness/routes.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/harness/tool_events.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/harness/transcripts.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/llm_dispatch.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/mcp_bridge.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/model/__init__.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/model/config.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/model_proxy.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/patch.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/ptc.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/remote.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/remote_skills.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/.gitignore +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/__init__.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/base.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/build.sh +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/cli.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/colima.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/colima_relay.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/docker.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/entitlements.plist +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/executor.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/images/agents/Dockerfile +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/krun.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/launcher.c +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/linux.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/mux.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/net/.gitignore +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/net/Cargo.lock +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/net/Cargo.toml +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/net/src/main.rs +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/prebuilt.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/rootfs.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/sandboxd.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/session.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/tls.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/serve/__init__.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/serve/cli.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/serve/config.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/serve/legacy.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/serve/paging.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/serve/pool.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/serve/schedules.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/serve/store.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/serve/turns.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/agentd/tool_decorator.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/artifacts/bin-darwin/agentd-krun +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/artifacts/bin-darwin/agentd-net +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/artifacts/bin-darwin/prebuilt.json +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/artifacts/bin-linux-aarch64/agentd-krun-linux-aarch64 +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/artifacts/bin-linux-aarch64/agentd-net-linux-aarch64 +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/artifacts/bin-linux-aarch64/libkrun-linux-aarch64.so +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/artifacts/bin-linux-aarch64/prebuilt.json +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/artifacts/bin-linux-x86_64/agentd-krun-linux-x86_64 +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/artifacts/bin-linux-x86_64/agentd-net-linux-x86_64 +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/artifacts/bin-linux-x86_64/libkrun-linux-x86_64.so +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/artifacts/bin-linux-x86_64/prebuilt.json +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/config/facts.yaml +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/config/filesystem.yaml +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/config/ycombinator-config.yaml +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/docs/agentd-serve.md +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/docs/harnesses.md +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/docs/ptc.md +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/docs/releasing.md +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/docs/sandboxes.md +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/docs/tool-calling.md +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/docs/upgrading-0.9.md +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/examples/code_interpreter.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/examples/file_retrieval_complete.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/examples/ptc_streaming.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/examples/ptc_with_mcp.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/examples/ptc_with_tools.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/examples/sample_document.txt +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/hatch_build.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/scripts/build-binaries.sh +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/scripts/build-wheels.sh +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/scripts/check-wheel.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/test/__init__.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/test/conftest.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/test/test_android.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/test/test_available.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/test/test_colima.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/test/test_compare_streaming_events.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/test/test_harness.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/test/test_harness_open.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/test/test_harness_responses.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/test/test_harness_streaming.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/test/test_linux.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/test/test_manual_tool_execution.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/test/test_mcp_bridge.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/test/test_mixed_tools.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/test/test_patch.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/test/test_prebuilt.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/test/test_ptc.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/test/test_ptc_responses.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/test/test_remote.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/test/test_responses.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/test/test_responses_stream.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/test/test_sandbox.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/test/test_secrets.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/test/test_serve.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/test/test_transcripts.py +0 -0
- {agentd-0.9.2 → agentd-0.9.4}/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 = (
|
|
@@ -64,6 +65,34 @@ CHROME_PATHS = (
|
|
|
64
65
|
STEALTH_FLAGS = ("--disable-blink-features=AutomationControlled", "--no-first-run",
|
|
65
66
|
"--no-default-browser-check", "--disable-features=Translate", "--window-size=1280,900")
|
|
66
67
|
LOGIN_FORM_WAIT = 10.0 # seconds browser_login waits for a password field to appear
|
|
68
|
+
_AUTO_ATTACH = {"autoAttach": True, "waitForDebuggerOnStart": True, "flatten": True}
|
|
69
|
+
_POLICED_TARGETS = ("page", "iframe", "worker", "shared_worker", "service_worker")
|
|
70
|
+
|
|
71
|
+
# "Accept" buttons of common cookie-consent banners, clicked when a page opens and before
|
|
72
|
+
# a login (they steal focus and cover forms). Accepting is fine: every session's profile,
|
|
73
|
+
# cookies included, is deleted when it ends.
|
|
74
|
+
CONSENT_ACCEPT = (
|
|
75
|
+
"#onetrust-accept-btn-handler", # OneTrust ("I understand", "Accept all")
|
|
76
|
+
"#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll", # Cookiebot
|
|
77
|
+
"#CybotCookiebotDialogBodyButtonAccept",
|
|
78
|
+
"#truste-consent-button", # TrustArc
|
|
79
|
+
"#didomi-notice-agree-button", # Didomi
|
|
80
|
+
".osano-cm-accept-all", # Osano
|
|
81
|
+
".cky-btn-accept", # CookieYes
|
|
82
|
+
".cmplz-btn.cmplz-accept", # Complianz
|
|
83
|
+
"[data-testid='uc-accept-all-button']", # Usercentrics
|
|
84
|
+
".fc-cta-consent", # Google consent (Funding Choices)
|
|
85
|
+
".cc-window .cc-allow, .cc-window .cc-dismiss", # Insites Cookie Consent
|
|
86
|
+
)
|
|
87
|
+
_CONSENT_JS = """(() => {
|
|
88
|
+
const roots = [document, ...[...document.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => e.shadowRoot)];
|
|
89
|
+
for (const sel of %s) for (const root of roots) for (const el of root.querySelectorAll(sel)) {
|
|
90
|
+
const r = el.getBoundingClientRect(), st = getComputedStyle(el);
|
|
91
|
+
if (r.width > 2 && r.height > 2 && st.visibility !== 'hidden' && st.display !== 'none')
|
|
92
|
+
return {x: r.left + r.width / 2, y: r.top + r.height / 2, sel};
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
})()""" % json.dumps(list(CONSENT_ACCEPT))
|
|
67
96
|
|
|
68
97
|
# Read the page from an isolated world: interactive elements with what a person sees.
|
|
69
98
|
_SNAPSHOT_JS = r"""
|
|
@@ -197,6 +226,7 @@ class Browser:
|
|
|
197
226
|
_session: str | None = field(default=None, repr=False)
|
|
198
227
|
_profile: Path | None = field(default=None, repr=False)
|
|
199
228
|
_pending: str | None = field(default=None, repr=False)
|
|
229
|
+
_page_target: str | None = field(default=None, repr=False)
|
|
200
230
|
_login_pending: dict[str, str] = field(default_factory=dict, repr=False) # site -> approval id
|
|
201
231
|
_login_grants: set[str] = field(default_factory=set, repr=False) # sites approved for this session
|
|
202
232
|
_elements: list[int] = field(default_factory=list, repr=False) # ref -> backendNodeId
|
|
@@ -243,7 +273,8 @@ class Browser:
|
|
|
243
273
|
host = (urlsplit(conf["url"]).hostname or "").lower() if conf.get("url") else ""
|
|
244
274
|
if not host:
|
|
245
275
|
raise ValueError(f"login {site!r} needs a url or hosts (where its credentials may be typed)")
|
|
246
|
-
# The url's site and its subdomains
|
|
276
|
+
# The url's site and its subdomains, so a saved example.com/login that redirects to
|
|
277
|
+
# www.example.com/signin still counts.
|
|
247
278
|
base = host[4:] if host.startswith("www.") else host
|
|
248
279
|
return [base, f"*.{base}"]
|
|
249
280
|
|
|
@@ -260,13 +291,47 @@ class Browser:
|
|
|
260
291
|
return {"status": "allowed"}
|
|
261
292
|
if self.approvals is None:
|
|
262
293
|
raise BrowserAccessError("logins need an approver (none is configured)")
|
|
263
|
-
|
|
294
|
+
names = self._login_secrets(site)
|
|
295
|
+
missing = self._uncached(names) # locked in fnox: approving will need the master password
|
|
264
296
|
approval = self.approvals.request("browser_login", "", {
|
|
265
|
-
"site": site, "host": hosts[0], "hosts": hosts,
|
|
266
|
-
|
|
297
|
+
"site": site, "host": hosts[0], "hosts": hosts, "secrets": names}, reason,
|
|
298
|
+
unlock=(missing, self._fill) if missing else None)
|
|
267
299
|
self._login_pending[site] = approval.id
|
|
268
300
|
return {"id": approval.id, "status": approval.status}
|
|
269
301
|
|
|
302
|
+
def _login_secrets(self, site: str) -> list[str]:
|
|
303
|
+
conf = self.logins[site]
|
|
304
|
+
return [conf[k] for k in ("username", "password", "totp") if conf.get(k)]
|
|
305
|
+
|
|
306
|
+
def _fnox_dir(self) -> Path:
|
|
307
|
+
return Path(self.fnox_cwd or os.getcwd()).resolve()
|
|
308
|
+
|
|
309
|
+
def _uncached(self, names: list[str]) -> list[str]:
|
|
310
|
+
return fnox.uncached(names, self._fnox_dir())
|
|
311
|
+
|
|
312
|
+
def _fill(self, names: list[str], password: bytearray) -> dict[str, str]:
|
|
313
|
+
return fnox.fill(names, password, cwd=self._fnox_dir())
|
|
314
|
+
|
|
315
|
+
async def _ensure_unlocked(self, site: str) -> None:
|
|
316
|
+
"""A login approved earlier (always, session, by host code) whose secrets are locked in
|
|
317
|
+
fnox asks for an unlock (the human's master password) and waits for it."""
|
|
318
|
+
missing = await asyncio.to_thread(self._uncached, self._login_secrets(site))
|
|
319
|
+
if not missing:
|
|
320
|
+
return
|
|
321
|
+
if self.approvals is None:
|
|
322
|
+
raise BrowserAccessError(f"{site}'s credentials ({', '.join(missing)}) are locked in fnox, "
|
|
323
|
+
"and no approver is configured to unlock them")
|
|
324
|
+
approval = self.approvals.request("unlock", "", {"site": site, "host": self._login_hosts(site)[0],
|
|
325
|
+
"secrets": missing}, f"log in to {site}",
|
|
326
|
+
unlock=(missing, self._fill))
|
|
327
|
+
status = await self.approvals.wait(approval.id)
|
|
328
|
+
if status not in ("once", "session", "always"):
|
|
329
|
+
raise BrowserAccessError(f"{site}'s credentials are locked in fnox: unlock approval {approval.id} is "
|
|
330
|
+
f"{status}" + ("" if status == "deny" else "; call browser_login again once "
|
|
331
|
+
"it's approved"))
|
|
332
|
+
if await asyncio.to_thread(self._uncached, missing):
|
|
333
|
+
raise BrowserAccessError(f"{site}'s credentials are still locked in fnox")
|
|
334
|
+
|
|
270
335
|
def _use_login(self, site: str, spend: bool = True) -> None:
|
|
271
336
|
"""Raise unless ``site``'s login is approved; a 'once' approval is spent here (with ``spend``)."""
|
|
272
337
|
if self._login_granted(site):
|
|
@@ -327,11 +392,34 @@ class Browser:
|
|
|
327
392
|
page = next((t for t in targets if t["type"] == "page"), None)
|
|
328
393
|
if page is None:
|
|
329
394
|
page = {"targetId": (await self._pipe.send("Target.createTarget", {"url": "about:blank"}))["targetId"]}
|
|
395
|
+
self._page_target = page["targetId"]
|
|
330
396
|
self._session = (await self._pipe.send("Target.attachToTarget",
|
|
331
397
|
{"targetId": page["targetId"], "flatten": True}))["sessionId"]
|
|
332
398
|
await self._send("Page.enable") # lifecycle only; never Runtime.enable
|
|
333
|
-
await self._send("Fetch.enable", {"patterns": [{"urlPattern": "*", "requestStage": "Request"}]})
|
|
334
399
|
self._pipe.handlers["Fetch.requestPaused"] = self._on_request
|
|
400
|
+
self._pipe.handlers["Target.attachedToTarget"] = self._on_attached
|
|
401
|
+
await self._send("Fetch.enable", {"patterns": [{"urlPattern": "*", "requestStage": "Request"}]})
|
|
402
|
+
# Every other target (new tabs and popups, iframes in their own process, workers) is held
|
|
403
|
+
# at start until its requests are intercepted too, so none gets past the allowlist.
|
|
404
|
+
await self._pipe.send("Target.setAutoAttach", _AUTO_ATTACH)
|
|
405
|
+
await self._send("Target.setAutoAttach", _AUTO_ATTACH)
|
|
406
|
+
|
|
407
|
+
async def _on_attached(self, params: dict, session: str | None) -> None:
|
|
408
|
+
info, child = params.get("targetInfo", {}), params.get("sessionId")
|
|
409
|
+
try:
|
|
410
|
+
if info.get("targetId") != self._page_target and info.get("type") in _POLICED_TARGETS:
|
|
411
|
+
await self._pipe.send("Fetch.enable", {"patterns": [{"urlPattern": "*", "requestStage": "Request"}]},
|
|
412
|
+
child)
|
|
413
|
+
if info.get("type") in ("page", "iframe"):
|
|
414
|
+
await self._pipe.send("Target.setAutoAttach", _AUTO_ATTACH, child)
|
|
415
|
+
except (RuntimeError, ConnectionError, asyncio.TimeoutError):
|
|
416
|
+
pass
|
|
417
|
+
finally:
|
|
418
|
+
if params.get("waitingForDebugger"):
|
|
419
|
+
try:
|
|
420
|
+
await self._pipe.send("Runtime.runIfWaitingForDebugger", {}, child)
|
|
421
|
+
except (RuntimeError, ConnectionError, asyncio.TimeoutError):
|
|
422
|
+
pass
|
|
335
423
|
|
|
336
424
|
async def _send(self, method: str, params: dict | None = None, timeout: float = 30) -> dict:
|
|
337
425
|
assert self._pipe is not None
|
|
@@ -405,8 +493,32 @@ class Browser:
|
|
|
405
493
|
if state.get("result", {}).get("value") == "complete":
|
|
406
494
|
break
|
|
407
495
|
await asyncio.sleep(0.25)
|
|
496
|
+
await self._dismiss_consent()
|
|
408
497
|
return await self.snapshot()
|
|
409
498
|
|
|
499
|
+
async def _dismiss_consent(self, rounds: int = 2) -> list[str]:
|
|
500
|
+
"""Click a known cookie-consent banner's accept button (real mouse events), up to ``rounds``
|
|
501
|
+
times for two-step banners. Returns the selectors clicked."""
|
|
502
|
+
clicked = []
|
|
503
|
+
for _ in range(rounds):
|
|
504
|
+
hit = (await self._isolated(_CONSENT_JS))["result"].get("value")
|
|
505
|
+
if not hit:
|
|
506
|
+
break
|
|
507
|
+
await self._click_at(hit["x"], hit["y"])
|
|
508
|
+
clicked.append(hit["sel"])
|
|
509
|
+
await asyncio.sleep(0.6)
|
|
510
|
+
return clicked
|
|
511
|
+
|
|
512
|
+
async def _click_at(self, x: float, y: float) -> None:
|
|
513
|
+
x += random.uniform(-2, 2)
|
|
514
|
+
y += random.uniform(-2, 2)
|
|
515
|
+
await self._send("Input.dispatchMouseEvent", {"type": "mouseMoved", "x": x, "y": y})
|
|
516
|
+
await asyncio.sleep(random.uniform(0.05, 0.15))
|
|
517
|
+
for kind in ("mousePressed", "mouseReleased"):
|
|
518
|
+
await self._send("Input.dispatchMouseEvent", {"type": kind, "x": x, "y": y, "button": "left",
|
|
519
|
+
"clickCount": 1})
|
|
520
|
+
await asyncio.sleep(random.uniform(0.04, 0.12))
|
|
521
|
+
|
|
410
522
|
async def snapshot(self) -> dict[str, Any]:
|
|
411
523
|
"""The page: URL, title, visible text (trimmed) and numbered interactive elements."""
|
|
412
524
|
await self._ready()
|
|
@@ -439,25 +551,55 @@ class Browser:
|
|
|
439
551
|
|
|
440
552
|
async def click(self, ref: int) -> dict[str, Any]:
|
|
441
553
|
await self._ready()
|
|
442
|
-
|
|
443
|
-
x += random.uniform(-2, 2)
|
|
444
|
-
y += random.uniform(-2, 2)
|
|
445
|
-
await self._send("Input.dispatchMouseEvent", {"type": "mouseMoved", "x": x, "y": y})
|
|
446
|
-
await asyncio.sleep(random.uniform(0.05, 0.15))
|
|
447
|
-
for kind in ("mousePressed", "mouseReleased"):
|
|
448
|
-
await self._send("Input.dispatchMouseEvent", {"type": kind, "x": x, "y": y, "button": "left",
|
|
449
|
-
"clickCount": 1})
|
|
450
|
-
await asyncio.sleep(random.uniform(0.04, 0.12))
|
|
554
|
+
await self._click_at(*await self._center(ref))
|
|
451
555
|
await asyncio.sleep(0.5)
|
|
452
556
|
return await self.snapshot()
|
|
453
557
|
|
|
454
|
-
async def
|
|
558
|
+
async def _node(self, ref: int) -> str:
|
|
559
|
+
"""A handle on element ``ref`` in agentd's isolated world (the page's scripts can't see our calls)."""
|
|
560
|
+
tree = await self._send("Page.getFrameTree")
|
|
561
|
+
world = await self._send("Page.createIsolatedWorld", {"frameId": tree["frameTree"]["frame"]["id"],
|
|
562
|
+
"worldName": "agentd"})
|
|
563
|
+
obj = await self._send("DOM.resolveNode", {"backendNodeId": self._elements[ref],
|
|
564
|
+
"executionContextId": world["executionContextId"]})
|
|
565
|
+
return obj["object"]["objectId"]
|
|
566
|
+
|
|
567
|
+
async def _on(self, handle: str, function: str) -> Any:
|
|
568
|
+
r = await self._send("Runtime.callFunctionOn", {"objectId": handle, "functionDeclaration": function,
|
|
569
|
+
"returnByValue": True})
|
|
570
|
+
return r.get("result", {}).get("value")
|
|
571
|
+
|
|
572
|
+
async def _type_into(self, ref: int, text: str, *, clear: bool = False) -> None:
|
|
573
|
+
"""Type ``text`` into element ``ref``. Before each character, focus goes back to it if
|
|
574
|
+
the page moved it (a cookie banner, a chat widget), so no keystroke lands elsewhere."""
|
|
455
575
|
node = self._elements[ref]
|
|
576
|
+
handle = await self._node(ref)
|
|
577
|
+
focused = "function() { const r = this.getRootNode(); return (r.activeElement || document.activeElement) === this; }"
|
|
456
578
|
await self._send("DOM.focus", {"backendNodeId": node})
|
|
579
|
+
if clear: # select what's there and delete it with a real key press (frameworks see the change)
|
|
580
|
+
await self._on(handle, "function() { if (this.select) this.select(); else document.execCommand('selectAll'); }")
|
|
581
|
+
for kind in ("keyDown", "keyUp"):
|
|
582
|
+
await self._send("Input.dispatchKeyEvent", {"type": kind, "key": "Backspace", "code": "Backspace",
|
|
583
|
+
"windowsVirtualKeyCode": 8})
|
|
457
584
|
for ch in text:
|
|
585
|
+
if not await self._on(handle, focused):
|
|
586
|
+
await self._send("DOM.focus", {"backendNodeId": node})
|
|
458
587
|
await self._send("Input.dispatchKeyEvent", {"type": "char", "text": ch})
|
|
459
588
|
await asyncio.sleep(random.uniform(0.03, 0.09))
|
|
460
589
|
|
|
590
|
+
async def _fill_field(self, ref: int, text: str) -> None:
|
|
591
|
+
"""Type a credential into ``ref``, verify the box got every character (by length only: the
|
|
592
|
+
value is never read back), retype once if not, else fail rather than submit."""
|
|
593
|
+
await self._dismiss_consent(rounds=1) # banners often appear while the form is being filled
|
|
594
|
+
handle = await self._node(ref)
|
|
595
|
+
length = "function() { return (this.value !== undefined ? this.value : this.textContent || '').length; }"
|
|
596
|
+
for attempt in range(2):
|
|
597
|
+
await self._type_into(ref, text, clear=True)
|
|
598
|
+
if (await self._on(handle, length) or 0) >= len(text): # >=: some fields format what's typed
|
|
599
|
+
return
|
|
600
|
+
raise BrowserAccessError("the page took focus while typing (or the field doesn't take the whole "
|
|
601
|
+
"credential): not submitting")
|
|
602
|
+
|
|
461
603
|
async def type(self, ref: int, text: str, submit: bool = False) -> dict[str, Any]:
|
|
462
604
|
await self._ready()
|
|
463
605
|
if not 0 <= ref < len(self._elements):
|
|
@@ -486,6 +628,7 @@ class Browser:
|
|
|
486
628
|
"""Fill and submit a site's login form from fnox, on the host (only on its login hosts)."""
|
|
487
629
|
await self._ready()
|
|
488
630
|
self._use_login(site, spend=False) # a 'once' is spent only when credentials are typed
|
|
631
|
+
await self._ensure_unlocked(site)
|
|
489
632
|
conf = self.logins[site]
|
|
490
633
|
kw = {"cwd": self.fnox_cwd} if self.fnox_cwd else {}
|
|
491
634
|
# Already on the site's login form (the agent got there itself): stay. Otherwise
|
|
@@ -494,9 +637,11 @@ class Browser:
|
|
|
494
637
|
and any(e.get("password") for e in (await self.snapshot())["elements"])):
|
|
495
638
|
await self.open(conf["url"])
|
|
496
639
|
await self._on_login_host(site)
|
|
497
|
-
# Many login pages build the form with JavaScript after the load: wait for it.
|
|
640
|
+
# Many login pages build the form with JavaScript after the load: wait for it. Consent
|
|
641
|
+
# banners (which cover the form or grab focus mid-typing) often load late too.
|
|
498
642
|
deadline = time.monotonic() + LOGIN_FORM_WAIT
|
|
499
643
|
while True:
|
|
644
|
+
await self._dismiss_consent(rounds=1)
|
|
500
645
|
page = await self.snapshot()
|
|
501
646
|
elements = page["elements"]
|
|
502
647
|
password = next((e["ref"] for e in elements if e.get("password")), None)
|
|
@@ -508,9 +653,9 @@ class Browser:
|
|
|
508
653
|
user = next((e["ref"] for e in reversed(elements[:password]) if e.get("tag") == "input"
|
|
509
654
|
and e.get("type") in (None, "text", "email", "tel")), None)
|
|
510
655
|
if user is not None and conf.get("username"):
|
|
511
|
-
await self.
|
|
656
|
+
await self._fill_field(user, host_secrets.secret(conf["username"], **kw))
|
|
512
657
|
await self._on_login_host(site) # still there, right before the password
|
|
513
|
-
await self.
|
|
658
|
+
await self._fill_field(password, host_secrets.secret(conf["password"], **kw))
|
|
514
659
|
self._use_login(site) # credentials typed: a 'once' approval is now used
|
|
515
660
|
await self._press_enter()
|
|
516
661
|
await asyncio.sleep(1.5)
|
|
@@ -520,7 +665,9 @@ class Browser:
|
|
|
520
665
|
code_field = next((e["ref"] for e in page["elements"] if e.get("tag") == "input"
|
|
521
666
|
and e.get("type") in (None, "text", "number", "tel")), None)
|
|
522
667
|
if code_field is not None:
|
|
523
|
-
|
|
668
|
+
code = totp(host_secrets.secret(conf["totp"], **kw))
|
|
669
|
+
host_secrets.remember(code) # derived, not read from fnox: scrub it from snapshots too
|
|
670
|
+
await self._fill_field(code_field, code)
|
|
524
671
|
await self._press_enter()
|
|
525
672
|
await asyncio.sleep(1.5)
|
|
526
673
|
return await self.snapshot()
|
|
@@ -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]
|