Skip to content
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ This single constraint drives almost every design rule below. Small local models

1. **Read-only by default.** Every tool that queries a platform is read-only. Any tool that *changes state* on a live platform (isolate host, disable user, quarantine file, close incident) is a **gated write action** — see [Gated Write Actions](#gated-write-actions). It MUST require an explicit config flag AND per-action human confirmation, in one of two modes: **(a) forge-resistant** — a single-use confirmation token or a watcher approval delivered out-of-band on a channel the model cannot read; this is the default and the **only** permitted mode for destructive or irreversible actions. **(b) chat-confirm** — an opt-in, per-platform mode (off by default) where the operator's in-chat "approved" is the confirmation; it is convenient for supervised, reversible actions but is **not** forge-resistant (a misaligned model could fabricate it), so it is never used for destructive actions.
2. **Secrets never leave the host and never reach the model.** Credentials are loaded from per-platform `.env` files. They are **never logged, never included in tool output, never passed into a prompt or model context, never sent off-box.**
3. **Redact before returning.** All tool output passes through the core redaction layer before it is returned to the agent. Strip API keys, tokens, raw PII, and secrets from every payload — including error messages and stack traces.
3. **Redact before returning.** All tool output passes through the core redaction layer before it is returned to the agent. Strip API keys, tokens, raw PII, and secrets from every payload — including error messages and stack traces. Expected platform errors are mapped to findings by each server's `errors.py`; everything else — transport failures, unmapped statuses, bugs in our own mapping — is caught by `core/redaction/boundary.py`'s `guarded_tool`, applied beneath `@mcp.tool()` on every registered tool so no exception can reach the client unredacted or findingless.
4. **Every tool returns the structured findings schema.** No tool returns ad-hoc text. Output is normalized JSON (see [The Findings Schema](#the-findings-schema)) so agents — and small models especially — can parse and chain results predictably.
5. **Tools must be small-model-safe.** Flat argument schemas, short enums, few tools per server, no deeply nested objects, bounded/paginated output. See [Designing Tools for Small Models](#designing-tools-for-small-models). This is the repo's reason to exist — do not regress it for convenience.
6. **All safety logic lives in `core/`, never in a server.** Redaction, secret handling, the findings schema, and the gated-action machinery are implemented once, in the shared core, and imported by every server. A server must not re-implement or bypass them.
Expand Down Expand Up @@ -243,6 +243,7 @@ Each integration follows `.env.<platform>` and the thin-server pattern. Read too
## Secrets & Privacy

- **Per-platform `.env`.** `.env.wazuh`, `.env.defender`, `.env.entra`, … Each server loads only its own. All `.env*` files are gitignored.
- **Credential files are located by search, never by CWD.** Servers and scripts call `core/auth/env.py`'s `load_platform_env("<platform>")`, which searches the working directory and its parents, then the installed package's checkout, with `$F0_SECTOOLS_ENV_DIR` as an explicit override. A bare `load_dotenv(".env.<platform>")` is a defect — it silently loads nothing whenever the MCP client is launched from anywhere but the repo root, and a test in `core/tests/test_auth_env.py` guards against reintroducing it.
- **Nothing leaves the host.** No telemetry, no analytics, no external calls except to the operator's own configured security platforms.
- **Secrets never reach the model.** Credentials live in `core/auth/`; they are used to make API calls and are never placed in tool output, prompts, or model context.
- **Redaction is mandatory and centralized.** Every return path goes through `core/redaction/`, including error/exception paths.
Expand Down
8 changes: 6 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,12 @@ change. Do it in this order, TDD-ing each code step. (Background:
5. **Tools** (`tools.py`) — ≤ ~8 flat read tools returning `list[Finding]`;
write the contract tests first (fake client) — live data validates real
field names later.
6. **Server** (`server.py`) — `FastMCP`, one `@mcp.tool()` per tool, build the
client from config, **redact at the boundary** (`redact_obj(f.model_dump())`).
6. **Server** (`server.py`) — `MCPServer`, one `@mcp.tool()` per tool with
`@guarded_tool("<source>")` directly beneath it, build the client from
config, **redact at the boundary** (`redact_finding(f).model_dump()`).
The guard is not optional: it turns any error your mapper did not claim
into one redacted finding instead of a raw exception string, and a test
(`core/tests/test_tool_boundary.py`) fails if a tool is missing it.
7. **Evals** — `evals/<platform>/tasks.yaml` (≥1 task per tool) + add the
server to `SERVERS` in `evals/test_eval_coverage.py` and `SERVER_MODULES`
in `evals/run.py`.
Expand Down
49 changes: 33 additions & 16 deletions core/f0_sectools_core/auth/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,39 @@
from __future__ import annotations

import os
from collections.abc import Mapping
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field

from .env import find_platform_env

_TRUE = {"1", "true", "yes", "on"}


def _require_env(prefix: str, names: Iterable[str], env: Mapping[str, str]) -> None:
"""Raise if any required variable is unset, saying where credentials were looked for.

The variable names alone are a dead end: the usual cause is not a missing
key but a credential file that was never located, which looks identical
from the caller's side. Naming the file and its search result turns a
guessing game into a one-line fix. Values are never included.
"""
missing = [name for name in names if not env.get(name)]
if not missing:
return
platform = prefix.lower()
found = find_platform_env(platform)
where = (
f"found .env.{platform} in {found.parent} - add the missing keys there"
if found
else (
f"no .env.{platform} was found in the working directory, its parents, "
"or the installed package's checkout - create one in the repo root "
"or point F0_SECTOOLS_ENV_DIR at it"
)
)
raise ValueError(f"Missing required environment variables: {', '.join(missing)} ({where})")


@dataclass
class PlatformConfig:
tenant_id: str
Expand All @@ -26,9 +53,7 @@ class PlatformConfig:
def from_env(cls, prefix: str, env: Mapping[str, str] | None = None) -> PlatformConfig:
env = env if env is not None else os.environ
required = {k: f"{prefix}_{k.upper()}" for k in ("tenant_id", "client_id", "client_secret")}
missing = [name for name in required.values() if not env.get(name)]
if missing:
raise ValueError(f"Missing required environment variables: {', '.join(missing)}")
_require_env(prefix, required.values(), env)
verify = env.get(f"{prefix}_VERIFY_TLS", "true").strip().lower() in _TRUE
allow_write = env.get(f"{prefix}_ALLOW_WRITE", "false").strip().lower() in _TRUE
return cls(
Expand Down Expand Up @@ -58,9 +83,7 @@ def from_env(
) -> LimaCharlieConfig:
env = env if env is not None else os.environ
required = {"oid": f"{prefix}_OID", "api_key": f"{prefix}_API_KEY"}
missing = [name for name in required.values() if not env.get(name)]
if missing:
raise ValueError(f"Missing required environment variables: {', '.join(missing)}")
_require_env(prefix, required.values(), env)
allow_write = env.get(f"{prefix}_ALLOW_WRITE", "false").strip().lower() in _TRUE
return cls(
oid=env[required["oid"]],
Expand Down Expand Up @@ -90,9 +113,7 @@ def from_env(
) -> ProjectAchillesConfig:
env = env if env is not None else os.environ
required = {"base_url": f"{prefix}_BASE_URL", "api_key": f"{prefix}_API_KEY"}
missing = [name for name in required.values() if not env.get(name)]
if missing:
raise ValueError(f"Missing required environment variables: {', '.join(missing)}")
_require_env(prefix, required.values(), env)
verify = env.get(f"{prefix}_VERIFY_TLS", "true").strip().lower() in _TRUE
allow_write = env.get(f"{prefix}_ALLOW_WRITE", "false").strip().lower() in _TRUE
confirm_mode = env.get(f"{prefix}_CONFIRM_MODE", "token").strip().lower()
Expand Down Expand Up @@ -132,9 +153,7 @@ def from_env(
"access_key": f"{prefix}_ACCESS_KEY",
"secret_key": f"{prefix}_SECRET_KEY",
}
missing = [name for name in required.values() if not env.get(name)]
if missing:
raise ValueError(f"Missing required environment variables: {', '.join(missing)}")
_require_env(prefix, required.values(), env)
verify = env.get(f"{prefix}_VERIFY_TLS", "true").strip().lower() in _TRUE
base_url = env.get(f"{prefix}_BASE_URL", "https://cloud.tenable.com").rstrip("/")
return cls(
Expand Down Expand Up @@ -182,9 +201,7 @@ def from_env(
k: f"{prefix}_{k.upper()}"
for k in ("tenant_id", "client_id", "client_secret", "workspace_id")
}
missing = [name for name in required.values() if not env.get(name)]
if missing:
raise ValueError(f"Missing required environment variables: {', '.join(missing)}")
_require_env(prefix, required.values(), env)
try:
retention = int(env.get(f"{prefix}_RETENTION_DAYS", "30"))
except ValueError:
Expand Down
102 changes: 102 additions & 0 deletions core/f0_sectools_core/auth/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Locate a platform's ``.env`` file by searching, not by trusting the working directory.

An MCP client starts a server with whatever working directory it happens to
have: opencode launched from a subdirectory of the checkout, a systemd unit
from ``/``, a desktop client from ``$HOME``. A bare
``load_dotenv(".env.defender")`` resolves against that directory, so it
silently loads nothing and the server fails later with an opaque "missing
environment variables" error that points at the credentials rather than at
the launch context.

Resolving by search makes the credential location a property of the checkout
instead of a property of how the client was launched.

Search order, highest precedence first:

1. ``$F0_SECTOOLS_ENV_DIR`` -- an explicit operator override, for keeping
credentials outside the checkout entirely.
2. The working directory and each of its ancestors.
3. The installed package's directory and each of its ancestors -- reached
when the checkout is nowhere near the working directory at all.

Only variables named ``<PLATFORM>_*`` are injected; anything else in the
file is ignored, so one platform's file can neither set another's
credential nor alter the process environment.

A file that is *not* found is not an error: supplying credentials as real
environment variables is a supported deployment, and ``python-dotenv`` never
overwrites a variable that is already set, so the surrounding environment
always wins over the file.

Secrets read here enter this process's environment only. They are never
logged, never returned in tool output, and never placed in model context.
"""

from __future__ import annotations

import os
from pathlib import Path

from dotenv import dotenv_values

__all__ = ["env_search_dirs", "find_platform_env", "load_platform_env"]


def env_search_dirs() -> list[Path]:
"""Directories searched for a credential file, highest precedence first."""
candidates: list[Path] = []

override = os.environ.get("F0_SECTOOLS_ENV_DIR")
if override:
candidates.append(Path(override).expanduser())

try:
cwd = Path.cwd()
except OSError: # working directory deleted out from under the process
cwd = None
if cwd is not None:
candidates.extend([cwd, *cwd.parents])

package = Path(__file__).resolve()
candidates.extend(package.parents)

seen: set[Path] = set()
ordered: list[Path] = []
for directory in candidates:
if directory not in seen:
seen.add(directory)
ordered.append(directory)
return ordered


def find_platform_env(platform: str) -> Path | None:
"""Return the path to ``.env.<platform>``, or None if no checkout holds one."""
filename = f".env.{platform}"
for directory in env_search_dirs():
candidate = directory / filename
if candidate.is_file():
return candidate
return None


def load_platform_env(platform: str) -> Path | None:
"""Load ``.env.<platform>`` into the environment; return where it came from.

Returns None when no file exists, leaving the surrounding environment as
the credential source. Callers use the return value for diagnostics only
-- never log or return the file's *contents*.
"""
path = find_platform_env(platform)
if path is None:
return None
# Only this platform's own variables are injected. Two reasons, both
# Critical Rules: Rule 7 is per-platform credential isolation, and a file
# loaded wholesale could also set process-wide knobs -- HTTPS_PROXY is the
# sharp one, since httpx honours it (trust_env) on calls carrying a live
# token. `setdefault` keeps dotenv's override=False semantics: a variable
# already exported wins.
prefix = f"{platform.upper()}_"
for key, value in dotenv_values(path).items():
if value is not None and key.startswith(prefix):
os.environ.setdefault(key, value)
return path
28 changes: 26 additions & 2 deletions core/f0_sectools_core/gating/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,21 @@ def consume(self, action: str, target: str) -> bool:
return True


class AuditWriteFailed(RuntimeError):
"""The platform write succeeded but recording it to the audit trail did not.

Rule 8 requires every write to be audited, and the write necessarily runs
before the record can describe its result -- so this window cannot be
designed away, only reported honestly. ``action_executed`` is read
(duck-typed, no import) by ``core/redaction/boundary.py`` so the caller is
told the action TOOK EFFECT rather than being handed a generic failure it
might retry. That matters most in chat-confirm mode, where the token is not
single-use and a retry would execute the action a second time.
"""

action_executed = True


class GatedAction:
def __init__(
self,
Expand Down Expand Up @@ -270,12 +285,21 @@ def _audit(self, target: str, actor: str, token: str | None, method: str) -> Non
)
self.audit.record(self.name, target, actor, token or "", method=method, ref=ref)

def _audit_or_flag(self, target: str, actor: str, token: str | None, method: str) -> None:
"""Audit the completed write, or fail in a way that says it completed."""
try:
self._audit(target, actor, token, method)
except Exception as exc:
raise AuditWriteFailed(
f"{self.name} executed against {target} but the audit record failed"
) from exc

def execute(
self, *, target: str, actor: str, token: str | None, run: Callable[[], Any]
) -> Any:
method = self._authorize(target, token)
result = run()
self._audit(target, actor, token, method)
self._audit_or_flag(target, actor, token, method)
return result

async def execute_async(
Expand All @@ -288,5 +312,5 @@ async def execute_async(
) -> Any:
method = self._authorize(target, token)
result = await run()
self._audit(target, actor, token, method)
self._audit_or_flag(target, actor, token, method)
return result
Loading
Loading