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.
Files changed (143) hide show
  1. {agentd-0.9.2 → agentd-0.9.4}/PKG-INFO +1 -1
  2. {agentd-0.9.2 → agentd-0.9.4}/agentd/devices/browser.py +166 -19
  3. {agentd-0.9.2 → agentd-0.9.4}/agentd/egress/__init__.py +25 -3
  4. {agentd-0.9.2 → agentd-0.9.4}/agentd/egress/approvals.py +100 -18
  5. {agentd-0.9.2 → agentd-0.9.4}/agentd/egress/policy.py +7 -32
  6. {agentd-0.9.2 → agentd-0.9.4}/agentd/egress/proxy.py +46 -3
  7. agentd-0.9.4/agentd/fnox.py +212 -0
  8. {agentd-0.9.2 → agentd-0.9.4}/agentd/secrets.py +16 -18
  9. {agentd-0.9.2 → agentd-0.9.4}/agentd/serve/app.py +16 -2
  10. agentd-0.9.4/docs/browser-logins.md +259 -0
  11. {agentd-0.9.2 → agentd-0.9.4}/docs/egress-and-secrets.md +8 -4
  12. {agentd-0.9.2 → agentd-0.9.4}/pyproject.toml +1 -1
  13. agentd-0.9.4/test/fake_fnox.py +75 -0
  14. {agentd-0.9.2 → agentd-0.9.4}/test/test_approvals.py +15 -1
  15. {agentd-0.9.2 → agentd-0.9.4}/test/test_browser.py +84 -2
  16. {agentd-0.9.2 → agentd-0.9.4}/test/test_egress.py +0 -3
  17. agentd-0.9.4/test/test_fnox.py +145 -0
  18. agentd-0.9.4/test/test_unlock.py +198 -0
  19. {agentd-0.9.2 → agentd-0.9.4}/uv.lock +1 -1
  20. {agentd-0.9.2 → agentd-0.9.4}/.gitignore +0 -0
  21. {agentd-0.9.2 → agentd-0.9.4}/.python-version +0 -0
  22. {agentd-0.9.2 → agentd-0.9.4}/LICENSE +0 -0
  23. {agentd-0.9.2 → agentd-0.9.4}/README.md +0 -0
  24. {agentd-0.9.2 → agentd-0.9.4}/agentd/__init__.py +0 -0
  25. {agentd-0.9.2 → agentd-0.9.4}/agentd/app.py +0 -0
  26. {agentd-0.9.2 → agentd-0.9.4}/agentd/availability.py +0 -0
  27. {agentd-0.9.2 → agentd-0.9.4}/agentd/conversation_logger.py +0 -0
  28. {agentd-0.9.2 → agentd-0.9.4}/agentd/devices/__init__.py +0 -0
  29. {agentd-0.9.2 → agentd-0.9.4}/agentd/devices/android.py +0 -0
  30. {agentd-0.9.2 → agentd-0.9.4}/agentd/egress/ca.py +0 -0
  31. {agentd-0.9.2 → agentd-0.9.4}/agentd/egress/http2.py +0 -0
  32. {agentd-0.9.2 → agentd-0.9.4}/agentd/harness/__init__.py +0 -0
  33. {agentd-0.9.2 → agentd-0.9.4}/agentd/harness/chat.py +0 -0
  34. {agentd-0.9.2 → agentd-0.9.4}/agentd/harness/claude_cli.py +0 -0
  35. {agentd-0.9.2 → agentd-0.9.4}/agentd/harness/claude_code.py +0 -0
  36. {agentd-0.9.2 → agentd-0.9.4}/agentd/harness/codex.py +0 -0
  37. {agentd-0.9.2 → agentd-0.9.4}/agentd/harness/events.py +0 -0
  38. {agentd-0.9.2 → agentd-0.9.4}/agentd/harness/omp.py +0 -0
  39. {agentd-0.9.2 → agentd-0.9.4}/agentd/harness/opencode.py +0 -0
  40. {agentd-0.9.2 → agentd-0.9.4}/agentd/harness/responses.py +0 -0
  41. {agentd-0.9.2 → agentd-0.9.4}/agentd/harness/routes.py +0 -0
  42. {agentd-0.9.2 → agentd-0.9.4}/agentd/harness/tool_events.py +0 -0
  43. {agentd-0.9.2 → agentd-0.9.4}/agentd/harness/transcripts.py +0 -0
  44. {agentd-0.9.2 → agentd-0.9.4}/agentd/llm_dispatch.py +0 -0
  45. {agentd-0.9.2 → agentd-0.9.4}/agentd/mcp_bridge.py +0 -0
  46. {agentd-0.9.2 → agentd-0.9.4}/agentd/model/__init__.py +0 -0
  47. {agentd-0.9.2 → agentd-0.9.4}/agentd/model/config.py +0 -0
  48. {agentd-0.9.2 → agentd-0.9.4}/agentd/model_proxy.py +0 -0
  49. {agentd-0.9.2 → agentd-0.9.4}/agentd/patch.py +0 -0
  50. {agentd-0.9.2 → agentd-0.9.4}/agentd/ptc.py +0 -0
  51. {agentd-0.9.2 → agentd-0.9.4}/agentd/remote.py +0 -0
  52. {agentd-0.9.2 → agentd-0.9.4}/agentd/remote_skills.py +0 -0
  53. {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/.gitignore +0 -0
  54. {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/__init__.py +0 -0
  55. {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/base.py +0 -0
  56. {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/build.sh +0 -0
  57. {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/cli.py +0 -0
  58. {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/colima.py +0 -0
  59. {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/colima_relay.py +0 -0
  60. {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/docker.py +0 -0
  61. {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/entitlements.plist +0 -0
  62. {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/executor.py +0 -0
  63. {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/images/agents/Dockerfile +0 -0
  64. {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/krun.py +0 -0
  65. {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/launcher.c +0 -0
  66. {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/linux.py +0 -0
  67. {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/mux.py +0 -0
  68. {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/net/.gitignore +0 -0
  69. {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/net/Cargo.lock +0 -0
  70. {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/net/Cargo.toml +0 -0
  71. {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/net/src/main.rs +0 -0
  72. {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/prebuilt.py +0 -0
  73. {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/rootfs.py +0 -0
  74. {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/sandboxd.py +0 -0
  75. {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/session.py +0 -0
  76. {agentd-0.9.2 → agentd-0.9.4}/agentd/sandbox/tls.py +0 -0
  77. {agentd-0.9.2 → agentd-0.9.4}/agentd/serve/__init__.py +0 -0
  78. {agentd-0.9.2 → agentd-0.9.4}/agentd/serve/cli.py +0 -0
  79. {agentd-0.9.2 → agentd-0.9.4}/agentd/serve/config.py +0 -0
  80. {agentd-0.9.2 → agentd-0.9.4}/agentd/serve/legacy.py +0 -0
  81. {agentd-0.9.2 → agentd-0.9.4}/agentd/serve/paging.py +0 -0
  82. {agentd-0.9.2 → agentd-0.9.4}/agentd/serve/pool.py +0 -0
  83. {agentd-0.9.2 → agentd-0.9.4}/agentd/serve/schedules.py +0 -0
  84. {agentd-0.9.2 → agentd-0.9.4}/agentd/serve/store.py +0 -0
  85. {agentd-0.9.2 → agentd-0.9.4}/agentd/serve/turns.py +0 -0
  86. {agentd-0.9.2 → agentd-0.9.4}/agentd/tool_decorator.py +0 -0
  87. {agentd-0.9.2 → agentd-0.9.4}/artifacts/bin-darwin/agentd-krun +0 -0
  88. {agentd-0.9.2 → agentd-0.9.4}/artifacts/bin-darwin/agentd-net +0 -0
  89. {agentd-0.9.2 → agentd-0.9.4}/artifacts/bin-darwin/prebuilt.json +0 -0
  90. {agentd-0.9.2 → agentd-0.9.4}/artifacts/bin-linux-aarch64/agentd-krun-linux-aarch64 +0 -0
  91. {agentd-0.9.2 → agentd-0.9.4}/artifacts/bin-linux-aarch64/agentd-net-linux-aarch64 +0 -0
  92. {agentd-0.9.2 → agentd-0.9.4}/artifacts/bin-linux-aarch64/libkrun-linux-aarch64.so +0 -0
  93. {agentd-0.9.2 → agentd-0.9.4}/artifacts/bin-linux-aarch64/prebuilt.json +0 -0
  94. {agentd-0.9.2 → agentd-0.9.4}/artifacts/bin-linux-x86_64/agentd-krun-linux-x86_64 +0 -0
  95. {agentd-0.9.2 → agentd-0.9.4}/artifacts/bin-linux-x86_64/agentd-net-linux-x86_64 +0 -0
  96. {agentd-0.9.2 → agentd-0.9.4}/artifacts/bin-linux-x86_64/libkrun-linux-x86_64.so +0 -0
  97. {agentd-0.9.2 → agentd-0.9.4}/artifacts/bin-linux-x86_64/prebuilt.json +0 -0
  98. {agentd-0.9.2 → agentd-0.9.4}/config/facts.yaml +0 -0
  99. {agentd-0.9.2 → agentd-0.9.4}/config/filesystem.yaml +0 -0
  100. {agentd-0.9.2 → agentd-0.9.4}/config/ycombinator-config.yaml +0 -0
  101. {agentd-0.9.2 → agentd-0.9.4}/docs/agentd-serve.md +0 -0
  102. {agentd-0.9.2 → agentd-0.9.4}/docs/harnesses.md +0 -0
  103. {agentd-0.9.2 → agentd-0.9.4}/docs/ptc.md +0 -0
  104. {agentd-0.9.2 → agentd-0.9.4}/docs/releasing.md +0 -0
  105. {agentd-0.9.2 → agentd-0.9.4}/docs/sandboxes.md +0 -0
  106. {agentd-0.9.2 → agentd-0.9.4}/docs/tool-calling.md +0 -0
  107. {agentd-0.9.2 → agentd-0.9.4}/docs/upgrading-0.9.md +0 -0
  108. {agentd-0.9.2 → agentd-0.9.4}/examples/code_interpreter.py +0 -0
  109. {agentd-0.9.2 → agentd-0.9.4}/examples/file_retrieval_complete.py +0 -0
  110. {agentd-0.9.2 → agentd-0.9.4}/examples/ptc_streaming.py +0 -0
  111. {agentd-0.9.2 → agentd-0.9.4}/examples/ptc_with_mcp.py +0 -0
  112. {agentd-0.9.2 → agentd-0.9.4}/examples/ptc_with_tools.py +0 -0
  113. {agentd-0.9.2 → agentd-0.9.4}/examples/sample_document.txt +0 -0
  114. {agentd-0.9.2 → agentd-0.9.4}/hatch_build.py +0 -0
  115. {agentd-0.9.2 → agentd-0.9.4}/scripts/build-binaries.sh +0 -0
  116. {agentd-0.9.2 → agentd-0.9.4}/scripts/build-wheels.sh +0 -0
  117. {agentd-0.9.2 → agentd-0.9.4}/scripts/check-wheel.py +0 -0
  118. {agentd-0.9.2 → agentd-0.9.4}/test/__init__.py +0 -0
  119. {agentd-0.9.2 → agentd-0.9.4}/test/conftest.py +0 -0
  120. {agentd-0.9.2 → agentd-0.9.4}/test/test_android.py +0 -0
  121. {agentd-0.9.2 → agentd-0.9.4}/test/test_available.py +0 -0
  122. {agentd-0.9.2 → agentd-0.9.4}/test/test_colima.py +0 -0
  123. {agentd-0.9.2 → agentd-0.9.4}/test/test_compare_streaming_events.py +0 -0
  124. {agentd-0.9.2 → agentd-0.9.4}/test/test_harness.py +0 -0
  125. {agentd-0.9.2 → agentd-0.9.4}/test/test_harness_open.py +0 -0
  126. {agentd-0.9.2 → agentd-0.9.4}/test/test_harness_responses.py +0 -0
  127. {agentd-0.9.2 → agentd-0.9.4}/test/test_harness_streaming.py +0 -0
  128. {agentd-0.9.2 → agentd-0.9.4}/test/test_linux.py +0 -0
  129. {agentd-0.9.2 → agentd-0.9.4}/test/test_manual_tool_execution.py +0 -0
  130. {agentd-0.9.2 → agentd-0.9.4}/test/test_mcp_bridge.py +0 -0
  131. {agentd-0.9.2 → agentd-0.9.4}/test/test_mixed_tools.py +0 -0
  132. {agentd-0.9.2 → agentd-0.9.4}/test/test_patch.py +0 -0
  133. {agentd-0.9.2 → agentd-0.9.4}/test/test_prebuilt.py +0 -0
  134. {agentd-0.9.2 → agentd-0.9.4}/test/test_ptc.py +0 -0
  135. {agentd-0.9.2 → agentd-0.9.4}/test/test_ptc_responses.py +0 -0
  136. {agentd-0.9.2 → agentd-0.9.4}/test/test_remote.py +0 -0
  137. {agentd-0.9.2 → agentd-0.9.4}/test/test_responses.py +0 -0
  138. {agentd-0.9.2 → agentd-0.9.4}/test/test_responses_stream.py +0 -0
  139. {agentd-0.9.2 → agentd-0.9.4}/test/test_sandbox.py +0 -0
  140. {agentd-0.9.2 → agentd-0.9.4}/test/test_secrets.py +0 -0
  141. {agentd-0.9.2 → agentd-0.9.4}/test/test_serve.py +0 -0
  142. {agentd-0.9.2 → agentd-0.9.4}/test/test_transcripts.py +0 -0
  143. {agentd-0.9.2 → agentd-0.9.4}/test/test_upstream.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentd
3
- Version: 0.9.2
3
+ Version: 0.9.4
4
4
  Summary: agent daemon
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.10
@@ -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: delta.com/custlogin redirects to www.delta.com/skymiles/login.
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
- conf = self.logins[site]
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
- "secrets": [conf[k] for k in ("username", "password", "totp") if conf.get(k)]}, reason)
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
- x, y = await self._center(ref)
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 _type_into(self, ref: int, text: str) -> None:
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._type_into(user, host_secrets.secret(conf["username"], **kw))
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._type_into(password, host_secrets.secret(conf["password"], **kw))
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
- await self._type_into(code_field, totp(host_secrets.secret(conf["totp"], **kw)))
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: secret rules into the fnox config they came from, host
17
- allowances and browser logins into ``~/.agentd/egress/allow.toml``), or ``deny``.
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.egress.policy import Allow, Policy, SecretRule, add_rule_to_fnox
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 # "connect" | "secret"
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 allowances (host grants that fnox can't hold)
86
+ # Persistent grants ("always")
77
87
  # ------------------------------------------------------------------ #
78
88
 
79
- def _saved(self) -> dict[str, list[str]]:
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 _save(self, key: str, value: str) -> None:
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(f"{k} = [" + ", ".join(json.dumps(s) for s in v) + "]\n"
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
- approval = self.request(kind, egress.session, details)
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 = "") -> Approval:
144
- """A pending approval (an identical pending one is reused) announced to the webhook."""
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 decide(self, approval_id: str, decision: str, by: str = "local") -> Approval:
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
- if persist and egress.config_files:
199
- add_rule_to_fnox(egress.config_files[-1], rule)
200
- persist = False # once
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
- approval = _ACTIVE.request("secret", session, details, reason)
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
- argv = [fnox] + (["-P", profile] if profile else []) + ["config-files"]
128
- try:
129
- r = subprocess.run(argv, cwd=cwd, capture_output=True, text=True, timeout=30)
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
- argv = [fnox, "--non-interactive"] + (["-P", profile] if profile else []) + ["get", name]
180
- r = subprocess.run(argv, cwd=cwd, capture_output=True, text=True, timeout=60,
181
- env={**os.environ, "FNOX_NON_INTERACTIVE": "1"})
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]