Security
Security model
The threat model, what read-only means in practice, how commands are executed, the limits that bound every call, and the controls that are on by default.
Secronyx is a read-only MCP server that gives an AI client structured access to host state without giving it a shell. This page describes what the server defends against, the mechanisms it uses, and which flag governs each control. It is written from the source tree; where an older document disagrees with the code, the code is what is described here.
Threat model
The server assumes the client on the other end of the transport may be compromised, manipulated by prompt injection, or simply wrong. It is designed so that such a client can read a bounded, policy-controlled view of the host and nothing more.
| Threat | Mitigation |
|---|---|
| Arbitrary command execution or injection | No shell. Collectors invoke a fixed set of binaries named in the source through cmdexec, with parameterised argument vectors. Caller input is never concatenated into a shell command line; where a value has to be embedded in a PowerShell script it is checked against an allowlist and then quoted with cmdexec.PSQuote. |
| Credential exfiltration through query output | Output redaction is on by default and runs inside the collectors that can carry a secret: logs, processes, scheduled tasks, the Windows registry, and the security, compliance and configuration parsers. Tools that expose configuration and accounts sit in the sensitive scope, which is not registered unless the operator opts in. |
| Reading arbitrary files | The configuration and lock-file tools run a caller-supplied path through a path policy with allowed roots, a deny list of key and credential files, symlink resolution and a 1 MiB cap. get_app_logs also takes a path; it is confined to a separate fixed list of log directories. |
| Using the host as a network pivot | The only tool that connects to a caller-supplied target resolves it first and refuses cloud metadata, loopback, link-local, multicast and unspecified addresses before any packet is sent. |
| Unauthorised remote access | The HTTP listener refuses to start off-loopback without both authentication and TLS. Every identity carries a scope list that is enforced on every tools/call. |
| Replay of captured credentials | SSH signatures carry a timestamp and nonce; JWT jti values are remembered until expiry; introspected tokens are replay-checked the same way. |
| Resource exhaustion | A per-call result cap, HTTP server timeouts, per-client rate limiting with a concurrency cap, and deadlines with a concurrency semaphore in the polling agent. |
| Tampering with the record of what was read | Audit logging is on by default, append-only, and hash-chained. If the log file cannot be opened the trail continues on stderr rather than stopping. |
What the server is not:
- Not a substitute for network segmentation. It is one process on one host; put it behind the same controls as any other service on that host.
- Not a way to give an AI agent shell access. There is no tool that runs a caller-supplied command, and none will be added.
- Not a defence against a host that is already compromised. It runs with the privileges it is given and reads what those privileges allow.
- Not a secrets manager. Redaction hides secrets that appear in system state; it does not store or rotate them.
What read-only means here
Every registered tool reads. No tool writes a file, changes a setting, sends a signal, or starts or stops a service. Concretely:
- Collectors read
/proc,/sys, fixed configuration paths, and the output of a fixed set of binaries named in the source, such asps,journalctl,dmesg,ping,wevtutil,/usr/bin/logand PowerShell cmdlets. - Sixteen tools take a filesystem path that the path policy governs:
get_app_configand the fifteen lock-file tools (get_npm_lock,get_cargo_lock,get_go_sumand so on). They are gated by the path policy described in Network and path policy.get_app_logsaccepts a path as well, but is confined by its own fixed list of log directories (/var/log,/Library/Logs,C:\Windows\Logsand similar) rather than by that policy. - One tool,
get_network_latency, opens outbound connections. Its targets are gated by the probe policy on the same page. Its HTTP form is connect-only: no request is sent, no redirect is followed, no body is read. - The GitGuardian redaction provider (
--redact-provider gitguardian) sends candidate values tohttps://api.gitguardian.comwhen an API key is configured, runs a localggshieldbinary when one is installed, and otherwise falls back to built-in patterns. The default provider makes no network calls and runs no commands.
The direct query mode (secronyx --query <name>) runs a collector without the MCP layer. It is an operator convenience for someone who already has a shell on the host: redaction, the path policy and the probe policy are process-global and still apply, but the scope policy and the tools/call audit events belong to the tool registry and do not.
Allowlisted execution, no shell
Collector process execution goes through internal/cmdexec. (Two things outside the collectors do not: the service subcommand, which drives systemctl, launchctl and net with constant arguments, and the optional ggshield call in the GitGuardian redaction provider.) The two entry points are:
func Command(name string, arg ...string) *exec.Cmd
func CommandContext(ctx context.Context, name string, arg ...string) *exec.CmdBoth build an exec.Cmd from an argument vector. No caller-supplied value is ever handed to a shell interpreter: the tree contains no sh -c or bash -c invocation, and the only cmd /c uses are constant probes (cmd /c ver in the shell-inventory collector, cmd /c assoc inside a fixed script in the Windows file-association collector). Where a caller-supplied value must reach a binary, it is passed as its own argument and validated first. The ping target is a worked example: the value is a separate argv element, but ping accepts options anywhere on its command line, so isSafePingTarget refuses anything that is not hostname or IP characters, anything longer than 253 characters, and anything starting with -. The binary then receives the resolved IP literal, never the name.
PowerShell invocations built with cmdexec.PowerShell or cmdexec.PowerShellArgs always pass -NoProfile -NonInteractive -Command. -NoProfile stops a user or machine profile from altering collector output; -NonInteractive makes a script that would prompt fail instead of blocking on stdin forever. Most Windows collectors do not yet use that wrapper: they call cmdexec.Command("powershell", "-NoProfile", "-Command", script) themselves, so they get -NoProfile but not -NonInteractive. Either way, every caller-controlled value embedded in a script (IIS site and pool names, event log channels) is first validated against an allowlist and then wrapped by cmdexec.PSQuote, which produces a single-quoted PowerShell literal in which no interpolation or escape sequence is recognised.
Command output is parsed into typed structures rather than returned as an opaque blob, although some results keep the source line they were parsed from in a raw field.
Deadlines and concurrency
Different entry points have different bounds.
Polling and hybrid agent. internal/agent/executor.go runs every work item under a context deadline. A work item that carries no timeout gets DefaultQueryTimeout (30 s); a requested timeout above MaxQueryTimeout (120 s) is clamped. At most DefaultMaxConcurrent (4) queries run at once, and waiting for a slot counts against the deadline, so a flood of items cannot queue forever. A query that overruns is reported with timed_out set and an error wrapping ErrQueryTimeout (query exceeded deadline). The executor documents an important limit: Go cannot kill a goroutine, so a collector that ignores its context keeps running until its own command timeout or OS call completes; its result is discarded and it holds its concurrency slot until it exits, which is why the cap exists.
HTTP transport. The listener is created with ReadHeaderTimeout 10 s, ReadTimeout and WriteTimeout 30 s, IdleTimeout 60 s and MaxHeaderBytes 64 KiB. The rate-limiting middleware (below) also caps in-flight requests at 32 across all clients.
stdio transport. Requests are processed one at a time in the order received. There is no per-call deadline in the server itself; the bounds are whatever each collector imposes. The connectivity probes run under the probe policy's timeout (5 s for resolution and for each connection attempt), and the IIS, enterprise and winextra Windows helpers run their commands under a context timeout; many other invocations rely on the binary's own limits instead.
Result cap
A single tool result is capped at DefaultMaxResultBytes, which is 4 << 20 (4 MiB), adjustable with --max-result-bytes. Values below 1 KiB are raised to 1 KiB so a misconfiguration cannot make every call fail. When a handler's output exceeds the cap the client receives a tool-level error rather than the data:
Error: result too large: <n> bytes exceeds the <cap> byte cap; narrow the query (limit, lines, filters)
The event is audited as tools/call with result error and error result_too_large, and counted in metrics under the same error type.
Rate limiting and lockout
On the HTTP transport, rate limiting is on by default (--rate-limit=false disables it and logs WARNING: HTTP rate limiting and brute-force lockout are DISABLED). The defaults in DefaultRateLimitConfig are:
| Setting | Value |
|---|---|
| Requests per second, per client address | 20 |
| Burst | 40 |
| Concurrent in-flight requests (all clients) | 32 |
| Authentication failures before lockout | 10 |
| Lockout duration | 15 minutes |
| Tracked client addresses (LRU) | 10,000 |
A limited or locked-out request receives 429 with a Retry-After header; a request refused by the concurrency cap receives 503 with Retry-After: 1 and the body server busy.
Listener guardrails
HTTPConfig.Validate runs before a socket is opened and is pure (no network, no files). Its rules:
| Bind address | No authentication | Authentication, no TLS | Authentication and TLS |
|---|---|---|---|
127.0.0.1, ::1, localhost |
allowed | allowed | allowed |
| anything else | refused | refused | allowed |
A static bearer token (--token or SECRONYX_TOKEN) must be at least MinBearerTokenLength (32) characters and is refused everywhere otherwise, including on loopback. A client CA bundle (--tls-client-ca) requires --tls-cert and --tls-key. --allow-unauthenticated (or SECRONYX_ALLOW_UNAUTHENTICATED=1) overrides the two refused cells and makes the server log a line beginning SECURITY WARNING: at start-up; it never relaxes the token-length rule.
/health returns {"status":"ok"}; it and the OAuth protected-resource metadata document at /.well-known/oauth-protected-resource are the two endpoints served without authentication. /metrics requires the same credentials as the MCP endpoint because metrics name every tool called. No CORS headers are sent unless --cors-origin lists origins, and a * entry is ignored whenever authentication is configured. X-Forwarded-For and X-Real-IP are ignored for audit purposes unless --trust-proxy-headers is set, so a caller cannot forge the client address in the audit log. See Remote access over HTTP and Authentication.
Replay protection
- SSH-signed requests carry a timestamp and nonce. The verifier rejects timestamps outside
DefaultClockSkew(5 minutes) and any nonce seen withinDefaultNonceTTL(10 minutes). - OIDC JWTs: a
jtiis remembered until the token'sexp;--oidc-require-jtirefuses tokens without one. A token whoseiatis more than 5 minutes in the future is refused, andexp - iatmay not exceed--oidc-max-token-lifetime(default 24 h). - Introspected tokens: a
jtireturned by the introspection endpoint is replay-checked the same way. Successful introspections are cached for--introspection-cache-ttl(default 60 s) keyed by the token's SHA-256; failures are never cached. - Both replay caches are bounded at
DefaultReplayCacheMaxEntries(100,000).
Defaults
The following are on unless the operator turns them off, and turning them off is logged:
| Control | Default | Off switch | Log line |
|---|---|---|---|
| Output redaction | on, provider default |
--no-redact |
WARNING: output redaction is DISABLED; secrets in system state will be returned verbatim |
| Audit logging | on, file /var/log/secronyx/audit.jsonl, stderr fallback |
--no-audit |
WARNING: audit logging is DISABLED |
| HTTP rate limiting | on | --rate-limit=false |
WARNING: HTTP rate limiting and brute-force lockout are DISABLED |
sensitive scope |
not registered | --enable-sensitive (opt in) |
WARNING: sensitive-scope tools are enabled |
| Off-loopback exposure without auth and TLS | refused | --allow-unauthenticated (opt in) |
SECURITY WARNING: serving ... |
Controls and the flags that govern them
| Control | Mechanism | Flag or setting | Detail |
|---|---|---|---|
| Tool inventory | ScopePolicy at registration |
--scopes, SECRONYX_SCOPES |
Scopes and authorization |
| Sensitive tools | never registered without opt-in | --enable-sensitive, SECRONYX_ENABLE_SENSITIVE=1 |
Scopes and authorization |
| Per-identity authorization | IdentityAllows on every tools/call |
scopes carried by the credential | Scopes and authorization |
| Output redaction | provider-based, on by default | --no-redact, --redact-provider |
Redaction |
| Audit trail | JSON Lines, SHA-256 hash chain, O_APPEND |
--audit-output, --audit-sync-write, --no-audit |
Audit logging |
| Audit integrity check | re-computes the chain | --audit-verify |
Audit logging |
| File reads | path policy: roots, deny globs, symlinks, 1 MiB | --allowed-paths |
Network and path policy |
| Outbound probes | resolve-then-check, metadata always refused | --probe-allow-loopback, --probe-allow-hosts, --probe-deny-hosts |
Network and path policy |
| Result size | per-call cap | --max-result-bytes (default 4 MiB) |
this page |
| HTTP exposure | Validate refuses unsafe binds |
--listen, --tls-cert, --tls-key, --allow-unauthenticated |
Remote access over HTTP |
| Rate limiting | token bucket, lockout, concurrency cap | --rate-limit |
this page |
| Client address in audit | RemoteAddr unless proxy trusted |
--trust-proxy-headers |
Audit logging |
| Browser access | no CORS headers by default | --cors-origin |
Remote access over HTTP |
| Token replay | jti cache, nonce cache |
--oidc-require-jti, --oidc-max-token-lifetime, --introspection-cache-ttl |
Authentication |
| Agent deadlines | 30 s default, 120 s ceiling, 4 concurrent | fixed in internal/agent/executor.go |
Hybrid and SaaS mode |
The full list of flags with defaults is in the Configuration reference. For how these controls map to compliance frameworks see Compliance mapping; to report a weakness in any of them see Reporting a vulnerability.
Built 2026-09-19. Source: levantar-ai/secronyx. Found a mistake? Tell us.