Skip to content

Commit 6ccb73f

Browse files
author
Douglas Jones
committed
v4.0 close: Sable audits (type enforcement + stdlib), cookbook entries #13/#14, 3 stdlib security fixes
1 parent 6bab38a commit 6ccb73f

25 files changed

Lines changed: 1771 additions & 11 deletions

.kiro/specs/v4-language/tasks.md

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@
88
- [x] **V4-1-4** Add `TypeViolation` to capability manifest errors list
99
- [x] **V4-1-5** Write `tests/test_type_enforcement.py` (20 tests)
1010
- [x] **V4-1-6** Verify all existing tests still pass (450 passing)
11-
- [ ] **V4-1-7** Sable audit of type enforcement implementation
12-
- [x] **V4-1-8** File Quill/Glyph dispatch
11+
- [x] **V4-1-7** Sable audit of type enforcement implementation- [x] **V4-1-8** File Quill/Glyph dispatch
1312

1413
## REQ-V4-2: Standard library
1514

@@ -38,16 +37,16 @@
3837
### V4-2 shared
3938
- [x] **V4-2-10** Write `tests/test_stdlib.py` (44 tests)
4039
- [x] **V4-2-11** Update `docs/AGENT_QUICKREF.md` with new primitive groups
41-
- [ ] **V4-2-12** Add cookbook entries for stdlib patterns
42-
- [ ] **V4-2-13** Sable audit of stdlib (path traversal, HTTPS enforcement, JSON injection)
40+
- [x] **V4-2-12** Add cookbook entries for stdlib patterns
41+
- [x] **V4-2-13** Sable audit of stdlib (path traversal, HTTPS enforcement, JSON injection)
4342
- [x] **V4-2-14** File Quill/Glyph dispatch
4443

4544
## REQ-V4-3: Public registry
4645

4746
- [x] **V4-3-1** Write `docs/REGISTRY.md`
4847
- [ ] **V4-3-2** Publish the five canonical pipeline symbols to codifide.com (blob write API pending)
4948
- [x] **V4-3-3** Add `registry` field to capability manifest — deferred (no field defined yet)
50-
- [ ] **V4-3-4** Add cookbook entry for publish-and-resolve workflow
49+
- [x] **V4-3-4** Add cookbook entry for publish-and-resolve workflow
5150
- [ ] **V4-3-5** Verify `run --registry https://codifide.com` resolves pipeline symbols
5251
- [x] **V4-3-6** File Quill/Glyph dispatch
5352
- [x] **V4-3-7** Registry browser at codifide.com/registry
@@ -67,10 +66,10 @@ All tasks deferred. No adoption evidence for network-exposed server.
6766

6867
## Open items (post-v4.0)
6968

70-
- [ ] Sable audit of type enforcement (V4-1-7)
71-
- [ ] Sable audit of stdlib (V4-2-13)
72-
- [ ] Cookbook entries for stdlib patterns (V4-2-12)
73-
- [ ] Cookbook entry for publish-and-resolve workflow (V4-3-4)
69+
- [ ] Sable audit of type enforcement (V4-1-7) — DONE
70+
- [ ] Sable audit of stdlib (V4-2-13) — DONE (3 fixes applied: HTTPS redirect, io.write size limit, JSON recursion)
71+
- [ ] Cookbook entries for stdlib patterns (V4-2-12) — DONE
72+
- [ ] Cookbook entry for publish-and-resolve workflow (V4-3-4) — DONE
7473
- [ ] Fix blob store write API — query params not headers (in progress)
7574
- [ ] Seed registry with pipeline symbols (blocked on blob write fix)
7675
- [ ] Verify end-to-end registry resolution (blocked on seeding)

codifide/runtime/primitives.py

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,208 @@ def escalate(img: Any, label: Any) -> str:
421421
returns="Image",
422422
)
423423

424+
# -- Standard library: File I/O (V4-2a) ----------------------------------
425+
import os as _os
426+
427+
def _io_read(path: Any) -> str:
428+
p = str(_unwrap(path))
429+
# Path traversal defense: reject any path containing '..'
430+
if ".." in p.split(_os.sep) or ".." in p.split("/"):
431+
raise ValueError(
432+
f"io.read: path traversal rejected — '..' not allowed in path: {p!r}"
433+
)
434+
_MAX_READ = 16 * 1024 * 1024 # 16 MiB
435+
try:
436+
size = _os.path.getsize(p)
437+
except OSError as exc:
438+
raise OSError(f"io.read: cannot stat {p!r}: {exc}") from exc
439+
if size > _MAX_READ:
440+
raise ValueError(
441+
f"io.read: file {p!r} is {size} bytes, exceeds 16 MiB limit"
442+
)
443+
try:
444+
with open(p, "r", encoding="utf-8") as fh:
445+
return fh.read(_MAX_READ + 1)
446+
except OSError as exc:
447+
raise OSError(f"io.read: cannot read {p!r}: {exc}") from exc
448+
449+
def _io_write(path: Any, content: Any) -> None:
450+
p = str(_unwrap(path))
451+
if ".." in p.split(_os.sep) or ".." in p.split("/"):
452+
raise ValueError(
453+
f"io.write: path traversal rejected — '..' not allowed in path: {p!r}"
454+
)
455+
c = str(_unwrap(content))
456+
# AUD-STD-05: size limit consistent with io.read (16 MiB).
457+
_MAX_WRITE = 16 * 1024 * 1024
458+
if len(c.encode("utf-8")) > _MAX_WRITE:
459+
raise ValueError(
460+
f"io.write: content exceeds 16 MiB limit"
461+
)
462+
try:
463+
with open(p, "w", encoding="utf-8") as fh:
464+
fh.write(c)
465+
except OSError as exc:
466+
raise OSError(f"io.write: cannot write {p!r}: {exc}") from exc
467+
468+
def _io_exists(path: Any) -> bool:
469+
p = str(_unwrap(path))
470+
if ".." in p.split(_os.sep) or ".." in p.split("/"):
471+
raise ValueError(
472+
f"io.exists: path traversal rejected — '..' not allowed in path: {p!r}"
473+
)
474+
return _os.path.exists(p)
475+
476+
reg.register("io.read", _io_read, effect="io.read", returns="String")
477+
reg.register("io.write", _io_write, effect="io.write", returns="Unit")
478+
reg.register("io.exists", _io_exists, effect="io.read", returns="Bool")
479+
480+
# -- Standard library: HTTP client (V4-2b) --------------------------------
481+
def _https_only_opener():
482+
"""Build a urllib opener that rejects HTTP redirects (AUD-STD-02).
483+
484+
urllib follows redirects by default. A server at https://example.com
485+
could redirect to http://evil.com, bypassing the HTTPS-only check.
486+
This opener intercepts redirects and raises if the destination is
487+
not HTTPS.
488+
"""
489+
import urllib.request as _req
490+
491+
class _HTTPSOnlyRedirectHandler(_req.HTTPRedirectHandler):
492+
def redirect_request(self, req, fp, code, msg, headers, newurl):
493+
if not newurl.startswith("https://"):
494+
raise ValueError(
495+
f"http: redirect to non-HTTPS URL rejected: {newurl!r}. "
496+
f"Only HTTPS redirects are allowed."
497+
)
498+
return super().redirect_request(req, fp, code, msg, headers, newurl)
499+
500+
return _req.build_opener(_HTTPSOnlyRedirectHandler())
501+
502+
def _http_get(url: Any) -> str:
503+
import urllib.error as _err
504+
u = str(_unwrap(url))
505+
if not u.startswith("https://"):
506+
raise ValueError(
507+
f"http.get: only HTTPS URLs are allowed, got: {u!r}. "
508+
f"Use 'https://' instead of 'http://'."
509+
)
510+
_MAX_RESP = 16 * 1024 * 1024
511+
opener = _https_only_opener()
512+
try:
513+
with opener.open(u, timeout=30) as resp:
514+
data = resp.read(_MAX_RESP + 1)
515+
if len(data) > _MAX_RESP:
516+
raise ValueError(
517+
f"http.get: response from {u!r} exceeds 16 MiB limit"
518+
)
519+
return data.decode("utf-8", errors="replace")
520+
except _err.HTTPError as exc:
521+
raise ValueError(
522+
f"http.get: HTTP {exc.code} from {u!r}: {exc.reason}"
523+
) from exc
524+
except _err.URLError as exc:
525+
raise ValueError(
526+
f"http.get: cannot reach {u!r}: {exc.reason}"
527+
) from exc
528+
529+
def _http_post(url: Any, body: Any) -> str:
530+
import urllib.request as _req
531+
import urllib.error as _err
532+
u = str(_unwrap(url))
533+
b = str(_unwrap(body)).encode("utf-8")
534+
if not u.startswith("https://"):
535+
raise ValueError(
536+
f"http.post: only HTTPS URLs are allowed, got: {u!r}. "
537+
f"Use 'https://' instead of 'http://'."
538+
)
539+
_MAX_RESP = 16 * 1024 * 1024
540+
opener = _https_only_opener()
541+
try:
542+
request = _req.Request(
543+
u, data=b, method="POST",
544+
headers={"Content-Type": "text/plain; charset=utf-8"},
545+
)
546+
with opener.open(request, timeout=30) as resp:
547+
data = resp.read(_MAX_RESP + 1)
548+
if len(data) > _MAX_RESP:
549+
raise ValueError(
550+
f"http.post: response from {u!r} exceeds 16 MiB limit"
551+
)
552+
return data.decode("utf-8", errors="replace")
553+
except _err.HTTPError as exc:
554+
raise ValueError(
555+
f"http.post: HTTP {exc.code} from {u!r}: {exc.reason}"
556+
) from exc
557+
except _err.URLError as exc:
558+
raise ValueError(
559+
f"http.post: cannot reach {u!r}: {exc.reason}"
560+
) from exc
561+
562+
reg.register("http.get", _http_get, effect="http.fetch", returns="String")
563+
reg.register("http.post", _http_post, effect="http.fetch", returns="String")
564+
565+
# -- Standard library: JSON (V4-2c) ---------------------------------------
566+
def _json_parse(s: Any) -> Any:
567+
import json as _json
568+
text = str(_unwrap(s))
569+
try:
570+
return _json.loads(text)
571+
except _json.JSONDecodeError as exc:
572+
raise ValueError(f"json.parse: invalid JSON: {exc}") from exc
573+
except RecursionError as exc:
574+
# AUD-STD-03: deeply nested JSON can cause RecursionError in
575+
# Python's JSON parser. Catch and surface as a typed PrimitiveError.
576+
raise ValueError(
577+
f"json.parse: JSON structure is too deeply nested"
578+
) from exc
579+
580+
def _json_encode(v: Any) -> str:
581+
import json as _json
582+
val = _unwrap(v)
583+
# Reject values that cannot be represented in JSON.
584+
if isinstance(val, _BottomType):
585+
raise ValueError("json.encode: cannot encode ⊥ (refusal) as JSON")
586+
try:
587+
return _json.dumps(val, ensure_ascii=False)
588+
except (TypeError, ValueError) as exc:
589+
raise ValueError(f"json.encode: cannot encode value: {exc}") from exc
590+
591+
reg.register("json.parse", _json_parse, effect=None, returns="Any")
592+
reg.register("json.encode", _json_encode, effect=None, returns="String")
593+
594+
# -- Standard library: Date arithmetic (V4-2d) ----------------------------
595+
def _clock_today() -> str:
596+
import datetime as _dt
597+
ts = time.time()
598+
trace.clock_reads.append(ts)
599+
return _dt.date.today().isoformat()
600+
601+
def _clock_parse(s: Any) -> int:
602+
import datetime as _dt
603+
text = str(_unwrap(s))
604+
try:
605+
d = _dt.date.fromisoformat(text)
606+
return int(_dt.datetime(d.year, d.month, d.day).timestamp())
607+
except ValueError as exc:
608+
raise ValueError(
609+
f"clock.parse: expected YYYY-MM-DD, got {text!r}: {exc}"
610+
) from exc
611+
612+
def _clock_add_days(ts: Any, n: Any) -> int:
613+
return int(_num(ts)) + int(_num(n)) * 86400
614+
615+
def _clock_format(ts: Any, fmt: Any) -> str:
616+
import datetime as _dt
617+
t = int(_num(ts))
618+
f = str(_unwrap(fmt))
619+
return _dt.datetime.fromtimestamp(t).strftime(f)
620+
621+
reg.register("clock.today", _clock_today, effect="clock.read", returns="String")
622+
reg.register("clock.parse", _clock_parse, effect=None, returns="Int")
623+
reg.register("clock.add_days", _clock_add_days, effect=None, returns="Int")
624+
reg.register("clock.format", _clock_format, effect=None, returns="String")
625+
424626
return reg
425627

426628

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Registry Browser — codifide.com/registry
2+
3+
**Date:** 2026-05-14
4+
**Persona:** Quill
5+
6+
---
7+
8+
## What shipped
9+
10+
A full symbol browser at `https://codifide.com/registry`.
11+
12+
**Features:**
13+
- Live status indicator — green dot + "Registry live" when `/health` responds
14+
- Three canonical pipeline symbols listed with intent strings, type signatures, and effect badges
15+
- Hash display with one-click copy button for each symbol
16+
- "View canonical JSON →" link opens the live symbol in the browser
17+
- Expand/collapse usage panels showing import syntax and CLI examples
18+
- "Try it live" panel with the full pipeline command
19+
- Publish workflow — 3-step guide for publishing your own symbols
20+
- API reference table — all five endpoints with method badges
21+
- Responsive — works on mobile
22+
- Matches the existing design system exactly (same CSS variables, fonts, nav, footer)
23+
24+
**Navigation:**
25+
- "Registry" link added to the main nav on `index.html`
26+
- "Browse the registry →" link added to the v4.0 announcement section
27+
- Registry link added to the footer nav
28+
29+
**Files:**
30+
- `publicsite/registry.html` — the page
31+
- `publicsite/registry.css` — page-specific styles extending `styles.css`
32+
- `publicsite/registry.js` — live status check, copy buttons, expand/collapse
33+
- `publicsite/vercel.json``/registry` route added
34+
- `publicsite/index.html` — nav and footer updated
35+
36+
## What I'm not yet sure of
37+
38+
Whether the "View canonical JSON →" links will work correctly once the symbols
39+
are seeded. They point to `/symbols/sha256:...` which routes to the serverless
40+
function — that function needs the symbols in the blob store to return JSON
41+
rather than a 404.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
dispatch:
2+
id: sha256:pending
3+
schema: codifide.dispatch/0.1
4+
subject: Registry browser — codifide.com/registry live
5+
at: "2026-05-14T00:00:00Z"
6+
author: Glyph
7+
intent: >
8+
Record the launch of the public registry browser page.
9+
Full symbol browser with live status, copy buttons, usage panels,
10+
API reference, and publish guide. Matches existing design system.
11+
12+
state:
13+
shipped:
14+
- "registry.html — full symbol browser page"
15+
- "registry.css — page-specific styles"
16+
- "registry.js — live status, copy buttons, expand/collapse"
17+
- "vercel.json — /registry route"
18+
- "index.html — Registry nav link, footer link, v4.0 announcement link"
19+
- "Page live at https://www.codifide.com/registry"
20+
in_flight:
21+
- "Symbol seeding — pipeline symbols not yet in blob store"
22+
blocked: []
23+
refused: []
24+
25+
evidence:
26+
- claim: "Registry page live at /registry"
27+
kind: run
28+
source: "curl https://www.codifide.com/registry"
29+
result: "<title>Codifide Registry — Public Symbol Store</title>"
30+
confidence: 1.0
31+
32+
unknowns:
33+
- question: "Do the 'View canonical JSON' links work once symbols are seeded?"
34+
why_unknown: "Symbols not yet in blob store — seeding requires REGISTRY_WRITE_TOKEN"
35+
how_to_resolve: "Run the three store push commands after confirming the blob store write API works"
36+
37+
next:
38+
- action: "Seed registry with pipeline symbols"
39+
depends_on: ["Vercel Blob write API working"]
40+
effect: "{ops.seed}"
41+
42+
links:
43+
canonical: "dispatches/2026-05-14-registry-browser.yaml"
44+
human_readout: "dispatches/2026-05-14-registry-browser.readout.md"
45+
live_url: "https://www.codifide.com/registry"
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Registry Deployment — Fly.io
2+
3+
**Date:** 2026-05-14
4+
**Persona:** Quill
5+
6+
---
7+
8+
## What happened
9+
10+
The public registry infrastructure was completed. The server was already
11+
written (V3-2); what was missing was a deployment target, a Docker image,
12+
and a seed script.
13+
14+
## What shipped
15+
16+
**`Dockerfile`** — builds a minimal Python 3.11 image with the codifide
17+
package installed. Starts `codifide serve --read-only --host 0.0.0.0 --port 7777`.
18+
Includes a health check. Verified: builds cleanly, health endpoint responds,
19+
POST /symbols correctly rejected in read-only mode.
20+
21+
**`fly.toml`** — Fly.io deployment config. `codifide-registry` app, `iad`
22+
region, 256mb shared VM, 1GB persistent volume at `/data/store`, HTTPS
23+
enforced, `min_machines_running = 1` so the registry is always reachable.
24+
25+
**`.dockerignore`** — excludes tests, docs, examples, dispatches, and build
26+
artifacts from the image. Keeps the image small.
27+
28+
**`scripts/seed_registry.sh`** — pushes the three canonical pipeline symbols
29+
to the registry after deploy. Verifies each is reachable via curl.
30+
31+
**`docs/DEPLOY.md`** — step-by-step deploy guide: install flyctl, create
32+
app, create volume, deploy, add custom domain, seed, verify end-to-end.
33+
34+
## What's left for you
35+
36+
1. `curl -L https://fly.io/install.sh | sh` — install flyctl
37+
2. `flyctl auth login`
38+
3. `flyctl launch --no-deploy --name codifide-registry`
39+
4. `flyctl volumes create codifide_store --size 1 --region iad`
40+
5. `flyctl deploy`
41+
6. Add DNS: `registry.codifide.com CNAME codifide-registry.fly.dev`
42+
7. `./scripts/seed_registry.sh --registry https://registry.codifide.com`
43+
44+
That's the full operational step. The code is ready; it just needs a
45+
machine to run on.
46+
47+
## What I'm not yet sure of
48+
49+
Whether Fly.io free tier will stay free for this workload long-term.
50+
The registry is read-only and low-traffic, so it should be well within
51+
limits — but worth monitoring after launch.

0 commit comments

Comments
 (0)