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.
Files changed (142) hide show
  1. {agentd-0.9.2 → agentd-0.9.3}/PKG-INFO +1 -1
  2. {agentd-0.9.2 → agentd-0.9.3}/agentd/devices/browser.py +39 -3
  3. {agentd-0.9.2 → agentd-0.9.3}/agentd/egress/__init__.py +25 -3
  4. {agentd-0.9.2 → agentd-0.9.3}/agentd/egress/approvals.py +100 -18
  5. {agentd-0.9.2 → agentd-0.9.3}/agentd/egress/policy.py +7 -32
  6. {agentd-0.9.2 → agentd-0.9.3}/agentd/egress/proxy.py +46 -3
  7. agentd-0.9.3/agentd/fnox.py +212 -0
  8. {agentd-0.9.2 → agentd-0.9.3}/agentd/secrets.py +16 -18
  9. {agentd-0.9.2 → agentd-0.9.3}/agentd/serve/app.py +16 -2
  10. {agentd-0.9.2 → agentd-0.9.3}/docs/egress-and-secrets.md +7 -3
  11. {agentd-0.9.2 → agentd-0.9.3}/pyproject.toml +1 -1
  12. agentd-0.9.3/test/fake_fnox.py +75 -0
  13. {agentd-0.9.2 → agentd-0.9.3}/test/test_approvals.py +15 -1
  14. {agentd-0.9.2 → agentd-0.9.3}/test/test_browser.py +8 -2
  15. {agentd-0.9.2 → agentd-0.9.3}/test/test_egress.py +0 -3
  16. agentd-0.9.3/test/test_fnox.py +145 -0
  17. agentd-0.9.3/test/test_unlock.py +198 -0
  18. {agentd-0.9.2 → agentd-0.9.3}/uv.lock +1 -1
  19. {agentd-0.9.2 → agentd-0.9.3}/.gitignore +0 -0
  20. {agentd-0.9.2 → agentd-0.9.3}/.python-version +0 -0
  21. {agentd-0.9.2 → agentd-0.9.3}/LICENSE +0 -0
  22. {agentd-0.9.2 → agentd-0.9.3}/README.md +0 -0
  23. {agentd-0.9.2 → agentd-0.9.3}/agentd/__init__.py +0 -0
  24. {agentd-0.9.2 → agentd-0.9.3}/agentd/app.py +0 -0
  25. {agentd-0.9.2 → agentd-0.9.3}/agentd/availability.py +0 -0
  26. {agentd-0.9.2 → agentd-0.9.3}/agentd/conversation_logger.py +0 -0
  27. {agentd-0.9.2 → agentd-0.9.3}/agentd/devices/__init__.py +0 -0
  28. {agentd-0.9.2 → agentd-0.9.3}/agentd/devices/android.py +0 -0
  29. {agentd-0.9.2 → agentd-0.9.3}/agentd/egress/ca.py +0 -0
  30. {agentd-0.9.2 → agentd-0.9.3}/agentd/egress/http2.py +0 -0
  31. {agentd-0.9.2 → agentd-0.9.3}/agentd/harness/__init__.py +0 -0
  32. {agentd-0.9.2 → agentd-0.9.3}/agentd/harness/chat.py +0 -0
  33. {agentd-0.9.2 → agentd-0.9.3}/agentd/harness/claude_cli.py +0 -0
  34. {agentd-0.9.2 → agentd-0.9.3}/agentd/harness/claude_code.py +0 -0
  35. {agentd-0.9.2 → agentd-0.9.3}/agentd/harness/codex.py +0 -0
  36. {agentd-0.9.2 → agentd-0.9.3}/agentd/harness/events.py +0 -0
  37. {agentd-0.9.2 → agentd-0.9.3}/agentd/harness/omp.py +0 -0
  38. {agentd-0.9.2 → agentd-0.9.3}/agentd/harness/opencode.py +0 -0
  39. {agentd-0.9.2 → agentd-0.9.3}/agentd/harness/responses.py +0 -0
  40. {agentd-0.9.2 → agentd-0.9.3}/agentd/harness/routes.py +0 -0
  41. {agentd-0.9.2 → agentd-0.9.3}/agentd/harness/tool_events.py +0 -0
  42. {agentd-0.9.2 → agentd-0.9.3}/agentd/harness/transcripts.py +0 -0
  43. {agentd-0.9.2 → agentd-0.9.3}/agentd/llm_dispatch.py +0 -0
  44. {agentd-0.9.2 → agentd-0.9.3}/agentd/mcp_bridge.py +0 -0
  45. {agentd-0.9.2 → agentd-0.9.3}/agentd/model/__init__.py +0 -0
  46. {agentd-0.9.2 → agentd-0.9.3}/agentd/model/config.py +0 -0
  47. {agentd-0.9.2 → agentd-0.9.3}/agentd/model_proxy.py +0 -0
  48. {agentd-0.9.2 → agentd-0.9.3}/agentd/patch.py +0 -0
  49. {agentd-0.9.2 → agentd-0.9.3}/agentd/ptc.py +0 -0
  50. {agentd-0.9.2 → agentd-0.9.3}/agentd/remote.py +0 -0
  51. {agentd-0.9.2 → agentd-0.9.3}/agentd/remote_skills.py +0 -0
  52. {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/.gitignore +0 -0
  53. {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/__init__.py +0 -0
  54. {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/base.py +0 -0
  55. {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/build.sh +0 -0
  56. {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/cli.py +0 -0
  57. {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/colima.py +0 -0
  58. {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/colima_relay.py +0 -0
  59. {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/docker.py +0 -0
  60. {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/entitlements.plist +0 -0
  61. {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/executor.py +0 -0
  62. {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/images/agents/Dockerfile +0 -0
  63. {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/krun.py +0 -0
  64. {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/launcher.c +0 -0
  65. {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/linux.py +0 -0
  66. {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/mux.py +0 -0
  67. {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/net/.gitignore +0 -0
  68. {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/net/Cargo.lock +0 -0
  69. {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/net/Cargo.toml +0 -0
  70. {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/net/src/main.rs +0 -0
  71. {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/prebuilt.py +0 -0
  72. {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/rootfs.py +0 -0
  73. {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/sandboxd.py +0 -0
  74. {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/session.py +0 -0
  75. {agentd-0.9.2 → agentd-0.9.3}/agentd/sandbox/tls.py +0 -0
  76. {agentd-0.9.2 → agentd-0.9.3}/agentd/serve/__init__.py +0 -0
  77. {agentd-0.9.2 → agentd-0.9.3}/agentd/serve/cli.py +0 -0
  78. {agentd-0.9.2 → agentd-0.9.3}/agentd/serve/config.py +0 -0
  79. {agentd-0.9.2 → agentd-0.9.3}/agentd/serve/legacy.py +0 -0
  80. {agentd-0.9.2 → agentd-0.9.3}/agentd/serve/paging.py +0 -0
  81. {agentd-0.9.2 → agentd-0.9.3}/agentd/serve/pool.py +0 -0
  82. {agentd-0.9.2 → agentd-0.9.3}/agentd/serve/schedules.py +0 -0
  83. {agentd-0.9.2 → agentd-0.9.3}/agentd/serve/store.py +0 -0
  84. {agentd-0.9.2 → agentd-0.9.3}/agentd/serve/turns.py +0 -0
  85. {agentd-0.9.2 → agentd-0.9.3}/agentd/tool_decorator.py +0 -0
  86. {agentd-0.9.2 → agentd-0.9.3}/artifacts/bin-darwin/agentd-krun +0 -0
  87. {agentd-0.9.2 → agentd-0.9.3}/artifacts/bin-darwin/agentd-net +0 -0
  88. {agentd-0.9.2 → agentd-0.9.3}/artifacts/bin-darwin/prebuilt.json +0 -0
  89. {agentd-0.9.2 → agentd-0.9.3}/artifacts/bin-linux-aarch64/agentd-krun-linux-aarch64 +0 -0
  90. {agentd-0.9.2 → agentd-0.9.3}/artifacts/bin-linux-aarch64/agentd-net-linux-aarch64 +0 -0
  91. {agentd-0.9.2 → agentd-0.9.3}/artifacts/bin-linux-aarch64/libkrun-linux-aarch64.so +0 -0
  92. {agentd-0.9.2 → agentd-0.9.3}/artifacts/bin-linux-aarch64/prebuilt.json +0 -0
  93. {agentd-0.9.2 → agentd-0.9.3}/artifacts/bin-linux-x86_64/agentd-krun-linux-x86_64 +0 -0
  94. {agentd-0.9.2 → agentd-0.9.3}/artifacts/bin-linux-x86_64/agentd-net-linux-x86_64 +0 -0
  95. {agentd-0.9.2 → agentd-0.9.3}/artifacts/bin-linux-x86_64/libkrun-linux-x86_64.so +0 -0
  96. {agentd-0.9.2 → agentd-0.9.3}/artifacts/bin-linux-x86_64/prebuilt.json +0 -0
  97. {agentd-0.9.2 → agentd-0.9.3}/config/facts.yaml +0 -0
  98. {agentd-0.9.2 → agentd-0.9.3}/config/filesystem.yaml +0 -0
  99. {agentd-0.9.2 → agentd-0.9.3}/config/ycombinator-config.yaml +0 -0
  100. {agentd-0.9.2 → agentd-0.9.3}/docs/agentd-serve.md +0 -0
  101. {agentd-0.9.2 → agentd-0.9.3}/docs/harnesses.md +0 -0
  102. {agentd-0.9.2 → agentd-0.9.3}/docs/ptc.md +0 -0
  103. {agentd-0.9.2 → agentd-0.9.3}/docs/releasing.md +0 -0
  104. {agentd-0.9.2 → agentd-0.9.3}/docs/sandboxes.md +0 -0
  105. {agentd-0.9.2 → agentd-0.9.3}/docs/tool-calling.md +0 -0
  106. {agentd-0.9.2 → agentd-0.9.3}/docs/upgrading-0.9.md +0 -0
  107. {agentd-0.9.2 → agentd-0.9.3}/examples/code_interpreter.py +0 -0
  108. {agentd-0.9.2 → agentd-0.9.3}/examples/file_retrieval_complete.py +0 -0
  109. {agentd-0.9.2 → agentd-0.9.3}/examples/ptc_streaming.py +0 -0
  110. {agentd-0.9.2 → agentd-0.9.3}/examples/ptc_with_mcp.py +0 -0
  111. {agentd-0.9.2 → agentd-0.9.3}/examples/ptc_with_tools.py +0 -0
  112. {agentd-0.9.2 → agentd-0.9.3}/examples/sample_document.txt +0 -0
  113. {agentd-0.9.2 → agentd-0.9.3}/hatch_build.py +0 -0
  114. {agentd-0.9.2 → agentd-0.9.3}/scripts/build-binaries.sh +0 -0
  115. {agentd-0.9.2 → agentd-0.9.3}/scripts/build-wheels.sh +0 -0
  116. {agentd-0.9.2 → agentd-0.9.3}/scripts/check-wheel.py +0 -0
  117. {agentd-0.9.2 → agentd-0.9.3}/test/__init__.py +0 -0
  118. {agentd-0.9.2 → agentd-0.9.3}/test/conftest.py +0 -0
  119. {agentd-0.9.2 → agentd-0.9.3}/test/test_android.py +0 -0
  120. {agentd-0.9.2 → agentd-0.9.3}/test/test_available.py +0 -0
  121. {agentd-0.9.2 → agentd-0.9.3}/test/test_colima.py +0 -0
  122. {agentd-0.9.2 → agentd-0.9.3}/test/test_compare_streaming_events.py +0 -0
  123. {agentd-0.9.2 → agentd-0.9.3}/test/test_harness.py +0 -0
  124. {agentd-0.9.2 → agentd-0.9.3}/test/test_harness_open.py +0 -0
  125. {agentd-0.9.2 → agentd-0.9.3}/test/test_harness_responses.py +0 -0
  126. {agentd-0.9.2 → agentd-0.9.3}/test/test_harness_streaming.py +0 -0
  127. {agentd-0.9.2 → agentd-0.9.3}/test/test_linux.py +0 -0
  128. {agentd-0.9.2 → agentd-0.9.3}/test/test_manual_tool_execution.py +0 -0
  129. {agentd-0.9.2 → agentd-0.9.3}/test/test_mcp_bridge.py +0 -0
  130. {agentd-0.9.2 → agentd-0.9.3}/test/test_mixed_tools.py +0 -0
  131. {agentd-0.9.2 → agentd-0.9.3}/test/test_patch.py +0 -0
  132. {agentd-0.9.2 → agentd-0.9.3}/test/test_prebuilt.py +0 -0
  133. {agentd-0.9.2 → agentd-0.9.3}/test/test_ptc.py +0 -0
  134. {agentd-0.9.2 → agentd-0.9.3}/test/test_ptc_responses.py +0 -0
  135. {agentd-0.9.2 → agentd-0.9.3}/test/test_remote.py +0 -0
  136. {agentd-0.9.2 → agentd-0.9.3}/test/test_responses.py +0 -0
  137. {agentd-0.9.2 → agentd-0.9.3}/test/test_responses_stream.py +0 -0
  138. {agentd-0.9.2 → agentd-0.9.3}/test/test_sandbox.py +0 -0
  139. {agentd-0.9.2 → agentd-0.9.3}/test/test_secrets.py +0 -0
  140. {agentd-0.9.2 → agentd-0.9.3}/test/test_serve.py +0 -0
  141. {agentd-0.9.2 → agentd-0.9.3}/test/test_transcripts.py +0 -0
  142. {agentd-0.9.2 → agentd-0.9.3}/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.3
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 = (
@@ -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
- conf = self.logins[site]
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
- "secrets": [conf[k] for k in ("username", "password", "totp") if conf.get(k)]}, reason)
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: 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]
@@ -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
- for r in rules):
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