Skip to content

Commit 6d17d5e

Browse files
committed
Add gn-ten distributed envelope scanner
1 parent a3b65fb commit 6d17d5e

4 files changed

Lines changed: 417 additions & 1 deletion

File tree

support/gn_ten_node_lab/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ It owns local test-harness mechanics:
1616
- required app boot probes;
1717
- owner-defined facade host and `:pg` readiness checks;
1818
- JSON admin receipts for `preflight`, `up`, `status`, `probe`, and `down`;
19+
- distributed envelope scanning for tenant, authority, trace, idempotency,
20+
payload-mode, redaction, local-term, raw-payload, cross-tenant, stale-schema,
21+
and direct-lower-import defects;
1922
- node cleanup receipts.
2023

2124
It does not own AppKit, Mezzanine, Citadel, OuterBrain, Jido Integration,
@@ -54,6 +57,22 @@ and recorded as an explicit intent, but cross-command peer retention is not a
5457
v2 Phase 6 claim. A later daemon or release-path controller must own that
5558
stronger claim.
5659

60+
## Distributed Envelope Scanner
61+
62+
The package exposes `StackLab.GnTenNodeLab.scan_envelope/2` and
63+
`scan_envelopes/2`. The scanner validates only boundary hygiene; owner repos
64+
remain responsible for domain semantics.
65+
66+
It fails:
67+
68+
- missing schema, tenant, authority, trace, idempotency, source node,
69+
payload-mode, redaction, correlation, target profile, or issue time fields;
70+
- stale schema versions when supported versions are supplied;
71+
- cross-tenant read hints;
72+
- raw prompt/memory/provider/secret payload fields;
73+
- local-only runtime terms such as PIDs, ports, references, and functions;
74+
- direct lower-import or bypass hints.
75+
5776
## Security Posture
5877

5978
Default Erlang distribution cookies are local development cluster authority,

support/gn_ten_node_lab/lib/stack_lab/gn_ten_node_lab.ex

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ defmodule StackLab.GnTenNodeLab do
77
evidence semantics remain in their owner repos.
88
"""
99

10-
alias StackLab.GnTenNodeLab.{BootPlan, Peer, Preflight, Runner, Topology}
10+
alias StackLab.GnTenNodeLab.{BootPlan, EnvelopeScanner, Peer, Preflight, Runner, Topology}
1111

1212
@spec preflight(keyword()) :: {:ok, map()} | {:error, map()}
1313
defdelegate preflight(opts \\ []), to: Preflight, as: :run
@@ -36,6 +36,12 @@ defmodule StackLab.GnTenNodeLab do
3636
@spec down(keyword()) :: {:ok, map()} | {:error, map()}
3737
defdelegate down(opts \\ []), to: Runner
3838

39+
@spec scan_envelope(map(), keyword()) :: map()
40+
defdelegate scan_envelope(envelope, opts \\ []), to: EnvelopeScanner, as: :scan
41+
42+
@spec scan_envelopes([map()], keyword()) :: map()
43+
defdelegate scan_envelopes(envelopes, opts \\ []), to: EnvelopeScanner, as: :scan_many
44+
3945
@spec with_peer((Peer.t() -> term()), keyword()) :: {:ok, term()} | {:error, map()}
4046
defdelegate with_peer(fun, opts \\ []), to: Peer
4147
end
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
defmodule StackLab.GnTenNodeLab.EnvelopeScanner do
2+
@moduledoc """
3+
Distributed envelope scanner for local gn-ten node-lab proof facts.
4+
5+
This scanner validates harness and owner DTO envelopes before they are used as
6+
distributed proof facts. It is intentionally shape-focused; domain semantics
7+
remain with owner repos and their scanners.
8+
"""
9+
10+
@schema_version "stack_lab.gn_ten_node_lab.envelope_scan.v1"
11+
@scanner_ref "scanner://stack_lab/gn-ten-node-lab/distributed-envelope/v1"
12+
13+
@required_fields ~w(tenant_ref correlation_ref idempotency_key origin_node_ref target_profile redaction_class payload_mode issued_at)
14+
@schema_fields ~w(schema_ref schema_version)
15+
@trace_fields ~w(trace_ref trace_parent_ref)
16+
@blocked_key_fragments ~w(raw_prompt prompt_text raw_memory memory_body provider_payload credentials credential secret auth_header private_tool_output)
17+
@direct_lower_keys ~w(direct_lower_import direct_lower_imports direct_lower_call bypass_import bypass_imports)
18+
19+
@spec scan(map(), keyword()) :: map()
20+
def scan(envelope, opts \\ []) when is_map(envelope) and is_list(opts) do
21+
findings =
22+
envelope
23+
|> required_field_findings()
24+
|> Kernel.++(schema_findings(envelope, opts))
25+
|> Kernel.++(authority_findings(envelope))
26+
|> Kernel.++(trace_findings(envelope))
27+
|> Kernel.++(tenant_findings(envelope))
28+
|> Kernel.++(direct_lower_findings(envelope))
29+
|> Kernel.++(walk(envelope, []))
30+
31+
receipt(envelope, findings)
32+
end
33+
34+
@spec scan_many([map()], keyword()) :: map()
35+
def scan_many(envelopes, opts \\ []) when is_list(envelopes) and is_list(opts) do
36+
receipts = Enum.map(envelopes, &scan(&1, opts))
37+
findings = Enum.flat_map(receipts, &Map.fetch!(&1, "findings"))
38+
39+
%{
40+
"schema_version" => @schema_version,
41+
"scanner_ref" => @scanner_ref,
42+
"status" => status(findings),
43+
"envelope_count" => length(envelopes),
44+
"receipts" => receipts,
45+
"findings" => findings
46+
}
47+
end
48+
49+
@spec scan_file(Path.t(), keyword()) :: {:ok, map()} | {:error, map()}
50+
def scan_file(path, opts \\ []) when is_binary(path) and is_list(opts) do
51+
with {:ok, envelope_or_envelopes} <- read_fixture(path) do
52+
envelopes = List.wrap(envelope_or_envelopes)
53+
{:ok, scan_many(envelopes, opts)}
54+
end
55+
end
56+
57+
defp receipt(envelope, findings) do
58+
%{
59+
"schema_version" => @schema_version,
60+
"scanner_ref" => @scanner_ref,
61+
"status" => status(findings),
62+
"envelope_ref" => envelope_ref(envelope),
63+
"tenant_ref" => string_field(envelope, "tenant_ref"),
64+
"correlation_ref" => string_field(envelope, "correlation_ref"),
65+
"findings" => findings
66+
}
67+
end
68+
69+
defp required_field_findings(envelope) do
70+
Enum.flat_map(@required_fields, fn field ->
71+
if present?(field_value(envelope, field)),
72+
do: [],
73+
else: [finding(:missing_required_field, :missing_required_field, [], %{"field" => field})]
74+
end)
75+
end
76+
77+
defp schema_findings(envelope, opts) do
78+
cond do
79+
not any_present?(envelope, @schema_fields) ->
80+
[finding(:missing_schema, :missing_schema, [], %{"allowed_fields" => @schema_fields})]
81+
82+
unsupported_schema?(envelope, opts) ->
83+
[
84+
finding(:version_mismatch, :unsupported_schema_version, [], %{
85+
"schema_version" => string_field(envelope, "schema_version")
86+
})
87+
]
88+
89+
true ->
90+
[]
91+
end
92+
end
93+
94+
defp authority_findings(envelope) do
95+
if present?(field_value(envelope, "authority_ref")) or
96+
field_value(envelope, "authority_required?") == false do
97+
[]
98+
else
99+
[finding(:missing_authority, :missing_authority, [], %{})]
100+
end
101+
end
102+
103+
defp trace_findings(envelope) do
104+
if any_present?(envelope, @trace_fields),
105+
do: [],
106+
else: [finding(:missing_trace, :missing_trace, [], %{"allowed_fields" => @trace_fields})]
107+
end
108+
109+
defp tenant_findings(envelope) do
110+
tenant_ref = field_value(envelope, "tenant_ref")
111+
112+
~w(read_tenant_ref resource_tenant_ref target_tenant_ref)
113+
|> Enum.flat_map(fn field ->
114+
compare_tenant(field, tenant_ref, field_value(envelope, field))
115+
end)
116+
end
117+
118+
defp direct_lower_findings(envelope) do
119+
@direct_lower_keys
120+
|> Enum.flat_map(fn field ->
121+
case field_value(envelope, field) do
122+
value when value in [nil, [], false] ->
123+
[]
124+
125+
value ->
126+
[
127+
finding(:direct_lower_import, :direct_lower_import_present, [field], %{
128+
"value" => safe_inspect(value)
129+
})
130+
]
131+
end
132+
end)
133+
end
134+
135+
defp walk(%_struct{} = value, path), do: walk(Map.from_struct(value), path)
136+
137+
defp walk(value, path) when is_map(value) do
138+
Enum.flat_map(value, fn {key, nested_value} ->
139+
key_path = path ++ [to_string(key)]
140+
blocked_key_findings(key, nested_value, key_path) ++ walk(nested_value, key_path)
141+
end)
142+
end
143+
144+
defp walk(value, path) when is_list(value) do
145+
value
146+
|> Enum.with_index()
147+
|> Enum.flat_map(fn {nested_value, index} ->
148+
walk(nested_value, path ++ [Integer.to_string(index)])
149+
end)
150+
end
151+
152+
defp walk(value, path) when is_tuple(value) do
153+
value
154+
|> Tuple.to_list()
155+
|> Enum.with_index()
156+
|> Enum.flat_map(fn {nested_value, index} ->
157+
walk(nested_value, path ++ [Integer.to_string(index)])
158+
end)
159+
end
160+
161+
defp walk(value, path) when is_pid(value) or is_port(value) or is_reference(value) do
162+
[
163+
finding(:local_only_term, :local_only_runtime_term, path, %{
164+
"term_type" => local_term_type(value)
165+
})
166+
]
167+
end
168+
169+
defp walk(value, path) when is_function(value) do
170+
[finding(:local_only_term, :local_only_runtime_term, path, %{"term_type" => "function"})]
171+
end
172+
173+
defp walk(_value, _path), do: []
174+
175+
defp blocked_key_findings(key, value, path) do
176+
key_string = key |> to_string() |> String.downcase()
177+
178+
if Enum.any?(@blocked_key_fragments, &String.contains?(key_string, &1)) do
179+
[
180+
finding(:payload_not_allowed, :raw_or_sensitive_payload_field, path, %{
181+
"value" => safe_inspect(value)
182+
})
183+
]
184+
else
185+
[]
186+
end
187+
end
188+
189+
defp compare_tenant(_field, _tenant_ref, value) when value in [nil, ""], do: []
190+
191+
defp compare_tenant(field, tenant_ref, value) do
192+
if tenant_ref == value do
193+
[]
194+
else
195+
[
196+
finding(:cross_tenant_read, :tenant_ref_mismatch, [field], %{
197+
"tenant_ref" => tenant_ref,
198+
field => value
199+
})
200+
]
201+
end
202+
end
203+
204+
defp unsupported_schema?(envelope, opts) do
205+
supported = Keyword.get(opts, :supported_schema_versions, [])
206+
schema_version = field_value(envelope, "schema_version")
207+
supported != [] and present?(schema_version) and schema_version not in supported
208+
end
209+
210+
defp any_present?(envelope, fields), do: Enum.any?(fields, &present?(field_value(envelope, &1)))
211+
212+
defp field_value(map, field) when is_map(map) and is_binary(field) do
213+
Map.get(map, field, Map.get(map, String.to_atom(field)))
214+
end
215+
216+
defp string_field(map, field) do
217+
case field_value(map, field) do
218+
value when is_binary(value) -> value
219+
value when is_atom(value) and not is_nil(value) -> Atom.to_string(value)
220+
value when not is_nil(value) -> inspect(value)
221+
nil -> nil
222+
end
223+
end
224+
225+
defp present?(value), do: not is_nil(value) and value != ""
226+
227+
defp envelope_ref(envelope) do
228+
string_field(envelope, "envelope_ref") ||
229+
string_field(envelope, "correlation_ref") ||
230+
"envelope://stack_lab/gn-ten-node-lab/unknown"
231+
end
232+
233+
defp status([]), do: "pass"
234+
defp status([_ | _]), do: "open_defect"
235+
236+
defp finding(rule, reason, path, details) do
237+
%{
238+
"rule" => Atom.to_string(rule),
239+
"reason" => to_string(reason),
240+
"path" => path,
241+
"details" => details
242+
}
243+
end
244+
245+
defp local_term_type(value) when is_pid(value), do: "pid"
246+
defp local_term_type(value) when is_port(value), do: "port"
247+
defp local_term_type(value) when is_reference(value), do: "reference"
248+
249+
defp safe_inspect(value), do: inspect(value, limit: 20, printable_limit: 120)
250+
251+
defp read_fixture(path) do
252+
case Path.extname(path) do
253+
".exs" ->
254+
{fixture, _binding} = Code.eval_file(path)
255+
{:ok, fixture}
256+
257+
".json" ->
258+
path
259+
|> File.read()
260+
|> case do
261+
{:ok, body} -> Jason.decode(body)
262+
{:error, reason} -> {:error, failure("envelope_fixture_read_failed", reason)}
263+
end
264+
265+
_other ->
266+
{:error, failure("unsupported_envelope_fixture")}
267+
end
268+
rescue
269+
error -> {:error, failure("envelope_fixture_eval_failed", Exception.message(error))}
270+
end
271+
272+
defp failure(code, reason \\ nil), do: %{code: code, reason: inspect(reason)}
273+
end

0 commit comments

Comments
 (0)