-
Notifications
You must be signed in to change notification settings - Fork 0
Queryable firewall/flow claims + cross-server query surface (CLI + MCP) #38
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0131a2a
04b48e3
4f33567
3191905
97de716
60c8c53
6c81f35
89b6505
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1 @@ | ||
| {"claim_count":5660,"claim_extracted_at":"2026-07-10T20:13:21Z","concept_count":918,"context_pack_count":0,"edge_count":1981,"enrichment_run_count":2,"eval_case_count":53,"eval_result_count":53,"grounding_row_count":1,"grounding_version":"grounding_v1","insight_decision_count":4,"insight_label_count":4,"insight_policy_version":"agent_core_insight_v1","learning_event_count":4,"learning_ledger_version":"learning_ledger_v1","learning_review_count":0,"loop_decision_envelope_count":0,"loop_decision_memory_version":"agent_core_loop_decision_v1","observation_count":2,"policy_decision_count":0,"policy_version":"knowledge_policy_v1","quality_finding_count":44,"retrieval_version":"retrieval_v1","run_id":"local-20260710201321","source_ref_count":1068,"source_shas":{}} | ||
| {"claim_count":5660,"claim_extracted_at":"2026-07-11T13:12:51Z","concept_count":918,"context_pack_count":0,"edge_count":1981,"enrichment_run_count":2,"eval_case_count":53,"eval_result_count":53,"grounding_row_count":1,"grounding_version":"grounding_v1","insight_decision_count":4,"insight_label_count":4,"insight_policy_version":"agent_core_insight_v1","learning_event_count":4,"learning_ledger_version":"learning_ledger_v1","learning_review_count":0,"loop_decision_envelope_count":0,"loop_decision_memory_version":"agent_core_loop_decision_v1","observation_count":2,"policy_decision_count":0,"policy_version":"knowledge_policy_v1","quality_finding_count":44,"retrieval_version":"retrieval_v1","run_id":"local-20260711131251","source_ref_count":1068,"source_shas":{}} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -93,6 +93,33 @@ def _frontmatter(concept: dict[str, Any]) -> dict[str, Any]: | |
| return raw if isinstance(raw, dict) else {} | ||
|
|
||
|
|
||
| def _section(body: str, header: str) -> str: | ||
| """The body text under a ``# <header>`` heading, up to the next ``# `` | ||
| heading. Used to scope the legacy firewall regex to just its own section.""" | ||
| out: list[str] = [] | ||
| capturing = False | ||
| for line in body.splitlines(): | ||
| if line.strip() == header: | ||
| capturing = True | ||
| continue | ||
| if capturing and line.startswith("# "): | ||
| break | ||
| if capturing: | ||
| out.append(line) | ||
| return "\n".join(out) | ||
|
|
||
|
|
||
| def _ref_index(concept: dict[str, Any], suffix: str) -> int | None: | ||
| """1-based index of the source_ref whose path ends with ``suffix`` (so a | ||
| claim can cite the file the fact actually came from), or None.""" | ||
| refs = concept.get("source_refs") | ||
| if isinstance(refs, list): | ||
| for i, ref in enumerate(refs, start=1): | ||
| if isinstance(ref, dict) and str(ref.get("path") or "").endswith(suffix): | ||
| return i | ||
| return None | ||
|
|
||
|
|
||
| def _source_uri(concept: dict[str, Any], index: int = 1) -> str: | ||
| refs_obj = concept.get("source_refs") | ||
| refs: list[Any] = refs_obj if isinstance(refs_obj, list) else [] | ||
|
|
@@ -189,22 +216,122 @@ def _host_claims(concept: dict[str, Any], extracted_at: str) -> list[KnowledgeCl | |
| claims: list[KnowledgeClaim] = [] | ||
| if not host: | ||
| return claims | ||
| subject = f"host:{host}" | ||
| # host_meta (os/role) lives in host_vars/<host>.yml — cite it, not hosts.yml. | ||
| host_vars_ref = _ref_index(concept, f"host_vars/{host}.yml") or 1 | ||
| flows_ref = _ref_index(concept, "network_flows.yml") or host_vars_ref | ||
| address = fm.get("address") | ||
| if address: | ||
| claims.append(_claim(concept, subject=f"host:{host}", predicate="has_address", object_value=str(address), extracted_at=extracted_at)) | ||
| claims.append(_claim(concept, subject=subject, predicate="has_address", object_value=str(address), extracted_at=extracted_at)) | ||
| groups = fm.get("groups") | ||
| if isinstance(groups, list): | ||
| for group in groups: | ||
| claims.append(_claim(concept, subject=f"host:{host}", predicate="member_of_group", object_value=str(group), extracted_at=extracted_at)) | ||
| claims.append(_claim(concept, subject=subject, predicate="member_of_group", object_value=str(group), extracted_at=extracted_at)) | ||
| os_value = str(fm.get("os") or "").strip() | ||
| if os_value: | ||
| claims.append(_claim(concept, subject=subject, predicate="runs_os", object_value=os_value, extracted_at=extracted_at, source_ref_index=host_vars_ref)) | ||
| role_value = str(fm.get("role") or "").strip() | ||
| if role_value: | ||
| claims.append(_claim(concept, subject=subject, predicate="has_role", object_value=role_value, extracted_at=extracted_at, source_ref_index=host_vars_ref)) | ||
| body = str(concept.get("body") or "") | ||
| for role_name, role_value in _HOST_ROLE_RE.findall(body): | ||
| if role_value in {"not set", "not found"}: | ||
| for role_name, role_val in _HOST_ROLE_RE.findall(body): | ||
| if role_val in {"not set", "not found"}: | ||
| continue | ||
| predicate = "has_monitoring_role" if role_name == "Monitoring role" else "has_logs_role" | ||
| claims.append(_claim(concept, subject=f"host:{host}", predicate=predicate, object_value=role_value, extracted_at=extracted_at)) | ||
| for match in _FIREWALL_RE.finditer(body): | ||
| claims.append(_claim(concept, subject=subject, predicate=predicate, object_value=role_val, extracted_at=extracted_at)) | ||
| # `allows_inbound` is the firewall_extra_rules projection ONLY — scan just | ||
| # the "# Inbound firewall rules" section so cross-cutting/outbound flow | ||
| # bullets (same `port ... from` format) don't leak in as phantom rules. | ||
| for match in _FIREWALL_RE.finditer(_section(body, "# Inbound firewall rules")): | ||
| value = f"{match.group('proto')}/{match.group('port')}/{match.group('src')}" | ||
| claims.append(_claim(concept, subject=f"host:{host}", predicate="allows_inbound", object_value=value, extracted_at=extracted_at)) | ||
| claims.append(_claim(concept, subject=subject, predicate="allows_inbound", object_value=value, extracted_at=extracted_at, source_ref_index=host_vars_ref)) | ||
| claims.extend(_flow_claims(concept, subject, fm, extracted_at, host_vars_ref=host_vars_ref, flows_ref=flows_ref)) | ||
| return claims | ||
|
|
||
|
|
||
| def _flow_claims( | ||
| concept: dict[str, Any], | ||
| subject: str, | ||
| fm: dict[str, Any], | ||
| extracted_at: str, | ||
| *, | ||
| host_vars_ref: int = 1, | ||
| flows_ref: int = 1, | ||
| ) -> list[KnowledgeClaim]: | ||
| """Decomposed, cross-server-queryable claims from resolved host flows. | ||
|
|
||
| Reads the resolved ``network_flows`` frontmatter block written by | ||
| ``build_host_concept`` (each entry carries the fully-resolved peer host, | ||
| address, and — where ``all`` was narrowed — the excluded hosts). Emits, per | ||
| direction: | ||
|
|
||
| * inbound: ``allows_port`` / ``allows_proto`` (bare-value index claims) and | ||
| one aggregated ``allows_from`` per source peer. | ||
| * outbound: ``connects_port`` / ``connects_proto`` and one aggregated | ||
| ``connects_to`` per destination peer. | ||
| """ | ||
| raw = fm.get("network_flows") | ||
| if not isinstance(raw, list): | ||
| return [] | ||
| flows = [entry for entry in raw if isinstance(entry, dict)] | ||
| claims: list[KnowledgeClaim] = [] | ||
| directions: dict[str, list[dict[str, Any]]] = {"inbound": [], "outbound": []} | ||
| for entry in flows: | ||
| direction = str(entry.get("direction") or "") | ||
| if direction in directions: | ||
| directions[direction].append(entry) | ||
|
|
||
| def _ref_for(entries: list[dict[str, Any]]) -> int: | ||
| # Cite network_flows.yml only when every flow in the group is a | ||
| # cross-cutting flow; otherwise the fact came from the host vars. | ||
| origins = {str(e.get("origin") or "") for e in entries} | ||
| return flows_ref if origins == {"cross-cutting"} else host_vars_ref | ||
|
|
||
| config = { | ||
| "inbound": ("allows_port", "allows_proto", "allows_from", "src_host", "src_addr", "src_kind"), | ||
| "outbound": ("connects_port", "connects_proto", "connects_to", "dst_host", "dst_addr", "dst_kind"), | ||
| } | ||
| for direction, entries in directions.items(): | ||
| port_pred, proto_pred, rel_pred, host_key, addr_key, kind_key = config[direction] | ||
| ports = sorted({str(entry.get("port")) for entry in entries if entry.get("port") is not None}) | ||
| protos = sorted({str(entry.get("proto")) for entry in entries if entry.get("proto") is not None}) | ||
| for port in ports: | ||
| claims.append(_claim(concept, subject=subject, predicate=port_pred, object_value=port, extracted_at=extracted_at, source_ref_index=host_vars_ref, metadata={"direction": direction})) | ||
| for proto in protos: | ||
| claims.append(_claim(concept, subject=subject, predicate=proto_pred, object_value=proto, extracted_at=extracted_at, source_ref_index=host_vars_ref, metadata={"direction": direction})) | ||
|
Comment on lines
+298
to
+301
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Fresh evidence after the relationship-claim attribution fix: the decomposed index claims still always use Useful? React with 👍 / 👎. |
||
| # One aggregated claim per peer (object). The claim id hashes only | ||
| # subject/predicate/object/source_uri (not metadata), so grouping by | ||
| # peer_addr would mint two claims with the SAME id for a peer reachable | ||
| # on >1 address (router loopback + underlay) and `compile_claims` would | ||
| # drop the second. Instead keep one claim and preserve every address in | ||
| # the per-flow `addr` field so no address is lost. | ||
| grouped: dict[tuple[str, str], list[dict[str, Any]]] = {} | ||
| for entry in entries: | ||
| peer_host = entry.get("peer_host") | ||
| object_value = f"host:{peer_host}" if peer_host else str(entry.get("peer") or "unknown") | ||
| grouped.setdefault((object_value, str(peer_host or "")), []).append(entry) | ||
| for (object_value, _peer_host), grouped_entries in sorted(grouped.items()): | ||
| sample = grouped_entries[0] | ||
| peer_host = sample.get("peer_host") | ||
| flow_rows = sorted( | ||
| ({"port": str(entry.get("port")), "proto": str(entry.get("proto")), "family": str(entry.get("family") or ""), "addr": str(entry.get("peer_addr") or ""), "purpose": str(entry.get("purpose") or ""), "origin": str(entry.get("origin") or "")} for entry in grouped_entries), | ||
| key=lambda row: (row["proto"], row["port"], row["family"], row["addr"], row["origin"], row["purpose"]), | ||
| ) | ||
| addresses = sorted({str(entry.get("peer_addr")) for entry in grouped_entries if entry.get("peer_addr")}) | ||
| metadata: dict[str, Any] = { | ||
| "direction": direction, | ||
| host_key: peer_host, | ||
| addr_key: sample.get("peer_addr"), | ||
| kind_key: sample.get("peer_kind"), | ||
| "peer": sample.get("peer"), | ||
| "flows": flow_rows, | ||
| } | ||
| if len(addresses) > 1: | ||
| metadata[f"{addr_key}s"] = addresses | ||
| excludes = sample.get("excludes") | ||
| if isinstance(excludes, list) and excludes: | ||
| metadata["excludes"] = list(excludes) | ||
|
Comment on lines
+331
to
+333
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When multiple wildcard flows to the same peer have different Useful? React with 👍 / 👎. |
||
| claims.append(_claim(concept, subject=subject, predicate=rel_pred, object_value=object_value, extracted_at=extracted_at, source_ref_index=_ref_for(grouped_entries), metadata=metadata)) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Fresh evidence after the grouping change: this now builds separate groups by Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 6c81f35. Grouping by |
||
| return claims | ||
|
|
||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a cross-cutting flow is inbound for a host,
_flow_bulletrenders it in the sameport ... fromformat as real firewall rules under this new section._host_claimsstill scans the entire body with_FIREWALL_RE, so these repo-level flows also become legacyallows_inboundclaims; existing consumers that treatallows_inboundas thefirewall_extra_rulesprojection will see rules that never came from the host firewall. Render this section in a format the legacy regex cannot match or constrain the regex to the firewall section.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 97de716 — the legacy
_FIREWALL_REnow scans only the# Inbound firewall rulessection (via_section), so cross-cutting / outbound flow bullets (sameport … fromformat) no longer leak in as phantomallows_inboundclaims.