Skip to content

feat(sentinel): searchable Umbrella identities and the cloud firewall, plus three correctness fixes - #104

Merged
ubercylon8 merged 7 commits into
mainfrom
feat/sentinel-identity-and-audit
Aug 12, 2026
Merged

feat(sentinel): searchable Umbrella identities and the cloud firewall, plus three correctness fixes#104
ubercylon8 merged 7 commits into
mainfrom
feat/sentinel-identity-and-audit

Conversation

@ubercylon8

@ubercylon8 ubercylon8 commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Shaking down the sentinel server against the live workspace turned up five things worth changing. Each was reproduced before it was fixed, verified live after, and mutation-tested (25 mutations across the five changes, all caught).

1. Umbrella identities were returned but not searchable — 522faf8

Asked "which host or user is behind these IPs", a local model concluded the Umbrella logs carry no such field and spent six tool calls hunting the mapping across LimaCharlie, Tenable, Entra and Office 365. The conclusion was wrong: Identities_s is 100% populated (1,245 distinct in 24h; types "AD Users" and "Anyconnect Roaming Client") and hunt_dns_web was already returning it in every row. It just wasn't in indicator_fields, and nothing told the model it was there.

Worse, the dns surface matched indicator against Domain_s alone while its own help text advertised "a domain, URL fragment, or IP". An IP indicator passed validation and then matched nothing, so the tool answered "no activity" to a question it had never asked — the same advertised-but-not-honoured shape as the $orderby trap from the read-tool audit.

Fixed by widening the existing indicator rather than adding a hostname argument: one argument meaning "the thing you're looking for" beats a sixth argument on a five-argument tool, and argument-filling accuracy is this repo's premise. has needed no change — a token drawn from a row matched its own Identities_s on 359,304 of 359,304 rows. validate_indicator also accepts UPN_RE now, widening the charset by exactly one character (@), still with no quote, backslash or whitespace.

Live: hunt_dns_web(surface="dns", indicator="tailscale") returns 8 distinct identities across 8 internal IPs in one call — the whole six-call cross-platform hunt, answered from the row the tool already had.

The skill gains the routing rule and a pitfall the same transcript earned: the model reported "IP to User Mapping Found" for an external IP it had itself just shown serving five internal hosts. That address is a site's NAT egress; naming one user behind it is a false attribution stated with confidence.

2. The Umbrella cloud firewall was unreachable — 194aead

Cisco_Umbrella_firewall_CL (10.6M rows/7d) was the last table in the workspace no tool could reach, and it is not a duplicate of the CEF table behind hunt_firewall. Those are on-prem appliances seeing traffic that crosses the office network; this is Umbrella's cloud-delivered firewall, seeing roaming and remote clients that never touch the perimeter.

The measured difference is the point:

rows / 7d flows with a named user
CommonSecurityLog (perimeter) 108,263,075 0.14%
Cisco_Umbrella_firewall_CL (cloud) 10,616,038 100%

Ten times smaller and fully attributed — it answers "which user opened this connection", which the perimeter firewall structurally cannot. Source IP, destination IP/port and byte counts are 100% populated too, so volume questions become answerable.

Added as a second surface rather than a new tool: it mirrors hunt_dns_web's shape, keeps the server at seven tools under the ~8 ceiling, and the Surface dataclass already carried per-surface action maps, indicator fields and junk filters. Identity leads indicator_fields, so it is both the primary search field and the aggregate group-by — a bare call answers "which users generated this traffic, allowed vs blocked".

FQDNS_s (2.5% populated), Destination_Country_s (1%) and App_ID_s (0.8%) are deliberately not searchable: querying them would answer "no such traffic" to questions that really mean "that column is mostly empty" — the same defect removed from the dns surface in change 1.

Worth flagging operationally, independent of this code: 24h verdicts are 3,629,635 ALLOW against 9 BLOCK. That firewall is effectively in monitor mode.

3. Credentials were resolved by working directory — dace53e

Every server called load_dotenv(".env.<platform>") with a bare relative path. opencode's shipped wiring is uv run --directory ., so launching it from a subdirectory of the checkout started all nine servers with no credentials at all. Each then failed with "Missing required environment variables" — an error that points at the credentials rather than at the launch context, so it reads as misconfiguration. A capable local model burned ten minutes re-exporting variables into shells that each spawned a fresh process.

core/auth/env.py resolves by search instead: $F0_SECTOOLS_ENV_DIR, then the working directory and its parents, then the installed package's checkout. override=False is preserved, so an exported variable still wins and container/systemd deployments are unaffected. The five duplicated "missing variables" raises now share one helper that reports which problem occurred — file present but missing a key, or no file found — and a test asserts the message cannot leak a value.

4. Sentinel never went through the read-tool audit — 0a0a389

The 2026-07-25 staleness audit answered its four questions for every read tool across the then-eight servers. Sentinel shipped as #9 and was never run through it. Two of four answers were wrong.

Q2 — already-handled records returned as current. list_sentinel_incidents defaulted to status="any", so the tool described as "the SOC incident queue" returned closed incidents as current work. Live: the default call returned 23 Closed and 2 New while exactly 2 incidents were open — 92% already-handled work with the two that mattered buried in it. Now defaults to status="open", expressed as an exclusion (Status !~ "Closed") rather than an allow-list, so a status Sentinel adds later is treated as open work instead of vanishing.

Q4 — truncation not disclosed. Three of seven tools cut results silently (25 of 55 deduped incidents dropped). The four that did disclose used len(rows) >= limit, which cannot tell an exactly-full page from a truncated one. Every row-returning tool now fetches limit + 1 and reports what it observed. run_kql discloses only when it added the bound itself.

Q1 and Q3 were already correct — filters apply inside the KQL before the bound, and every row query orders before it takes.

5. Unmapped exceptions escaped the redaction boundary — 1823335

Servers map the errors they expect; everything else propagated past _render and reached the MCP client as a raw exception string. Reproduced: an httpx.ConnectError surfaced its message verbatim, through no redaction. That breaks Critical Rule 3 (redaction covers error paths) and Rule 4 (every tool returns the findings schema), on all 9 servers and all 58 tools — hence fixed once in core, not per server.

core/redaction/boundary.py adds guarded_tool, applied beneath @mcp.tool() on every registered tool — the only seam wide enough to catch failures in client construction, tool body and mapping code alike. An unclaimed exception becomes one posture finding carrying the exception type and its message, truncated to 300 characters and routed through the standard redaction pass rather than discarded. The title carries "temporarily unavailable", a DEGRADATION_MARKER, so reports file an infrastructure failure under coverage instead of counting it as a security finding.

Exception, deliberately not BaseException: cancellation must keep propagating. functools.wraps keeps the signature intact — verified by diffing the MCP-generated tool schema with and without the decorator, since a decorator visible to schema generation would silently rewrite all 58 tool contracts.

Drift guards added

Completeness is a property of a moment, not of the repo — an audit "complete across 8 servers" was inherited by none of the 9th. Three static guards make these durable: no server may call load_dotenv by a bare relative path; every registered tool must carry @guarded_tool with one source per server; and the existing gen_docs guard covers the regenerated reference.

Verification

1065 tests passing (+53), ruff and mypy clean, no gen_docs drift. Live read-only smoke against the validation workspace passes all seven sentinel tools, run from a subdirectory. No live write was performed at any point.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XYTy7da8Z5ZHhkwcCpjojZ

ubercylon8 and others added 4 commits August 12, 2026 08:36
Every server called load_dotenv(".env.<platform>") with a bare relative path,
which resolves against whatever working directory the MCP client happened to
have. Launching opencode from a subdirectory of the checkout (its shipped
wiring is `uv run --directory .`) therefore started all nine servers with no
credentials at all, and each failed with "Missing required environment
variables" — an error that points at the credentials rather than at the launch
context, so the failure reads as misconfiguration rather than as a path bug.

core/auth/env.py resolves the file by searching instead: $F0_SECTOOLS_ENV_DIR,
then the working directory and its parents, then the installed package's
checkout. python-dotenv's override=False semantics are preserved, so an
exported variable still beats the file and container/systemd deployments that
supply credentials without any file are unaffected.

The five duplicated "missing variables" raises in auth/config.py now share one
helper that also reports which of the two problems occurred — file present but
missing a key, or no file found — with the search hint. Values are never
included; a test asserts the error cannot leak one.

Verified live: the read-only Sentinel smoke run from skills/ now reaches the
workspace and all seven tools return findings. A drift guard fails if a future
server reintroduces the bare relative load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XYTy7da8Z5ZHhkwcCpjojZ
…lt queue, honest truncation

The 2026-07-25 read-tool staleness audit answered its four questions for every
read tool across the then-eight servers. Sentinel shipped as server #9 on
2026-08-11 and was never put through it. Two of the four answers were wrong.

Q2 (can already-handled records come back as current?) — list_sentinel_incidents
defaulted to status="any", so the tool described as "the SOC incident queue"
returned closed incidents as current work. On the validation workspace the
default call returned 23 Closed and 2 New while exactly 2 incidents were open:
the queue was 92% already-handled work with the two that mattered buried in it.
It now defaults to status="open", expressed as an exclusion (Status !~ "Closed")
rather than an allow-list of ("New","Active"), so a Status value Sentinel adds
later is treated as open work instead of silently disappearing. status="any"
and status="closed" remain available.

Q4 (is truncation disclosed?) — three of seven tools cut results silently:
list_sentinel_incidents (25 of 55 deduped incidents dropped),
search_office_activity, and run_kql. All three now emit core's truncation
finding. The four tools that already disclosed used `len(rows) >= limit`, which
cannot tell an exactly-full page from a truncated one and so over-reports; every
row-returning tool now fetches limit + 1 and reports what it actually observed.
run_kql discloses only when it added the bound itself — when the caller supplied
their own `take`, neither answer is knowable.

The detection-coverage skill now passes status="any" explicitly at the step that
compares incident volume against rule count: that comparison is about the whole
population a rule set produced, and the new default would have quietly reduced
it to open work only.

Q1 (server-side filtering) and Q3 (relevant page, not an arbitrary one) were
already correct — severity and status filter inside the KQL before the bound,
and every row query orders before it takes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XYTy7da8Z5ZHhkwcCpjojZ
Each server maps the errors it expects — auth, permission, licensing, rate
limit — into posture findings. Everything else propagated out of the tool, past
`_render`, and reached the MCP client as a raw exception string. Reproduced on
sentinel: an httpx.ConnectError surfaced its message verbatim, having passed
through no redaction at all. That breaks Critical Rule 3 (redaction covers error
paths) and Critical Rule 4 (every tool returns the findings schema), and it does
so on all 9 servers and all 58 tools — which is why it is fixed once in core
rather than per server.

`core/redaction/boundary.py` adds `guarded_tool`, applied directly beneath
`@mcp.tool()` on every registered tool. It is the only seam wide enough to catch
a failure in client construction, in the tool body, and in the mapping code
alike. An unclaimed exception becomes one posture finding carrying the exception
type and its message — truncated to 300 characters, because an exception can
carry an entire HTTP response body, and routed through the same redaction pass
as any other output rather than discarded, since a caller with no detail cannot
act. The title carries "temporarily unavailable", one of core/reports'
DEGRADATION_MARKERS, so a generated report files an infrastructure failure under
coverage instead of counting it as a security finding.

`Exception`, deliberately not `BaseException`: cancellation and interrupts must
keep propagating or a shutting-down server does not shut down. `functools.wraps`
keeps the wrapped signature intact — verified by comparing the MCP-generated
tool schema with and without the decorator, since a decorator visible to schema
generation would silently rewrite all 58 tool contracts.

An AST drift guard fails if any registered tool lacks the decorator, or if one
server reports more than one source. CONTRIBUTING's recipe step 6 gains the
requirement (and loses two stale references: FastMCP, redact_obj).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XYTy7da8Z5ZHhkwcCpjojZ
… returned

Asked "which host or user is behind these IPs", a local model concluded the
Umbrella logs carry no such field and spent six tool calls hunting the mapping
across LimaCharlie, Tenable, Entra and Office 365. The conclusion was wrong.
Identities_s is 100% populated on Cisco_Umbrella_dns_CL (1,245 distinct in 24h;
identity types "AD Users" and "Anyconnect Roaming Client"), and the tool was
already returning it — it simply could not be searched for, and nothing told the
model it was there.

The dns surface matched `indicator` against Domain_s alone, while its own help
text advertised "a domain, URL fragment, or IP". An IP indicator passed
validation and then matched nothing, so the tool answered "no activity" to a
question it had never actually asked — the same advertised-but-not-honoured
shape as the $orderby trap in the read-tool audit.

Rather than add a `hostname` parameter, the existing `indicator` now covers
identity and address: dns gains InternalIp_s, ExternalIp_s and Identities_s; web
gains Internal_IP_s and Identities_s; vpn gains Device_ID_s. One argument that
means "the thing you are looking for" beats a sixth argument on a tool that
already has five — tool-selection and argument-filling accuracy is the whole
premise of this repo. `has` needed no change: a token drawn from a row matched
its own Identities_s on 359,304 of 359,304 rows. validate_indicator now also
accepts UPN_RE, widening the charset by exactly one character ("@") so an AD
user is usable, still with no quote, backslash or whitespace.

Verified live: hunt_dns_web(surface="dns", indicator="tailscale") returns 8
distinct identities across 8 internal IPs in a single call — the whole six-call
cross-platform hunt, answered from the row the tool already had.

The skill gains the routing rule and a pitfall the same transcript earned: the
model reported "IP to User Mapping Found" for an external IP it had itself just
shown serving five internal hosts. That address is a site's NAT egress; naming
one user behind it is a false attribution stated with confidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XYTy7da8Z5ZHhkwcCpjojZ
@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

Claude finished @ubercylon8's task in 4m 37s —— View job


Security-focused review of PR #104

Todo list

  • Gather context (PR body, changed files list)
  • Review core/f0_sectools_core/auth/env.py (credential path resolution)
  • Review core/f0_sectools_core/redaction/boundary.py (exception redaction boundary)
  • Review core/f0_sectools_core/auth/config.py
  • Review sentinel server changes (server.py, tools.py, normalize.py) — indicator widening, status default, truncation disclosure
  • Review guarded_tool wiring across the 9 servers + the gated-write server interaction
  • Review skills docs (network-investigation, detection-coverage) for accuracy/safety
  • Check test coverage of changed logic
  • Post final findings

Summary

This is a well-scoped, well-tested fix set. The three cross-cutting fixes (.env search, redaction boundary, sentinel read-tool audit) are grounded directly in CLAUDE.md's Critical Rules and each ships mutation-adjacent test coverage (KQL injection charset, redaction-of-error-text, cancellation-still-propagates, drift guards for both load_dotenv bare-paths and un-guarded tools). The KQL-splice indicator widening (UPN_RE) stays inside the existing no-quote/no-backslash/no-whitespace charset, and test_upn_widening_still_rejects_a_kql_break_out explicitly covers the injection boundary. Nothing here defeats read-only-by-default, and secrets still never enter tool output.

Two findings worth a look, both correctness/design gaps rather than exploitable holes today:

1. guarded_tool on the gated-write server can mask a post-execution audit failure as "nothing happened" (Rule 8)
servers/projectachilles-actions-mcp/f0_pa_actions_mcp/server.py now wraps every write tool (run_test, schedule_test, set_schedule_status, cancel_tasks) in @guarded_tool("projectachilles"). GatedAction.execute_async (core/f0_sectools_core/gating/actions.py:281-292, unchanged by this PR) runs the actual platform call first, then writes the audit entry:

result = await run()          # the live write already happened
self._audit(target, actor, token, method)   # if THIS raises...

cancel_tasks's _run() (f0_pa_actions_mcp/tools.py:563-595) was clearly hardened to never let the live call itself raise past execute_async — good, and the comment there shows the author already thought about "skip the audit." But if AuditLog.record itself throws (disk full, permission error, path issue under ~/.f0sectools/gating/), that exception now propagates past the write and is caught by the new guarded_tool wrapper, producing a generic "... temporarily unavailable — unexpected OSError" posture finding. That finding gives no indication the state-changing action already executed on the live platform. For token/watcher confirm mode a retry is harmless (the token/approval was already consumed), but CLAUDE.md documents that chat-confirm mode's confirmation_token == target is not single-use — a model that reads "temporarily unavailable" and retries with the same echoed token in chat-confirm mode would re-execute the write. Worth either special-casing an audit-write failure (still redact, but title/severity it distinctly from "the platform call failed") or hardening AuditLog.record to never raise (e.g. best-effort with a fallback), so "the action ran" can never be indistinguishable from "the action failed."

2. The no-indicator (aggregate) branch of hunt_firewall/hunt_dns_web still truncates silently — the exact Q4 defect this PR states it closed
servers/sentinel-mcp/f0_sentinel_mcp/tools.py:272-277, the aggregate path (no indicator passed) issues:

parts.append(f"| summarize Events=count() by {spec.action_field}, {spec.indicator_fields[0]}")
parts.append(f"| top {limit} by Events desc")   # NOT _fetch_bound(limit)

Unlike the indicator/row-mode path (which correctly uses _fetch_bound(limit) = limit + 1) and unlike search_office_activity's discovery mode (tools.py:401-403, which does use _fetch_bound), this top limit query can never return more than limit rows, so _split_page's len(rows) > limit check at tools.py:302 can never be true and has_more is always False — truncation is never disclosed here even when far more than limit distinct action/indicator combinations exist in the window. This is the same silent-truncation shape the PR's Q4 fix (0a0a389) was written to eliminate everywhere ("Every row-returning tool now fetches limit + 1 and reports what it observed") — it just missed this one branch. No test in test_tools.py covers "more than limit groups in aggregate mode" either (test_hunt_firewall_without_indicator_is_aggregate_only only checks 1 row). Recommend | top {_fetch_bound(limit)} by Events desc plus the same _split_page/_more treatment used elsewhere in this function.

Not a problem, but worth a note

core/f0_sectools_core/auth/env.py's search walks the working directory's parents and the installed package's parents all the way to the filesystem root (env_search_dirs, env.py:41-65) looking for .env.<platform>. That's a deliberate, well-documented fix for the CWD-dependence bug, and override=False means an already-exported var always wins — so this doesn't leak an existing secret. But on a shared/multi-user host, an attacker able to write a .env.<platform> file into any ancestor directory of the launch CWD (e.g. /tmp, a shared parent) could get it loaded first, since dotenv also happily injects unrelated vars like HTTP_PROXY/HTTPS_PROXY into the process — and every httpx.AsyncClient construction in this repo (core/f0_sectools_core/auth/graph.py:40, the Tenable/PA clients) uses the default trust_env=True, so those would be honored on outbound calls carrying the real Bearer token. Given the repo's single-operator/local-model threat model this is low likelihood, but consider bounding the ascent (e.g. stop at $HOME, or require a repo marker) or noting the multi-user caveat in docs/user-guide/troubleshooting.md alongside the new guidance there.

…rewall surface

Cisco_Umbrella_firewall_CL (10.6M rows/7d) was the last table in the workspace
no tool could reach. It is not a duplicate of the CEF table behind
hunt_firewall: those are on-prem appliances seeing traffic that crosses the
office network, while this is Umbrella's cloud-delivered firewall seeing
roaming and remote clients that never touch the perimeter at all.

The measured difference is what makes it worth reaching. CommonSecurityLog
carries 108M rows/7d with a named user on 0.14% of them; Cisco_Umbrella_firewall_CL
carries 10.6M with one on 100% (Identity_Type_s "AD Users" on 3,629,629 of
3,630,796 rows in 24h, 286 distinct). Ten times smaller and fully attributed —
it answers "which user opened this connection", which the perimeter firewall
structurally cannot. SourceIP, destination IP/port and byte counts are 100%
populated too, so volume questions become answerable.

Added as a second surface rather than a new tool: it mirrors hunt_dns_web's
shape, which the model already drives, and keeps the server at seven tools
under the ~8 ceiling. The Surface dataclass already carried per-surface action
maps, indicator fields and junk filters, so the differing vocabulary needed no
new machinery. Identity leads indicator_fields, making it both the primary
search field and the aggregate group-by, so a bare call answers "which users
generated this traffic, allowed vs blocked". Ports are string-typed here, so
unlike CEF's int DestinationPort they need no port_field special case.

FQDNS_s (2.5% populated), Destination_Country_s (1%) and App_ID_s (0.8%) are
deliberately absent from indicator_fields: searching them would answer "no such
traffic" to questions that were really "that column is mostly empty" — the same
defect this branch just removed from the dns surface. The connector's ingested
CSV header row appears here as verdict_s == "Action" (~1,152/24h), filtered by
the existing hygiene clause.

Verified live: a bare cloud call returns the top users by verdict with no junk
rows; action="blocked" finds the 9 BLOCK flows in 24h; the perimeter default is
unchanged. Worth noting operationally — 3,629,635 ALLOW against 9 BLOCK means
that firewall is effectively in monitor mode, which is a posture finding for the
tenant rather than anything about this code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XYTy7da8Z5ZHhkwcCpjojZ
@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

Claude finished @ubercylon8's task in 2m 36s —— View job


Security-focused review of PR #104

Todo list

  • Gather context (PR body, diff vs origin/main, CLAUDE.md rules)
  • Review core/f0_sectools_core/auth/env.py (credential path resolution)
  • Review core/f0_sectools_core/redaction/boundary.py (exception redaction boundary) + guarded_tool wiring across servers
  • Review sentinel server changes (server.py, tools.py, normalize.py) — indicator widening, status default, truncation disclosure, new hunt_firewall(surface="cloud")
  • Review skills docs for accuracy/safety
  • Check test coverage of changed logic
  • Post final findings

Summary

This review covers the diff currently on the branch (git diff origin/main...HEAD, 5 commits). The first four commits (dace53e, 0a0a389, 1823335, 522faf8 — the .env search fix, the redaction boundary, the sentinel read-tool audit, and searchable Umbrella identities) match what a prior automated review on this PR already covered; I re-verified those and have nothing to add — the KQL-splice widening (UPN_RE) stays inside the existing no-quote/no-backslash/no-whitespace charset, redaction covers error paths, and override=False still means an exported credential wins over a discovered .env file.

New since that review is 194aead — a fifth commit adding surface="cloud" to hunt_firewall, reaching Cisco_Umbrella_firewall_CL. It's a clean, well-tested addition (short closed Literal["perimeter", "cloud"] enum, dedicated action vocabulary, live-verified field fill rates, docs/skill updated consistently), but it goes through the same shared _run_surface path as everything else, which surfaces one already-flagged gap more sharply and confirms it's still open.

1. Aggregate (no-indicator) mode of hunt_firewall/hunt_dns_web still truncates silently — the Q4 defect commit 0a0a389 says it closed, now more consequential

servers/sentinel-mcp/f0_sentinel_mcp/tools.py:279, the no-indicator branch of _run_surface (shared by every hunt_firewall/hunt_dns_web surface, including the new cloud one):

parts.append(f"| top {limit} by Events desc")   # not _fetch_bound(limit)

Compare search_office_activity's discovery-mode aggregate at tools.py:413, which correctly asks for _fetch_bound(limit) (limit + 1) so _split_page's len(rows) > limit check (tools.py:194) can actually detect truncation. Here it can't — top {limit} can never return more than limit rows, so has_more is always False and the tool never discloses that more groups existed, exactly the silent-truncation shape 0a0a389's PR text says was eliminated ("every row-returning tool now fetches limit + 1 and reports what it observed").

This commit's own numbers make the gap concrete: cloud_firewall's aggregate groups by verdict_s, Identity_s, and the commit message states 286 distinct identities in 24h on that table — well over the tool's default limit=25. A bare hunt_firewall(surface="cloud") call today silently returns the top 25 of 286 users with no truncation note, on the exact table this PR was written to make "answer who" queries reliable for. No test added in 194aead's test_tools.py/test_normalize.py covers "more than limit groups in aggregate mode" for either the new cloud surface or the pre-existing ones.

Fix: parts.append(f"| top {_fetch_bound(limit)} by Events desc") plus the same _split_page/_more treatment _run_surface already applies on the indicator/row-mode path just above it (tools.py:270-273).

Fix this →

2. (Carried forward, unchanged by this commit) guarded_tool on the gated-write server can still mask a post-execution audit failure

GatedAction.execute_async (core/f0_sectools_core/gating/actions.py) runs the live write, then writes the audit entry; if AuditLog.record itself raises, guarded_tool now catches it and returns a generic "temporarily unavailable" posture finding that gives no indication the state-changing action already executed. This is unchanged in this diff (core/f0_sectools_core/gating/ has no changes on this branch) — noting it again only because it's a real Rule 8 gap that a security-focused pass should keep visible, not because 194aead touched it.

Not a problem, minor note

validate_indicator's new kind="flow" (used by cloud_firewall) falls through to DOMAIN_RE/UPN_RE, which don't include : — so a raw IPv6 SourceIP/destinationIp_s value would fail validation as an indicator on this surface even though the field is populated. Same pre-existing limitation as kind="domain", not a regression, and the commit message's examples are all IPv4/port/UPN, but worth a follow-up if IPv6 shows up in this tenant's Umbrella logs.

Testing note: I was not able to run uv run pytest / ruff / mypy in this sandbox (Bash network/command approval is restricted here) — findings above are from static review of the diff only, not a live test run.

@ubercylon8 ubercylon8 changed the title feat(sentinel): searchable Umbrella identities, plus three correctness fixes found shaking down server #9 feat(sentinel): searchable Umbrella identities and the cloud firewall, plus three correctness fixes Aug 12, 2026
**Aggregate mode never disclosed truncation.** `_run_surface`'s no-indicator
branch asked for `| top {limit}`, which can never return more than `limit` rows,
so `_split_page`'s `len(rows) > limit` was structurally always False. The row
path was fixed in 0a0a389 and this branch was missed, making that commit's
claim — "every row-returning tool now fetches limit + 1" — untrue for every
hunt_* aggregate. It bites hardest on the surface added in 194aead:
cloud_firewall aggregates by identity across 286 distinct users against a
default limit of 25, so a bare call hid 261 of them silently.

The first test written for this passed before the fix. The fake client returns
its canned rows whatever the query says, so asserting on returned rows proved
only that the fake ignores KQL. The assertion is now on the emitted query,
which is the actual contract with the platform.

**An audited write could report as though it had not happened.** A gated action
runs the platform call and then records the audit entry; the write must come
first, or the record could not describe its result. If that record throws, the
new guarded_tool caught it and rendered "temporarily unavailable" — a
degradation, which says the opposite of what occurred. In chat-confirm mode the
confirmation is deliberately not single-use, so a model reading that and
retrying would execute the action a second time. `AuditWriteFailed` now carries
`action_executed = True`, and the boundary renders it as a high-severity action
finding that says the action took effect and must not be retried. Duck-typed,
so the redaction layer keeps no dependency on the gating layer.

**A credential file was injected wholesale.** `load_platform_env` loaded every
variable in the file it found. Critical Rule 7 is per-platform isolation, so a
`.env.defender` was never meant to be able to set `ENTRA_CLIENT_SECRET` — and
since the search added in dace53e walks parent directories, a file placed in a
shared ancestor could also set process-wide knobs. `HTTPS_PROXY` is the sharp
one: every client here builds `httpx.AsyncClient` with the default
`trust_env=True`, so it would be honoured on calls carrying a live token. Only
`<PLATFORM>_*` keys are injected now, via `setdefault` so an exported variable
still wins. Every documented variable across all nine `.env.*.example` files is
already prefixed, so nothing supported changes; the user guide notes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XYTy7da8Z5ZHhkwcCpjojZ
@ubercylon8

Copy link
Copy Markdown
Owner Author

Both review findings were real and are fixed in ce56077, plus the env note.

1. Aggregate mode truncating silently — confirmed, fixed. The review is right that 0a0a389's claim ("every row-returning tool now fetches limit + 1") was untrue for the aggregate branch, and right that 194aead made it worse: cloud_firewall aggregates by identity across 286 distinct users against a default limit of 25, so a bare call hid 261 silently. My own live run returned exactly 25 findings with no disclosure and I read straight past it.

Worth recording: the first test I wrote for this passed before the fix. The fake client returns canned rows regardless of the query, so asserting on returned rows only proved the fake ignores KQL. The assertion is now on the emitted query, which is the real contract with the platform.

2. Audit-write failure masked as a degradation — confirmed, fixed. AuditWriteFailed now carries action_executed = True; the boundary renders it as a high-severity action finding stating the action took effect and must not be retried, and deliberately avoids every DEGRADATION_MARKER so reports cannot file it as "not assessed". Duck-typed, so core/redaction keeps no dependency on core/gating. Covered end-to-end in test_gating.py (asserting the platform call really ran) as well as at the boundary.

3. The env ascent note — taken, and it went further than a doc caveat. load_platform_env was injecting the file wholesale, which is also a Critical Rule 7 violation independent of the proxy vector: a .env.defender could set ENTRA_CLIENT_SECRET. Only <PLATFORM>_* keys are injected now, via setdefault so an exported variable still wins. That closes the HTTPS_PROXY/trust_env path at the source rather than by bounding the ascent, so credentials kept deliberately outside the checkout still work. All nine .env.*.example files are already fully prefixed, so nothing supported changes; documented in the user guide.

Not fixed, and I agree it is a follow-up: kind="flow" rejects raw IPv6 because DOMAIN_RE/UPN_RE exclude :. Pre-existing for kind="domain" too, and this tenant's Umbrella logs are IPv4 — but it should be fixed before any workspace with IPv6 egress relies on it.

4 mutations run against the new tests, all caught. 1070 passing.

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

Claude finished @ubercylon8's task in 3m 44s —— View job


Security-focused review of PR #104 (re-run after ce56077)

Todo list

  • Gather context (PR body, diff vs origin/main, prior automated review comments)
  • Review core/f0_sectools_core/auth/env.py + config.py (credential path resolution)
  • Review core/f0_sectools_core/redaction/boundary.py + guarded_tool/AuditWriteFailed wiring
  • Review core/f0_sectools_core/gating/actions.py changes
  • Review sentinel aggregate-truncation fix (tools.py)
  • Check test coverage of changed logic
  • Post final findings

Summary

ce56077 closes all three gaps the two prior automated reviews on this PR raised, and does it well:

  • Aggregate truncation_run_surface's no-indicator branch now fetches _fetch_bound(limit) (tools.py:282) instead of a bare top {limit}, matching the row-mode path a few lines above and search_office_activity's discovery mode. The new test (test_aggregate_mode_discloses_truncation) correctly asserts on the emitted KQL rather than the fake client's canned rows — the commit message explains why the first version of this test would have passed against the very bug it targets, which is a good catch.
  • Audit-write failure masking a completed writeAuditWriteFailed (core/f0_sectools_core/gating/actions.py:218-231) now carries action_executed = True, and unexpected_error_finding (core/f0_sectools_core/redaction/boundary.py:66-84) duck-types on that to emit a high-severity action finding ("EXECUTED ... do not retry") instead of the generic "temporarily unavailable" degradation. I traced every execute_async call site in f0_pa_actions_mcp/tools.py (run_test, schedule_test, set_schedule_status, cancel_tasks) — none of the surrounding except blocks catch bare Exception/RuntimeError in a way that would swallow AuditWriteFailed before it reaches guarded_tool, including cancel_tasks's _run(), which was already careful not to raise past execute_async for the platform-call errors. This is the correct fix for the Rule 8 gap flagged before, and it stays honest about chat-confirm's non-single-use token.
  • Wholesale .env injectionload_platform_env now only injects <PLATFORM>_* keys via setdefault (core/f0_sectools_core/auth/env.py:98-101), closing the Rule 7 cross-platform leak and the HTTPS_PROXY/trust_env path through httpx.AsyncClient noted before. override=False semantics (exported var wins) are preserved.

One new gap, introduced by this exact fix:

The commit's own claim — "every documented variable across all nine .env.*.example files is already prefixed" — is false, and the fix silently breaks a documented, non-prefixed variable

servers/defender-mcp/.env.defender.example:31 and servers/projectachilles-mcp/.env.projectachilles.example:28 both still contain:

# F0_GATING_DIR=~/.f0sectools/gating  (shared gating-state dir override)

F0_GATING_DIR is deliberately platform-agnostic — core/f0_sectools_core/gating/actions.py:26-30 (gating_dir()) reads it unprefixed so servers and scripts/confirm_action.py can share one gating-state root, and docs/user-guide/gated-actions.md:76-78 / docs/user-guide/troubleshooting.md both instruct operators to set it. But load_platform_env's new filter (core/f0_sectools_core/auth/env.py:98-101) only injects keys starting with f"{platform.upper()}_", so if an operator uncomments that exact example line in .env.defender or .env.projectachilles, it is now silently dropped — the server falls back to the default ~/.f0sectools/gating with no warning. Before this fix, load_dotenv(path) picked it up for that server process (it just never solved the "CLI must agree" half, which already required a real shell export). After this fix, even that half stops working, and nothing tells the operator why their audit trail / pending-approval requests aren't where they expected — which is exactly the kind of silent, hard-to-diagnose divergence Rule 8 (auditability) and this PR's own "own creds never leave the host, never fail silently" theme are meant to prevent.

*_AUDIT_LOG_PATH and *_AUDIT_ACTOR in the same files are correctly prefixed and unaffected — this is specifically about the one shared, cross-platform variable that both .env.*.example files still document inline.

Recommend one of:

  1. Special-case F0_GATING_DIR (and any other intentionally-shared, non-prefixed knob) in load_platform_env's allowlist, or
  2. Remove the # F0_GATING_DIR=... line from both example files and state plainly (in troubleshooting.md/gated-actions.md) that it must be a real exported environment variable, never a .env.<platform> entry.

Either is a small change; I'd lean toward (2) since it matches the security intent of this fix (fewer things a .env.<platform> file can influence, not more) and the CLI-agreement requirement already meant a shell export was the only fully-correct way to set it.

No other issues found in this diff. AuditWriteFailed's error text still passes through redact_finding (Rule 3 preserved), and the new tests (test_audit_failure_reports_that_the_action_already_executed, test_a_write_that_executed_but_failed_to_audit_is_not_a_degradation, test_only_the_platforms_own_variables_are_loaded, test_aggregate_mode_discloses_truncation/..._silent_when_nothing_hidden) target the right behavior with meaningful assertions, not just shape checks.

Testing note: uv run pytest is blocked by the sandbox's command-approval restrictions here (same limitation as the prior automated review pass) — findings above are from static review of the diff, not a live test run.

…d it

The previous commit claimed every documented variable across the .env.*.example
files was platform-prefixed. That was wrong, and the check behind it was wrong:
it matched `^[A-Z0-9_]+=` and so skipped commented lines, which in an example
file are exactly the instructions operators copy. Both the defender and
projectachilles examples carried `# F0_GATING_DIR=...`, deliberately
un-prefixed because servers and scripts/confirm_action.py share one gating root.
After the prefix filter that line is silently ignored, so an operator who
uncommented it would get the default directory, no warning, and a watcher CLI
looking somewhere else.

Fixed by removing the line rather than allowlisting the variable. Allowlisting
would let any discovered .env file relocate where approvals, tokens and the
audit log live — and since these files are now found by walking parent
directories, that is a strictly worse version of the vector the prefix filter
just closed. Rule 8 wants the audit trail somewhere a credential file cannot
move it. Both examples now say to export it in the environment instead, which
was already the only fully-correct way to set it: the confirm CLI is a separate
process that never reads these files.

A test now enforces it, since the manual check is what failed: no .env.*.example
may document an assignment — commented or not — that load_platform_env would
not inject.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XYTy7da8Z5ZHhkwcCpjojZ
@ubercylon8

Copy link
Copy Markdown
Owner Author

Third finding confirmed and fixed in e993905 — the review is right that my previous commit message's claim was false.

The check behind that claim was the actual defect: I matched ^[A-Z0-9_]+=, which skips commented lines — and in an example file, commented assignments are the instructions operators copy. Both .env.defender.example and .env.projectachilles.example carried # F0_GATING_DIR=....

Took option (2), and I'd argue (1) is not merely the weaker option but an unsafe one. Allowlisting F0_GATING_DIR would let any discovered .env file relocate where approvals, tokens and the audit log live — and since dace53e made these files discoverable by walking parent directories, that is a strictly worse version of the vector the prefix filter just closed. Rule 8 wants the audit trail somewhere a credential file cannot move it. Both examples now direct operators to export it, which was already the only fully-correct way to set it since confirm_action.py is a separate process that never reads them.

A test now enforces the property, because the hand check is precisely what failed: no .env.*.example may document an assignment — commented or not — that load_platform_env would not inject. Mutation-tested by re-adding the offending line.

1071 passing.

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

Claude finished @ubercylon8's task in 1m 37s —— View job


Security-focused review of PR #104 (re-run after e993905)

Todo list

  • Gather context (PR body, prior review comments, diff vs origin/main)
  • Review new commit e993905 (F0_GATING_DIR docs fix) for correctness
  • Re-check the three previously-fixed gaps (aggregate truncation, audit-write masking, wholesale .env injection) are still solid
  • Sweep remaining diff for anything not yet covered by prior review passes
  • Check test coverage of changed logic
  • Post final findings

Summary

e993905 is the only new commit since the last automated pass on this PR (which reviewed through ce56077). It's a small, correct fix and I found no new issues.

What it does: the previous commit's claim that "every documented variable across the nine .env.*.example files is already prefixed" was checked by hand and wrong — servers/defender-mcp/.env.defender.example and servers/projectachilles-mcp/.env.projectachilles.example both still had a commented # F0_GATING_DIR=~/.f0sectools/gating line, which load_platform_env's new <PLATFORM>_*-only filter (core/f0_sectools_core/auth/env.py:98-101) silently ignores. An operator who uncommented it would get the default gating directory with no warning, and the watcher CLI (a separate process) would keep looking wherever it was already configured — a Rule 8 (auditability) gap.

Verified correct:

  • Both example files now instruct operators to export F0_GATING_DIR as a real environment variable rather than documenting it as a .env.<platform> entry, matching how scripts/confirm_action.py actually reads it (it never touches these files). docs/user-guide/gated-actions.md was updated to match.
  • The fix chose removal over allowlisting the variable, which is the right call given this repo's threat model: allowlisting F0_GATING_DIR would let a discovered .env.<platform> file (found by walking parent directories per dace53e) relocate where approvals, tokens, and the audit log live — strictly worse than the vector the prefix filter had just closed.
  • The new guard test (core/tests/test_auth_env.py::test_env_examples_only_document_prefixed_variables) fixes the actual root cause of the miss: the earlier manual check used ^[A-Z0-9_]+=, which skips commented lines — exactly the copy-paste instructions in an example file. The new regex (^#?\s*([A-Z][A-Z0-9_]*)\s*=) catches commented and uncommented assignments alike. I swept all nine .env.*.example files (including the root .env.wazuh.example, which the **/.env.*.example glob also matches) by hand and confirmed every remaining documented variable is correctly <PLATFORM>_-prefixed — the test's claim now holds.
  • Re-verified the two other gaps this PR previously fixed are still intact on this branch: AuditWriteFailed (core/f0_sectools_core/gating/actions.py:218-231, _audit_or_flag) still carries action_executed = True and is rendered by unexpected_error_finding (core/f0_sectools_core/redaction/boundary.py:66-84) as a high-severity action finding telling the caller not to retry, rather than a generic degradation. The hunt_firewall/hunt_dns_web aggregate path (servers/sentinel-mcp/f0_sentinel_mcp/tools.py:282) now fetches _fetch_bound(limit) and discloses truncation via _split_page/_more, matching the row-mode path and search_office_activity's discovery mode.

No new findings. Nothing in this diff weakens read-only-by-default, credential isolation, redaction coverage, or the findings schema.

Still open, unchanged from the prior pass (not introduced by this PR, low severity given the single-operator/local-model threat model): env_search_dirs() (core/f0_sectools_core/auth/env.py:45-69) ascends the working directory's parents all the way to the filesystem root. On a shared/multi-user host, a .env.<platform> file planted in any ancestor directory (e.g. /tmp) would be found. This is now materially safer than when first flagged, since the wholesale-injection issue this same commit chain closed means only <PLATFORM>_* keys from that file could be injected (not HTTPS_PROXY or another platform's secret) — worth a doc note in docs/user-guide/troubleshooting.md if this ever matters for a given deployment, but not a blocker.

Testing note: uv run pytest / ruff / mypy could not be run in this sandbox (command execution is restricted) — findings above are from static review of the diff and the referenced source, not a live test run.

@ubercylon8
ubercylon8 merged commit cfd4b66 into main Aug 12, 2026
11 checks passed
@ubercylon8
ubercylon8 deleted the feat/sentinel-identity-and-audit branch August 12, 2026 14:36
ubercylon8 added a commit that referenced this pull request Aug 12, 2026
Asked for a table of hostnames, a local model checked the Umbrella output,
found no field named anything like "hostname", and went looking in Tenable and
LimaCharlie instead. The hostname was in the rows it already had.

`Identities_s` is a JSON array of two identities on 98% of dns rows (356,571 of
362,944 in 1h): element 0 the Anyconnect roaming client — a machine name, 739
distinct, none containing "@" — and element 1 the AD user, 724 distinct, all
UPNs, ordered to match Identity_Types_s. PR #104 made that column searchable but
left it shaped as a JSON array inside a single evidence value.

That shape is the defect. The findings schema is flat by contract precisely so a
small model never has to parse a value, and `list_sentinel_incidents` already
learned this lesson the same way — live validation caught it returning `Owner`
and the tactics inside `AdditionalData` as raw JSON strings, and both were
parsed into readable evidence. The Umbrella surfaces never got the same
treatment, so "who" was answerable and "which machine" was not.

Rows now carry `identity_host` and `identity_user` as separate fields, with
`Identity_Types_s` consumed as the classifier rather than echoed back as another
array to parse. Classification is driven by the type array, not by position: the
ordering is this connector's convention, not a guarantee. Where types are absent
or do not line up, it falls back to shape ("@" means a user). The ~1% of rows
carrying a third AD Groups element surface it as `identity_other` — calling a
group a hostname would be the same class of error this split removes — and a
truncated or non-JSON value is kept unparsed rather than dropped.

Verified live: the seven blocked Tailscale hosts now report machine names
(SBL8773, SB11199, SBL3878, …) alongside their users, in one call, with no raw
array left in the output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XYTy7da8Z5ZHhkwcCpjojZ
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant