foldyard 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. foldyard/__init__.py +11 -0
  2. foldyard/__main__.py +9 -0
  3. foldyard/allowlist.py +470 -0
  4. foldyard/assets/box/Dockerfile +39 -0
  5. foldyard/assets/box/git-index-shim.sh +285 -0
  6. foldyard/assets/docs/adrs/0001-rootless-podman-vm-isolation-boundary.md +96 -0
  7. foldyard/assets/docs/adrs/0002-stack-colocation-project-as-isolation-unit.md +86 -0
  8. foldyard/assets/docs/adrs/0003-name-foldyard.md +69 -0
  9. foldyard/assets/docs/adrs/0004-one-machine-worktrees-as-compose-projects.md +84 -0
  10. foldyard/assets/docs/adrs/0005-secretless-by-default-posture-axes.md +78 -0
  11. foldyard/assets/docs/adrs/0006-host-side-enforcement-single-supervisor.md +82 -0
  12. foldyard/assets/docs/adrs/0007-credential-injection-at-egress-proxy.md +86 -0
  13. foldyard/assets/docs/adrs/0008-keyless-agent-auth.md +85 -0
  14. foldyard/assets/docs/adrs/0009-monitoring-cooperative-enforcement-locked.md +95 -0
  15. foldyard/assets/docs/adrs/0010-podman-everywhere-container-host.md +87 -0
  16. foldyard/assets/docs/adrs/0011-machine-backends-one-socket-contract.md +140 -0
  17. foldyard/assets/docs/adrs/0012-uv-tool-distribution-no-mutable-daemon-source.md +94 -0
  18. foldyard/assets/docs/adrs/0013-in-repo-carve-out-until-extraction.md +79 -0
  19. foldyard/assets/docs/adrs/0014-consumer-supplied-box-image.md +117 -0
  20. foldyard/assets/docs/adrs/0015-plugin-framework-per-consumer-registry.md +89 -0
  21. foldyard/assets/docs/adrs/0016-per-worktree-posture.md +91 -0
  22. foldyard/assets/docs/adrs/0017-nested-virt-validation-strategy.md +87 -0
  23. foldyard/assets/docs/adrs/0018-zed-editor-rejected.md +47 -0
  24. foldyard/assets/docs/adrs/0019-consumer-repo-plugins-trust-model.md +92 -0
  25. foldyard/assets/docs/adrs/0020-post-extraction-consumption-model.md +131 -0
  26. foldyard/assets/docs/adrs/0021-per-kernel-git-index-split.md +105 -0
  27. foldyard/assets/docs/adrs/0022-host-runs-the-adopted-config.md +140 -0
  28. foldyard/assets/docs/adrs/0023-no-host-executed-code-from-the-repo-mount.md +164 -0
  29. foldyard/assets/docs/adrs/0024-declarative-consumer-axes-no-repo-path-plugins.md +122 -0
  30. foldyard/assets/docs/adrs/README.md +46 -0
  31. foldyard/assets/docs/compose-overlays.md +88 -0
  32. foldyard/assets/docs/configuration.md +713 -0
  33. foldyard/assets/docs/lima-backend-scope.md +63 -0
  34. foldyard/assets/docs/modes.md +194 -0
  35. foldyard/assets/docs/nested-virt.md +179 -0
  36. foldyard/assets/docs/networking.md +112 -0
  37. foldyard/assets/docs/quickstart.md +132 -0
  38. foldyard/assets/docs/security.md +184 -0
  39. foldyard/assets/docs/testing-modes.md +86 -0
  40. foldyard/assets/machine-wall/machine-wall.sh +235 -0
  41. foldyard/assets/proxy/egress_proxy.py +743 -0
  42. foldyard/assets/skills/bootstrap-devbox/SKILL.md +134 -0
  43. foldyard/assets/skills/foldyard/SKILL.md +84 -0
  44. foldyard/assets/skills/foldyard/references/egress-and-capture.md +50 -0
  45. foldyard/assets/skills/foldyard/references/posture-and-credentials.md +54 -0
  46. foldyard/assets/skills/foldyard/references/when-a-change-doesnt-take.md +32 -0
  47. foldyard/box.py +1112 -0
  48. foldyard/browser.py +40 -0
  49. foldyard/cli.py +816 -0
  50. foldyard/config.py +1557 -0
  51. foldyard/configpin.py +868 -0
  52. foldyard/devmode.py +1484 -0
  53. foldyard/docs.py +129 -0
  54. foldyard/exposure.py +483 -0
  55. foldyard/githeal.py +240 -0
  56. foldyard/init.py +486 -0
  57. foldyard/keyless.py +371 -0
  58. foldyard/machine.py +480 -0
  59. foldyard/machine_backend.py +649 -0
  60. foldyard/plugins/__init__.py +898 -0
  61. foldyard/plugins/_passthrough_bundles.py +246 -0
  62. foldyard/plugins/auth0_sim.py +157 -0
  63. foldyard/plugins/claude.py +187 -0
  64. foldyard/plugins/codex.py +242 -0
  65. foldyard/plugins/codex_chatgpt_token.py +187 -0
  66. foldyard/plugins/fakecred.py +121 -0
  67. foldyard/plugins/fakecred_minter.py +64 -0
  68. foldyard/plugins/gcp.py +639 -0
  69. foldyard/plugins/gcp_metadata/README.md +104 -0
  70. foldyard/plugins/gcp_metadata/__init__.py +11 -0
  71. foldyard/plugins/gcp_metadata/minter.py +254 -0
  72. foldyard/plugins/gcp_metadata/server.py +260 -0
  73. foldyard/plugins/gh_cli_token.py +53 -0
  74. foldyard/plugins/github.py +482 -0
  75. foldyard/plugins/github_app_token.py +288 -0
  76. foldyard/plugins/inject.py +162 -0
  77. foldyard/plugins/llm.py +65 -0
  78. foldyard/plugins/proxy.py +625 -0
  79. foldyard/plugins/static_token.py +56 -0
  80. foldyard/plugins/vscode.py +25 -0
  81. foldyard/ports.py +85 -0
  82. foldyard/preflight.py +166 -0
  83. foldyard/reconcile.py +276 -0
  84. foldyard/skills.py +76 -0
  85. foldyard/stack.py +1465 -0
  86. foldyard/state_view.py +38 -0
  87. foldyard/supervisor.py +1208 -0
  88. foldyard/term.py +127 -0
  89. foldyard/transcripts.py +400 -0
  90. foldyard/tui.py +1983 -0
  91. foldyard/verify.py +279 -0
  92. foldyard/vscode.py +601 -0
  93. foldyard/worktree.py +512 -0
  94. foldyard-0.0.1.dist-info/METADATA +168 -0
  95. foldyard-0.0.1.dist-info/RECORD +99 -0
  96. foldyard-0.0.1.dist-info/WHEEL +4 -0
  97. foldyard-0.0.1.dist-info/entry_points.txt +3 -0
  98. foldyard-0.0.1.dist-info/licenses/LICENSE +202 -0
  99. foldyard-0.0.1.dist-info/licenses/NOTICE +6 -0
foldyard/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """foldyard — stack-colocated, secretless-by-default, laptop-local dev environment.
2
+
3
+ The unit of isolation is the project's whole dev stack, not an agent process. The design
4
+ decisions and their reasoning are in ``docs/adrs/``.
5
+
6
+ Keep this module import-light: the recipe hot path runs ``python3 -m foldyard mode
7
+ env`` under a plain system python3, so importing the package must not pull Textual
8
+ or any non-stdlib dependency (the CLI imports those lazily, per-verb).
9
+ """
10
+
11
+ __version__ = "0.0.1"
foldyard/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ """`python -m foldyard` → the CLI. Used by the recipe hot path under a plain system
2
+ python3 (no venv): `PYTHONPATH=foldyard/src python3 -m foldyard mode env`."""
3
+
4
+ import sys
5
+
6
+ from .cli import main
7
+
8
+ if __name__ == "__main__":
9
+ sys.exit(main())
foldyard/allowlist.py ADDED
@@ -0,0 +1,470 @@
1
+ #!/usr/bin/env python3
2
+ """Egress allow-store — the live, leveled allowlist behind the Network Log "allow this host" UX.
3
+
4
+ Companion to :mod:`foldyard.devmode`. When ``[proxy] default_deny`` is on, the egress proxy refuses
5
+ any host that isn't allowed. A host can be allowed at three levels:
6
+
7
+ once a short TTL (default 2 min), then auto-reverts — "let this one through, I'm watching"
8
+ session until the host supervisor restarts — "don't ask again this task"
9
+ permanent no expiry; survives a supervisor restart
10
+
11
+ Enforcement itself (``default_deny``) is host-owned too — see :func:`default_deny`.
12
+
13
+ EVERY level lives in one authoritative store in the Mac home (``allow-store.json``, OUTSIDE the repo
14
+ mount), so **nothing in the box can grant its own egress**. That placement is the whole
15
+ guarantee, so
16
+ permanent grants live there too rather than in the repo's ``foldyard.toml``: config travels with the
17
+ branch, and a store the box can edit is not a store — it read as "team-shared" but meant "the box
18
+ widens its own wall by writing a file it already owns". A grant is a property of an operator on a
19
+ host, like the posture itself and like the trust the mode system already keeps out of the mount.
20
+
21
+ The proxy daemon (a standalone mitmproxy addon that can't import foldyard) re-reads a resolved
22
+ *effective* file (``allow-effective.json``) per request, so grants take effect live with no daemon
23
+ restart. The host writes that file on every grant and on the supervisor's expiry sweep.
24
+
25
+ Stdlib only. Grants are Mac-only (the box must not escalate its own posture).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import os
32
+ import sys
33
+ import tempfile
34
+ from collections.abc import Callable
35
+ from datetime import UTC, datetime, timedelta
36
+ from pathlib import Path
37
+
38
+ from . import config
39
+
40
+ LEVELS = ("once", "session", "permanent")
41
+ ONCE_TTL_SECONDS = 120 # "allow once" lifetime before it auto-reverts
42
+
43
+
44
+ def in_box() -> bool:
45
+ return config.in_box()
46
+
47
+
48
+ def _now() -> datetime:
49
+ return datetime.now(UTC)
50
+
51
+
52
+ def _iso(dt: datetime) -> str:
53
+ return dt.astimezone(UTC).isoformat(timespec="seconds")
54
+
55
+
56
+ def _parse(iso: str | None) -> datetime | None:
57
+ try:
58
+ return datetime.fromisoformat(iso) if iso else None
59
+ except (ValueError, TypeError):
60
+ return None
61
+
62
+
63
+ def valid_host(host: str) -> bool:
64
+ """A plausible host / ``*.suffix`` glob — no scheme, path, whitespace, and at least one dot.
65
+ Keeps junk (and TOML-breaking text) out of the persisted allowlist."""
66
+ host = host.strip()
67
+ if not host or any(c.isspace() for c in host) or "/" in host or "://" in host:
68
+ return False
69
+ bare = host[2:] if host.startswith("*.") else host
70
+ return "." in bare and all(part for part in bare.split("."))
71
+
72
+
73
+ # ── the live store (once / session) ──────────────────────────────────────────────────
74
+
75
+
76
+ class StoreUnreadable(Exception):
77
+ """The allow-store exists but can't be read as our JSON object.
78
+
79
+ Distinct from ABSENT, which is just first run. A control this file backs must never be weakened
80
+ by its own damage, so readers fail CLOSED (enforce, grant nothing) and mutators refuse rather
81
+ than rebuild — silently recreating it would drop every grant it held."""
82
+
83
+
84
+ def _load_raw() -> dict:
85
+ """The store document. ``{}`` when the file doesn't exist yet (first run); raises
86
+ :class:`StoreUnreadable` when it exists but is damaged."""
87
+ path = config.allow_store_file()
88
+ if not path.exists():
89
+ return {}
90
+ try:
91
+ raw = json.loads(path.read_text())
92
+ except (OSError, ValueError) as e:
93
+ raise StoreUnreadable(f"{path}: {e}")
94
+ if not isinstance(raw, dict):
95
+ raise StoreUnreadable(f"{path}: expected a JSON object, got {type(raw).__name__}")
96
+ return raw
97
+
98
+
99
+ def _write_json(path: Path, payload: dict) -> None:
100
+ """Write ATOMICALLY — a temp file in the same dir, then ``os.replace``. Both files this module
101
+ writes are read by someone else while we write: the proxy addon re-reads the effective file per
102
+ request (a torn read would spuriously block egress — it fails closed), and a concurrent
103
+ `fy allow` / supervisor sweep would otherwise observe a truncated store.
104
+
105
+ The temp name is UNIQUE per writer (``mkstemp``). A fixed one only moves the tear: two
106
+ concurrent writers — a `fy allow` per worktree, the supervisor's sweep — would write the same
107
+ scratch file, and whoever replaced first would publish the other's half-written bytes."""
108
+ path.parent.mkdir(parents=True, exist_ok=True)
109
+ fd, name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
110
+ tmp = Path(name)
111
+ try:
112
+ with os.fdopen(fd, "w") as fh:
113
+ fh.write(json.dumps(payload, indent=2) + "\n")
114
+ os.replace(tmp, path)
115
+ except BaseException:
116
+ tmp.unlink(missing_ok=True) # never leave scratch files beside the store
117
+ raise
118
+
119
+
120
+ def _save_raw(doc: dict) -> None:
121
+ doc["written"] = _iso(_now())
122
+ _write_json(config.allow_store_file(), doc)
123
+
124
+
125
+ def _checked_raw() -> dict:
126
+ """:func:`_load_raw` plus FIELD validation. Absent fields are first run; PRESENT-but-wrong-
127
+ shaped ones are damage and raise, because reading them as defaults is how a hand-edit or a
128
+ partial restore silently drops every grant (an empty ``hosts`` we then rewrite) or hands
129
+ enforcement back to the repo seed the box can write (a non-bool ``default_deny``)."""
130
+ raw = _load_raw()
131
+ path = config.allow_store_file()
132
+ if "hosts" in raw:
133
+ hosts = raw["hosts"]
134
+ if not isinstance(hosts, dict):
135
+ raise StoreUnreadable(f"{path}: `hosts` is not an object")
136
+ bad = sorted(h for h, entry in hosts.items() if not isinstance(entry, dict))
137
+ if bad:
138
+ raise StoreUnreadable(f"{path}: malformed entries for {', '.join(bad)}")
139
+ if "default_deny" in raw and not isinstance(raw["default_deny"], bool):
140
+ raise StoreUnreadable(
141
+ f"{path}: `default_deny` is {type(raw['default_deny']).__name__}, not a bool"
142
+ )
143
+ if "declined" in raw:
144
+ declined = raw["declined"]
145
+ if not isinstance(declined, dict) or any(
146
+ not isinstance(v, dict) for v in declined.values()
147
+ ):
148
+ raise StoreUnreadable(f"{path}: `declined` is malformed")
149
+ return raw
150
+
151
+
152
+ def _load_store() -> dict[str, dict]:
153
+ """The grants, validated (see :func:`_checked_raw`)."""
154
+ return _checked_raw().get("hosts", {})
155
+
156
+
157
+ def _warn(msg: str) -> None:
158
+ print(f"⚠ {msg}", file=sys.stderr, flush=True)
159
+
160
+
161
+ def _save_store(hosts: dict[str, dict]) -> None:
162
+ doc = _load_raw()
163
+ doc["hosts"] = hosts
164
+ _save_raw(doc)
165
+
166
+
167
+ def default_deny() -> bool:
168
+ """Is the egress wall ENFORCING? Host-owned, like the grants.
169
+
170
+ ``[proxy] default_deny`` is only a SEED: it says what the project wants the first time, and
171
+ from then on the answer lives in the host store. It can't stay authoritative — repo config is
172
+ writable from inside the box, so a committed enforcement switch is one the yard can flip OFF for
173
+ itself, which is strictly worse than the per-host grants we already moved out (that widened the
174
+ wall by one host; this drops it entirely). Change it with ``fy allow wall on|off``."""
175
+ try:
176
+ stored = _checked_raw().get("default_deny")
177
+ except StoreUnreadable as e:
178
+ # Fail CLOSED: a damaged store must not hand enforcement back to `[proxy] default_deny`,
179
+ # which is repo config the box can write — that would turn "my allow-store broke" into
180
+ # "the yard switched its own wall off".
181
+ _warn(f"egress allow-store unreadable ({e}) — ENFORCING until it's repaired or removed")
182
+ return True
183
+ # `_checked_raw` already rejected a present-but-non-bool value, so this is a bool or absent.
184
+ return stored if stored is not None else config.proxy_default_deny()
185
+
186
+
187
+ def set_wall(on: bool) -> dict:
188
+ """Turn enforcement on/off in the host store (Mac only). Returns the new effective."""
189
+ _require_host()
190
+ _require_readable_store()
191
+ doc = _load_raw()
192
+ doc["default_deny"] = bool(on)
193
+ _save_raw(doc)
194
+ return write_effective()
195
+
196
+
197
+ def _prune(hosts: dict[str, dict]) -> tuple[dict[str, dict], bool]:
198
+ """Drop expired ``once`` grants. Returns (hosts, changed)."""
199
+ keep = {}
200
+ for host, entry in hosts.items():
201
+ exp = _parse(entry.get("expires"))
202
+ if exp is not None and exp <= _now():
203
+ continue
204
+ keep[host] = entry
205
+ return keep, len(keep) != len(hosts)
206
+
207
+
208
+ def live_hosts() -> list[str]:
209
+ """Every non-expired grant (once / session / permanent) — the patterns the proxy allows. A
210
+ damaged store grants NOTHING (fail closed, matching :func:`default_deny`)."""
211
+ return [g["host"] for g in grants()]
212
+
213
+
214
+ def grants() -> list[dict]:
215
+ """The non-expired grants WITH their metadata — ``{host, level, expires}`` sorted by host,
216
+ for the surfaces that manage them (the TUI's wall pane). Damaged store ⇒ ``[]``, same
217
+ fail-closed posture as :func:`live_hosts` (which is this, reduced to the patterns)."""
218
+ try:
219
+ hosts, _ = _prune(_load_store())
220
+ except StoreUnreadable as e:
221
+ _warn(f"egress allow-store unreadable ({e}) — granting nothing until it's repaired")
222
+ return []
223
+ return [
224
+ {"host": h, "level": e.get("level", "?"), "expires": e.get("expires")}
225
+ for h, e in sorted(hosts.items())
226
+ ]
227
+
228
+
229
+ # ── effective allowlist (what the proxy reads) ───────────────────────────────────────
230
+
231
+
232
+ def effective() -> dict:
233
+ """The resolved allowlist the egress proxy enforces — every grant in the host-side store. The
234
+ injector host + in-stack ``NO_PROXY`` hosts are handled proxy-side, not here."""
235
+ return {"default_deny": default_deny(), "allow": live_hosts()}
236
+
237
+
238
+ def write_effective() -> dict:
239
+ """Write the effective file the proxy daemon re-reads per request. Host-side (Mac) only."""
240
+ payload = effective()
241
+ _write_json(config.allow_effective_file(), payload)
242
+ return payload
243
+
244
+
245
+ # ── grants / revokes (Mac only) ──────────────────────────────────────────────────────
246
+
247
+
248
+ def _require_host() -> None:
249
+ if in_box():
250
+ raise SystemExit(
251
+ "✗ egress allows are Mac-only: the box must not grant its own egress "
252
+ "(the allow-store lives in the Mac home, outside the shared mount)."
253
+ )
254
+
255
+
256
+ def _require_readable_store() -> None:
257
+ """Mutators refuse on a damaged store instead of rebuilding it — a rebuild would silently drop
258
+ every grant it held. The fix is a human one, so name the file. Validates the GRANTS too
259
+ (``_checked_raw``), not just that the file parses."""
260
+ try:
261
+ _checked_raw()
262
+ except StoreUnreadable as e:
263
+ raise SystemExit(
264
+ f"✗ {e}\n Repair or delete that file, then re-run (deleting = no grants)."
265
+ )
266
+
267
+
268
+ def grant(host: str, level: str, ttl: int | None = None) -> dict:
269
+ """Allow ``host`` at ``level`` (once|session|permanent). Mac only. Returns the new effective."""
270
+ _require_host()
271
+ _require_readable_store()
272
+ host = host.strip()
273
+ if level not in LEVELS:
274
+ raise SystemExit(f"✗ unknown level {level!r} (have: {', '.join(LEVELS)})")
275
+ if not valid_host(host):
276
+ raise SystemExit(f"✗ not a valid host/glob: {host!r}")
277
+
278
+ hosts, _ = _prune(_load_store())
279
+ expires = _iso(_now() + timedelta(seconds=ttl or ONCE_TTL_SECONDS)) if level == "once" else None
280
+ hosts[host] = {"level": level, "expires": expires, "added": _iso(_now())}
281
+ doc = _load_raw()
282
+ doc["hosts"] = hosts
283
+ # Granting supersedes a standing "never" on the same host: the operator changed their mind,
284
+ # and a live grant shadowed by a decline record would make the recommendation surfaces lie.
285
+ if isinstance(doc.get("declined"), dict):
286
+ doc["declined"].pop(host, None)
287
+ _save_raw(doc)
288
+ return write_effective()
289
+
290
+
291
+ def revoke(host: str) -> dict:
292
+ """Remove ``host`` from the store at any level. Mac only."""
293
+ _require_host()
294
+ _require_readable_store()
295
+ host = host.strip()
296
+ hosts, _ = _prune(_load_store())
297
+ if host in hosts:
298
+ del hosts[host]
299
+ _save_store(hosts)
300
+ return write_effective()
301
+
302
+
303
+ def clear_ephemeral() -> dict:
304
+ """Drop all once/session grants, KEEPING permanent ones. For supervisor start — 'until restart'
305
+ grants end here. Mac only."""
306
+ _require_host()
307
+ _require_readable_store()
308
+ hosts, _ = _prune(_load_store())
309
+ _save_store({h: e for h, e in hosts.items() if e.get("level") == "permanent"})
310
+ return write_effective()
311
+
312
+
313
+ # ── repo-recommended grants (`[proxy] recommend`) ────────────────────────────────────
314
+ # The repo may RECOMMEND hosts (config.proxy_recommend — advisory, never enforced); the operator
315
+ # answers per host, and both answers land here in the host store: a yes is an ordinary grant, a
316
+ # "never" is a decline record so the offer stops re-asking. Enforcement never reads declines —
317
+ # they only silence offers, so damage to them can't widen or narrow the wall.
318
+
319
+
320
+ def declined() -> set[str]:
321
+ """Hosts the operator answered "never" to. Unreadable store ⇒ EVERYTHING is declined —
322
+ matching the fail-closed readers above: a broken store must not re-open offers (whose
323
+ accept path would refuse anyway, see :func:`_require_readable_store`)."""
324
+ try:
325
+ raw = _checked_raw()
326
+ except StoreUnreadable:
327
+ return {"*"}
328
+ return set(raw.get("declined", {}))
329
+
330
+
331
+ def decline(host: str) -> None:
332
+ """Record "never offer ``host`` again" (Mac only). Undone by granting it (any level) —
333
+ :func:`grant` drops the record — or by re-adding it by hand with `fy allow add`."""
334
+ _require_host()
335
+ _require_readable_store()
336
+ host = host.strip()
337
+ if not valid_host(host):
338
+ raise SystemExit(f"✗ not a valid host/glob: {host!r}")
339
+ doc = _load_raw()
340
+ declined = doc.setdefault("declined", {})
341
+ if not isinstance(declined, dict): # pragma: no cover — _checked_raw already rejected this
342
+ return
343
+ declined[host] = {"declined": _iso(_now())}
344
+ _save_raw(doc)
345
+
346
+
347
+ def recommendations() -> list[dict]:
348
+ """Everything ASKING to be granted for the bound config, in offer order: the repo's
349
+ ``[proxy] recommend`` first, then the active plugins' packaged asks
350
+ (:meth:`foldyard.plugins.Plugin.egress_recommend` — Claude Code's installer, npm for Codex),
351
+ de-duplicated by host with the repo's ``why`` winning.
352
+
353
+ Two sources, one consent path, because they differ only in who WROTE the list: the repo's is
354
+ box-writable and travels with the branch, a plugin's ships with the tool. Neither grants
355
+ anything — both land in :func:`offer_recommendations`, which asks the operator host by host."""
356
+ out = list(config.proxy_recommend())
357
+ seen = {e["host"] for e in out}
358
+ from .plugins import registry # lazy: allowlist is on the import-light hot path
359
+
360
+ for entry in registry().egress_recommend():
361
+ if entry["host"] not in seen and valid_host(entry["host"]):
362
+ seen.add(entry["host"])
363
+ out.append(entry)
364
+ return out
365
+
366
+
367
+ def pending_recommendations() -> list[dict]:
368
+ """The BOUND config's recommendations (:func:`recommendations`) still awaiting an answer: not
369
+ granted at any live level, not declined. Callers bind the ADOPTED config first (``devmode.
370
+ worktree_config`` does) so a recommendation only reaches an operator after the file carrying
371
+ it survived the adoption gate — an in-box edit can at most queue an ask for NEXT adoption
372
+ (and a plugin's ask only surfaces once the adopted copy declares that plugin's table)."""
373
+ refused = declined()
374
+ if "*" in refused:
375
+ return []
376
+ granted = set(live_hosts())
377
+ return [e for e in recommendations() if e["host"] not in granted and e["host"] not in refused]
378
+
379
+
380
+ def offer_recommendations(
381
+ *,
382
+ interactive: bool,
383
+ prompt: Callable[[str], str],
384
+ echo: Callable[[str], None],
385
+ accept_all: bool = False,
386
+ ) -> dict[str, int]:
387
+ """Offer the pending recommendations, one host at a time — the consent moment that makes a
388
+ shared allowlist safe. Returns ``{granted, declined, deferred}`` counts.
389
+
390
+ Per host: ``[y]es`` grants PERMANENT (the team-baseline intent), ``[s]ession`` until the
391
+ supervisor restarts, ``[n]ot now`` (the default — asked again next launch), ``ne[v]er``
392
+ records a decline. In the box it is a no-op (grants are host-side only). Injected
393
+ ``prompt``/``echo``
394
+ like ``configpin.resolve`` — tests drive it with no real stdin.
395
+
396
+ ``accept_all`` grants every pending host permanently WITHOUT asking — the unattended path
397
+ (`fy allow sync --yes`), for a first box-up with no terminal to answer on. It is a separate,
398
+ explicitly-typed decision rather than a fallback: a non-interactive caller that didn't ask for
399
+ it gets the list and no state change, because "nobody was there to say no" must never read as
400
+ yes. Only ever reachable from a host CLI invocation an operator typed."""
401
+ if in_box():
402
+ return {"granted": 0, "declined": 0, "deferred": 0}
403
+ pending = pending_recommendations()
404
+ counts = {"granted": 0, "declined": 0, "deferred": len(pending)}
405
+ if not pending:
406
+ return counts
407
+ n = len(pending)
408
+ echo(f"▶ {n} recommended egress host{'s' if n != 1 else ''} not yet granted:")
409
+ if accept_all:
410
+ for e in pending:
411
+ grant(e["host"], "permanent")
412
+ echo(f" ✓ {e['host']} allowed (permanent)" + (f" — {e['why']}" if e["why"] else ""))
413
+ return {"granted": n, "declined": 0, "deferred": 0}
414
+ if not interactive:
415
+ for e in pending:
416
+ echo(f" {e['host']}" + (f" — {e['why']}" if e["why"] else ""))
417
+ echo(
418
+ " No terminal here — review with `fy allow sync` (or the TUI's Network Log), "
419
+ "or grant them all unattended with `fy allow sync --yes`."
420
+ )
421
+ return counts
422
+ for e in pending:
423
+ why = f" — {e['why']}" if e["why"] else ""
424
+ answer = (
425
+ prompt(
426
+ f" allow {e['host']}{why}? [y]es permanent · [s]ession · "
427
+ f"[n]ot now (default) · ne[v]er: "
428
+ )
429
+ .strip()
430
+ .lower()
431
+ )
432
+ # EXACT matches, like the adoption gate: anything unrecognised defers, which changes
433
+ # nothing and asks again next time.
434
+ if answer in ("y", "yes"):
435
+ grant(e["host"], "permanent")
436
+ echo(f" ✓ {e['host']} allowed (permanent)")
437
+ counts["granted"] += 1
438
+ counts["deferred"] -= 1
439
+ elif answer in ("s", "session"):
440
+ grant(e["host"], "session")
441
+ echo(f" ✓ {e['host']} allowed (session — until the supervisor restarts)")
442
+ counts["granted"] += 1
443
+ counts["deferred"] -= 1
444
+ elif answer in ("v", "never"):
445
+ decline(e["host"])
446
+ echo(f" ✗ {e['host']} declined — not offered again (`fy allow add` re-allows)")
447
+ counts["declined"] += 1
448
+ counts["deferred"] -= 1
449
+ else:
450
+ echo(f" ({e['host']} deferred — you'll be offered it again)")
451
+ return counts
452
+
453
+
454
+ def sweep() -> bool:
455
+ """Expire lapsed ``once`` grants; rewrite the store + effective file if anything changed.
456
+ Returns True when it changed. Called from the supervisor tick. Mac only."""
457
+ if in_box():
458
+ return False
459
+ try:
460
+ hosts = _load_store()
461
+ except StoreUnreadable as e:
462
+ # The supervisor tick must not die on it; the fail-closed readers above already cover the
463
+ # posture, and the operator sees the reason.
464
+ _warn(f"egress allow-store unreadable ({e}) — skipping the expiry sweep")
465
+ return False
466
+ pruned, changed = _prune(hosts)
467
+ if changed:
468
+ _save_store(pruned)
469
+ write_effective()
470
+ return changed
@@ -0,0 +1,39 @@
1
+ # foldyard's GENERIC dev-box image — the package's built-in default box.
2
+ #
3
+ # Used when a consumer's foldyard.toml declares no [box].image, so any project can
4
+ # `foldyard init --box-only && foldyard box up` with NO Dockerfile to author. It is the
5
+ # `example/box.Dockerfile` generalised; a real consumer still points [box].image at their
6
+ # own toolchain image when they need one.
7
+ #
8
+ # foldyard's box contract (ADR-0014) is deliberately tiny — the image must provide:
9
+ # • an engine client that speaks the mounted socket → podman (installed below)
10
+ # • git → installed below
11
+ # • uv → so `foldyard box up` can
12
+ # `uv tool install foldyard` at box-up. uv provisions its OWN managed Python, so the
13
+ # image needs no system python/pip (uv-first, not python-first).
14
+ #
15
+ # The build CONTEXT is THIS packaged dir (not the consumer repo): the generic box must not
16
+ # depend on any repo contents. foldyard injects the rest at box-up — the foldyard CLI, the
17
+ # socket, env (CONTAINER_HOST), and (with a credential plugin) the proxy CA + mode mirror.
18
+ #
19
+ # Debian, deliberately (was quay.io/podman/stable, which is Fedora — chosen for its free
20
+ # podman, not its distro, and the distro is what consumers actually live with):
21
+ # • apt pulls from ONE stable host (deb.debian.org) — an egress allowlist can name it,
22
+ # where dnf's metalink mirror system redirects to arbitrary hosts no allowlist can;
23
+ # • Playwright (and most dev tooling) treats Debian/Ubuntu as first-class —
24
+ # `playwright install-deps` works here and does not on Fedora;
25
+ # • `[[box.tools]]` recipes match the apt one-liners most docs hand out.
26
+ # The engine CLIENT the contract needs is one apt install away (below) — the box only ever
27
+ # talks to the MOUNTED socket; it never runs a nested engine.
28
+ FROM debian:trixie-slim
29
+
30
+ # uv as a standalone binary — Astral's recommended image-copy install (pinnable, no curl).
31
+ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
32
+
33
+ # podman = the engine client for the mounted socket (CONTAINER_HOST is injected at box-up);
34
+ # ca-certificates so git/uv/curl verify TLS; curl for the static-binary bootstrap steps.
35
+ RUN apt-get update \
36
+ && apt-get install -y --no-install-recommends git podman ca-certificates curl \
37
+ && rm -rf /var/lib/apt/lists/*
38
+
39
+ WORKDIR /workspace