<objective> Build the egress guard core: the allowlist validator and the deterministic OpenShell network-policy renderer.
Purpose: Deliver D-77 and D-78, the pure-logic heart of RT-05, with no dependency on any other Phase 4 work. Output: New validator/renderer module plus its test file. </objective>
<execution_context> @~/.claude/gsd-core/workflows/execute-plan.md @~/.claude/gsd-core/templates/summary.md </execution_context>
<context> @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/04-reaper-egress-guard-and-shared-dispatcher/04-CONTEXT.md @.planning/phases/04-reaper-egress-guard-and-shared-dispatcher/04-PATTERNS.md </context>
<tasks>
<task type="tdd"> <name>Task 1: validateEgressAllow validator (D-77)</name> <files>src/runtime/egress.ts, test/runtime/egress.test.ts</files> <read_first> - src/runtime/config.ts lines 20 and 54-56 (IMAGE_PATTERN constant-plus-test style and the reject() pattern that throws SCHEMA_INVALID naming no value) - src/contract/validate.ts lines 24-27 (EgressRule type to import with the inline type modifier) - test/runtime/config.test.ts lines 98-106 (reject-case table style to copy) - test/core/policy.schema.test.ts (accept and reject pairs) </read_first> <behavior> - Test (RT-05): exact multi-label hostnames (e.g. registry.example.com) and IPv4 literals with ports 1-65535 are accepted - Test (RT-05): IPv6 literal (contains : or brackets), wildcard (*), localhost, loopback 127.0.0.0/8, link-local 169.254.0.0/16, unspecified 0.0.0.0, and out-of-range port are each refused with SCHEMA_INVALID - Test titles cite RT-05 </behavior> <action>Write the failing tests first per behavior, then implement validateEgressAllow(rules) in src/runtime/egress.ts per D-77: accept only an exact DNS hostname of at least two labels (letters, digits, hyphens) or an IPv4 literal, with an integer port 1-65535 (re-checked for the runtime path even though the schema enforces it); refuse every IPv6 literal, wildcard, localhost, loopback, link-local, and unspecified host with SCHEMA_INVALID via a local reject() in the config.ts style. Import EgressRule as a type from src/contract/validate.js with the .js suffix and inline type modifier. Open the module with a header comment naming D-77 and D-78 in the src/core/events.ts style. (Implements D-77; D-76 default: IPv6 is refused at this layer and reported as partially unit-verified.)</action> <acceptance_criteria> - test/runtime/egress.test.ts asserts every D-77 accept case and every D-77 refusal with SCHEMA_INVALID - No value from the refused entry appears in the thrown error </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/runtime/egress.test.js</automated> </verify> <done>Validator green with all accept and refuse cases.</done> </task>
<task type="tdd"> <name>Task 2: renderNetworkPolicies renderer (D-78)</name> <files>src/runtime/egress.ts, test/runtime/egress.test.ts</files> <read_first> - src/runtime/openshell.ts lines 168-191 (buildPolicyDocument, the only fixed-document precedent; its comment marks the shape a documented contract) - src/runtime/openshell.ts line 88 (Object.freeze on returned argv; the renderer freezes its output the same way) </read_first> <behavior> - Test (RT-05): empty allowlist renders to {} - Test (RT-05): non-empty allowlist renders the exact D-78 shape: sorted by host then port, keys egress_<n> counting from 0 after sorting, each entry { name: "<host>:<port>", endpoints: [{ host, port }], binaries: [{ path: workloadBinary }] } - Test (RT-05): rendered text contains no colon inside any host and no key outside name, endpoints, binaries, host, port, path - Test (RT-05): input order shuffled still renders identical output (deterministic order) - Test titles cite RT-05 </behavior> <action>Write the failing tests first per behavior, then implement renderNetworkPolicies(rules, workloadBinary) in src/runtime/egress.ts per D-78: sort a copy of the rules by host then port, key each entry egress_<n> from 0, write only name, endpoints[].host, endpoints[].port, and binaries[].path, and return the frozen object. Never write allowed_ips, path, ports, protocol, access, rules, deny_rules, network_middlewares, or any provider key (D-78, OQ-P4-03 default). (Implements D-78; OQ-P4-12 default: no allowed_ips is added, so the allowlist must contain only hosts the user intends to reach.)</action> <acceptance_criteria> - test/runtime/egress.test.ts asserts empty-renders-{}, the exact D-78 entry shape, the forbidden-key absence, and deterministic ordering - The renderer writes no key outside the D-78 set </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/runtime/egress.test.js</automated> </verify> <done>Renderer green with deterministic D-78 output.</done> </task>
</tasks>
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| policy file -> validator | Egress entries cross from user config into enforcement; only validated host/port pairs pass |
| renderer -> policy document | Rendered map crosses into the OpenShell create; only the D-78 key set is written |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-04-01 | T (Tampering) | src/runtime/egress.ts | high | mitigate | validateEgressAllow refuses IPv6 literals, wildcards, localhost, loopback, link-local, and unspecified hosts with SCHEMA_INVALID; tests assert each refusal (D-77) |
| T-04-02 | I (Information Disclosure) | src/runtime/egress.ts | medium | mitigate | Renderer writes only D-78 fields; no allowed_ips or inspection fields leak broader reach; forbidden-key test pins the set (D-78) |
| </threat_model> |
<verification> npx tsc -p tsconfig.json && node --test dist/test/runtime/egress.test.js; grep test/runtime/egress.test.ts for RT-05 in test titles. </verification>
<success_criteria> Validator and renderer green on fakes with every D-77 refusal and the exact D-78 shape proven. </success_criteria>
Artifacts this phase produces (contribution of this plan)
src/runtime/egress.ts(new): exportsvalidateEgressAllow(rules)andrenderNetworkPolicies(rules, workloadBinary)(D-77, D-78).test/runtime/egress.test.ts(new): RT-05 accept/refuse pairs, empty and non-empty render cases, forbidden-key and determinism assertions.
<output> Create .planning/phases/04-reaper-egress-guard-and-shared-dispatcher/04-01-SUMMARY.md when done </output>