Project
Architecture
How a tool call travels from an AI client through the transport, the tool registry, a collector and cmdexec to the operating system, and where the security controls sit on that path.
Secronyx is a single static Go binary with no runtime dependencies on the host beyond the operating system's own utilities. It has one job: turn a JSON-RPC request into a read of system state and return a typed JSON document. Everything else in the codebase exists to constrain that path — which tools exist, who may call them, what a collector is allowed to read, what leaves the process and what is written down about it.
This page describes the internal structure as it is implemented in the repository: the components, the request path, the collector pattern and its build tags, the authentication chain, and the cross-cutting policies that sit on the path. It is the orientation document for someone reading the source or reviewing the design. For behaviour rather than structure, see the Security model, Authentication and the Tool reference.
Components
The repository builds three commands.
| Command | Path | Role |
|---|---|---|
secronyx |
cmd/secronyx |
The agent. MCP server over stdio or HTTP, direct --query mode, API key management, service install, SSH request signing, and the hybrid/polling SaaS client. |
secronyx-token-server |
cmd/secronyx-token-server |
A self-contained OAuth 2.1 authorization server for environments with no IdP: client credentials, JWKS, introspection, RSA key generation and rotation (internal/tokenserver). |
docgen |
cmd/docgen |
Generates per-query Markdown from query definitions (go run ./cmd/docgen -output docs/queries). |
secronyx and secronyx-token-server are separate binaries and separate trust domains. The agent never issues tokens; the token server never reads system state.
Repository layout
cmd/
secronyx/ main.go, flag parsing, runQuery(), apikey and ssh-sign subcommands,
service_{linux,darwin,windows}.go for service installation
secronyx-token-server/ OAuth 2.1 authorization server entry point
docgen/ documentation generator
internal/
mcp/ protocol.go, server.go, http.go, tools*.go, authz.go,
oidc.go, auth_apikey.go, auth_mtls.go, auth_ssh.go,
ratelimit.go, replay.go
cmdexec/ the command execution seam: Command, CommandContext,
LookPath, PowerShell wrappers, PSQuote
collector/ ParallelCollector, used by the report generators
audit/ append-only JSON Lines audit provider and hash chain
redact/ provider-based redaction (default, gitguardian)
pathpolicy/ allowed roots and deny list for caller-supplied file paths
metrics/ Prometheus collectors
agent/ hybrid and polling SaaS client: poller, executor,
WebSocket transport, credential storage, JWKS validation
plugins/ enterprise plugin hook (build-tag gated, no-op in OSS)
tokenserver/ token issuance, client store, key management
cpu/ memory/ disk/ network/ process/ uptime/ temperature/ logs/ ...
one package per collector family (about fifty of them)
pkg/
types/ shared result structs, the JSON shapes tools return
mcp/ the subset of server types plugins compile against
plugin/ the Plugin interface and registry
test/integration/ build-tagged integration tests
deploy/ Windows MSI/enterprise and macOS .pkg/LaunchDaemon assets
charts/ Helm chart
The split between internal/ and pkg/ is deliberate: pkg/types and pkg/plugin are the surface an out-of-tree plugin compiles against; everything else is closed to importers.
The request path
A tool call takes the same path on both transports. Only the front door differs.
stdio HTTP
┌──────────────────┐ ┌───────────────────────┐
client ──▶│ os.Stdin │ client ──▶│ POST / (JSON body) │
│ bufio.Scanner │ │ rate limit middleware │
│ NDJSON, 10 MiB │ │ CORS / logging / │
│ max line │ │ metrics middleware │
└────────┬─────────┘ │ authenticate(r) │
│ │ → *Identity in ctx │
│ └───────────┬───────────┘
└──────────────┬─────────────────────┘
▼
Server.handleMessage(ctx, []byte)
├─ jsonrpc != "2.0" → -32600
├─ id == nil → notification, no reply
└─ switch req.Method
initialize | initialized | tools/list
tools/call | ping | default → -32601
│
▼ tools/call
┌─────────────────────────────────┐
│ registry lookup by tool name │ miss → -32602, audit error
│ callerAllows(ctx, tool.Scope) │ deny → -32003, audit denied
└───────────────┬─────────────────┘
▼
ToolHandler(ctx, args)
│
▼
collector.NewCollector().Collect()
│
┌──────────────────┼──────────────────┐
▼ ▼ ▼
*_linux.go *_darwin.go *_windows.go
/proc, /sys, sysctl, cgo, registry, WMI,
native syscalls system_profiler PowerShell
└──────────────────┼──────────────────┘
▼
cmdexec.Command / CommandContext
(parameterised argv, never a shell string)
│
▼
parse into a pkg/types struct
redact fields that can carry secrets
│
▼
json.MarshalIndent → CallToolResult
│
┌───────────────┴────────────────┐
│ result size > cap? → IsError │
│ audit tool call, record metrics │
└───────────────┬────────────────┘
▼
JSON-RPC Response
Transport details
On stdio, Server.serve reads newline-delimited JSON with a bufio.Scanner whose buffer starts at 1 MiB and is allowed to grow to 10 MiB, and writes responses with a json.Encoder. There is no framing header. Cancellation is checked at the top of each loop iteration, after a line has been read, so a SIGINT ends the loop once the next message arrives.
On HTTP, only POST / carries MCP traffic; any other method on that path returns 405 Method not allowed. Three further endpoints exist: /health, unauthenticated so a load balancer can probe it and returning only {"status":"ok"}; /metrics, wrapped in requireAuth because metric labels name every tool called; and /.well-known/oauth-protected-resource, also unauthenticated, for RFC 9728 resource metadata. The listener is configured with ReadHeaderTimeout 10s, ReadTimeout and WriteTimeout 30s, IdleTimeout 60s and MaxHeaderBytes 64 KiB.
Middleware is assembled in HTTPServer.wrap:
handler := h.withMetrics(h.withLogging(h.withCORS(mux)))
for i := len(h.config.Middlewares) - 1; i >= 0; i-- {
handler = h.config.Middlewares[i](handler)
}Configured middlewares — rate limiting is the one the command line installs — end up outermost, so a throttled request is rejected before it is logged or counted.
Method dispatch
handleMessage accepts exactly five request methods: initialize, initialized, tools/list, tools/call and ping. Anything else returns -32601. initialize reports ProtocolVersion "2024-11-05" and advertises only the tools capability. A message without an id is a notification and produces no response; the server acts on initialized and ignores notifications/cancelled.
The JSON-RPC error codes the server can emit are declared in internal/mcp/protocol.go:
ErrCodeParse = -32700
ErrCodeInvalidRequest = -32600
ErrCodeMethodNotFound = -32601
ErrCodeInvalidParams = -32602
ErrCodeInternal = -32603
ErrCodeForbidden = -32003-32003 is the scope denial, placed in the implementation-defined server error range. A collector that fails is not a JSON-RPC error: the handler's error becomes a CallToolResult with IsError: true and the text Error: <message>, so the model sees the failure as tool output rather than a protocol fault.
The tool registry
Tools live in a map on the Server, each entry carrying the tool declaration, its handler and exactly one scope string:
type registeredTool struct {
Tool Tool
Handler ToolHandler
Scope string // Required MCP scope (e.g., "core", "logs", "sensitive")
}RegisterAllTools(s) in internal/mcp/tools.go calls a series of register*Tools functions, one per family, spread across tools.go and the tools_*.go files. There are 530 tool registrations in total; under the default scope policy the nine sensitive-scope tools are refused, leaving 521 in the map. ToolCountByScope() reports the per-scope inventory, which the HTTP transport uses to fill scopes_supported in its RFC 9728 metadata, while the command line logs how many tools the scope policy skipped.
Registration is itself an authorization decision. SetScopePolicy must be called before RegisterAllTools; tools whose scope the policy refuses are never added to the map, their names are recorded in SkippedTools(), and they are absent from tools/list and unreachable on every transport including stdio:
func (p ScopePolicy) permits(scope string) bool {
if scope == ScopeSensitive && !p.EnableSensitive {
return false
}
if len(p.AllowedScopes) == 0 {
return true
}
for _, allowed := range p.AllowedScopes {
if allowed == scope { return true }
}
return false
}The second stage is per request. callerAllows(ctx, toolScope) looks for an *Identity placed in the context by the HTTP transport. With no identity — stdio, or HTTP with no authentication configured — it returns true, and registration is the only control. With an identity, IdentityAllows accepts the wildcard *, the bare scope, or the namespaced mcp:tools:<scope>; the bare audience scope mcp:tools grants nothing. The same predicate filters tools/list, so a caller never sees a tool it cannot invoke. Scopes and authorization covers the grant formats each authenticator produces.
The collector pattern
Every metric family is a package under internal/ with a public, platform-neutral type and a private, platform-specific implementation. internal/cpu is the canonical example:
// cpu.go — compiled everywhere
type Collector struct {
previousTimes *cpuTimes
previousTime time.Time
}
func NewCollector() *Collector { return &Collector{} }
// Collect gathers CPU information.
// This is the main entry point - platform-specific implementations
// are in cpu_linux.go, cpu_darwin.go, cpu_windows.go
func (c *Collector) Collect(perCPU bool) (*types.CPUInfo, error) {
return c.collect(perCPU)
}The exported method is a one-line forwarder to an unexported one that exists once per platform. The result type comes from pkg/types, so the JSON shape is identical whichever implementation ran.
Build constraints select the implementation:
//go:build linux
//go:build darwin && cgo
//go:build darwin && !cgo
//go:build windowsThe darwin && cgo / darwin && !cgo pair matters in practice: several macOS collectors call native frameworks through cgo, so a cross-compiled GOOS=darwin CGO_ENABLED=0 build gets the no-cgo variant that returns empty results. This is why the release workflow builds macOS binaries natively on macOS runners. Across internal/ there are 49 linux files, 48 windows, 35 darwin, plus combined constraints such as linux || darwin and !windows.
The rule that makes a uniform tool catalogue possible is that an unsupported platform returns an empty result, not an error. A model asking for get_iis_sites on Linux gets an empty document rather than a failure it has to interpret.
The report generators in internal/report compose several collectors through internal/collector.ParallelCollector, which runs a named map of CollectorFunc concurrently under a shared timeout (30s when unset) and returns a map[string]Result so a slow or failing member does not lose the rest.
The command execution seam
No collector calls os/exec directly. Everything goes through internal/cmdexec:
func Command(name string, arg ...string) *exec.Cmd
func CommandContext(ctx context.Context, name string, arg ...string) *exec.Cmd
func LookPath(file string) (string, error)
func PowerShell(script string) *exec.Cmd
func PowerShellContext(ctx context.Context, script string) *exec.Cmd
func PSQuote(s string) stringThis has two purposes.
Security. Commands are constructed as an argv slice with a fixed program name and parameterised arguments. There is no shell interpreter on the Unix path, so there is no place for ;, $(...) or && to be interpreted. PowerShell, which is unavoidably a script interpreter, is invoked through the cmdexec.PowerShell and PowerShellContext wrappers as -NoProfile -NonInteractive -Command (powerShellFlags); collectors that assemble the argv themselves pass at least -NoProfile -Command. Any caller-supplied value embedded in a script is passed through PSQuote, which single-quotes the value and doubles interior quotes — including the Unicode quote characters ', ', ‚ and ‛ that PowerShell also treats as string delimiters.
Testability. UseMocks(), SetMockOutput(name, output), SetMockError(name, output, exitCode) and Reset() swap the package-level commandFunc for one that re-executes the test binary as a helper process emitting canned output. A test file opts in with:
func TestHelperProcess(t *testing.T) { cmdexec.HelperProcess() }This is the seam pattern applied to process execution: the production path is exec.Command, the test path is a function variable, and no test ever touches the real ps, netstat or Get-CimInstance.
The authentication chain
HTTP authentication is an ordered list of Authenticator implementations, tried before the older bearer/OIDC/introspection path:
type Authenticator interface {
Authenticate(r *http.Request) (*Identity, error)
Name() string
}The contract is three-valued. A returned *Identity ends the chain and authenticates the request. A returned error ends the chain and rejects the request, wrapped as "<name>: <error>". (nil, nil) means "this credential type was not presented" and the chain moves on.
POST / with credentials
│
▼
┌───────────────────────────────────────────────┐
│ config.Authenticators (order set in main.go) │
│ │
│ 1. API key auth_apikey.go │
│ hashed store, expiry, revocation, │
│ CIDR pinning, hot reload │
│ │ nil,nil → next │
│ 2. SSH signing auth_ssh.go │
│ signed request, timestamp + nonce │
│ │ nil,nil → next │
│ 3. mTLS auth_mtls.go │
│ verified client cert → SPKI / SPIFFE / │
│ DNS SAN / CN / OU identity mapping │
│ │ nil,nil → fall through │
└────────────┼──────────────────────────────────┘
▼
authenticateLegacy(r): Authorization: Bearer <token>
first configured wins, and does not fall through:
1. static token → Identity{Subject: "bearer-token",
ClientID: "static-token", Scopes: ["*"]}
2. SaaS JWKS validator
3. OIDC validator (oidc.go: JWKS fetch + cache,
iss/aud/exp/iat checks, jti replay,
RequiredScopes)
4. introspection (POST /introspect, result cached
60s keyed by SHA-256 of the token)
│
▼
*Identity{Subject, ClientID, Scopes, Method}
│
▼
ctx = WithValue(identityKey) ─▶ scope checks + audit
The legacy path is a precedence list, not a cascade: if BearerToken is set, a bad token is rejected there and the OIDC and introspection validators are never consulted. The static token is compared with == and grants the wildcard scope *, which is why MinBearerTokenLength is 32 characters and why a static token is the weakest of the supported credentials. The API key authenticator, by contrast, compares a SHA-256 digest with subtle.ConstantTimeCompare.
The client IP is attached to the context before the chain runs, because API key CIDR pinning and SSH from= options need it. getClientIP only believes X-Forwarded-For or X-Real-IP when TrustProxyHeaders is set, so a caller cannot forge the address that lands in the audit log.
Whether authentication runs at all is authConfigured(): any of a static token, a SaaS JWKS validator, OIDC, an auth server, a non-empty Authenticators list, or a client CA with client certificates required. HTTPConfig.validate() runs before the socket opens and refuses a non-loopback bind without both authentication and TLS unless the operator passed the explicit override. That check is pure — it makes no network calls — and is unit tested. See Remote access over HTTP.
The JWT and introspection paths share the bounded replay cache in internal/mcp/replay.go: at most 100,000 identifiers, expired entries evicted first, with a jti remembered until the token's own expiry. SSH request signing keeps its own cache of the same shape in internal/sshauth, remembering nonces for 10 minutes (DefaultNonceTTL).
Cross-cutting policies
Most of these are installed once in main() before any tool runs, as package-level globals, so no collector can be written that bypasses them by accident. The metrics registry is the exception: it builds itself lazily, guarded by a sync.Once, the first time an event is recorded.
| Policy | Package | Installed as | Effect |
|---|---|---|---|
| Redaction | internal/redact |
redact.Enable(provider) / redact.Disable() |
On by default. Eleven collector packages call into it — logs, security, compliance, scheduled tasks, process command lines, resource snapshots, security-audit and system-configuration parsing, web server configuration, Windows registry values and runtime options. |
| File access | internal/pathpolicy |
pathpolicy.SetGlobal(policy) |
Every tool that opens a caller-supplied path checks it: allowed roots, symlink target re-checked, regular files only, 1 MiB ceiling, deny list of key material. |
| Outbound probes | internal/netconfig |
netconfig.SetProbePolicy(policy) |
get_network_latency resolves first and vets every resolved address before connecting to the vetted IP, which defeats DNS rebinding. |
| Audit | internal/audit |
audit.Configure(cfg) |
On by default, hash chain always enabled (cfg.IncludeHash = true). Falls back to stderr if the file cannot be opened rather than stopping the trail. |
| Metrics | internal/metrics |
metrics.Init(), run once by sync.Once on the first recorded event |
Prometheus counters and histograms for HTTP requests, tool calls, tool errors and auth results. |
| Rate limiting | internal/mcp/ratelimit.go |
an HTTPConfig.Middlewares entry |
Token bucket per client address, in-flight cap, lockout after repeated auth failures. |
The result cap is enforced in the server rather than in a policy object. resultSize sums the text of a CallToolResult; above resultCap() (default 4 << 20, floored at 1 KiB by SetMaxResultBytes) the content is replaced with an error telling the caller to narrow the query, and the event is audited and counted as result_too_large.
Each tools/call outcome produces exactly one audit event and one metrics observation:
| Outcome | Audit result | Metrics error type |
|---|---|---|
| Unknown tool name | error (tool not found) |
not_found |
| Scope denied | denied (scope denied) |
scope_denied |
| Handler returned an error | error (the error text) |
execution_error |
| Result over the cap | error (result_too_large) |
result_too_large |
| Success | success |
— |
Audit logging documents the event shape and the hash chain; Redaction documents the providers and field coverage; Network and path policy documents the roots, deny lists and probe rules.
The hybrid agent: a second front door
internal/agent implements the outbound SaaS client used by --mode hybrid and --mode polling. It is the same binary and the same collectors, but it does not go through internal/mcp. QueryExecutor.executeQuery is a switch on the query name that constructs collectors directly:
case "get_cpu_info":
return cpu.NewCollector().Collect(false)
case "get_processes":
limit := getIntParam(params, "limit", 10)
sortBy := getStringParam(params, "sort_by", "cpu")
return process.NewCollector().GetTopProcesses(limit, sortBy)The architectural consequence is worth stating plainly: because the executor never consults the tool registry, neither the registration-time scope policy nor the per-request scope check applies to hybrid work, and --max-result-bytes is not enforced on that path. Authorization for hybrid mode belongs to the control plane that issues work items. Each item does run under its own deadline (the item's timeout, 30s default, 120s ceiling) with at most four concurrent queries, and an overrun is reported as timed_out rather than blocking the queue. Hybrid and SaaS mode sets out the full trade-off; where per-host enforcement is required, run the inbound HTTP transport instead, as in Fleet deployment.
Plugins
pkg/plugin defines a Plugin interface (Name, Version, Scope, Description, Register) and a registry. Plugins are compiled into the binary at build time using build tags, not loaded at runtime — there is no dlopen, no plugin directory and no code path that executes something the binary did not ship with. internal/plugins has two files: plugins.go under //go:build !enterprise whose RegisterPlugins is a no-op, and plugins_enterprise.go under //go:build enterprise. The released open-source binary contains the no-op.
Dependencies
The collectors themselves use the standard library and native OS interfaces; the module's direct requirements exist for the server and SaaS layers rather than for reading system state:
github.com/golang-jwt/jwt/v5 JWT parsing and validation
golang.org/x/crypto SSH key handling, bcrypt
golang.org/x/sys syscalls not in the standard library
github.com/prometheus/client_golang /metrics
github.com/google/uuid audit event IDs
github.com/coder/websocket hybrid mode transport
github.com/aws/aws-sdk-go-v2/... SQS fallback for hybrid mode
The module declares go 1.23.0. CI builds with Go 1.22 in ci.yml and the SLSA workflow, and the toolchain directive pins go1.24.4 locally.
Design invariants
Read the codebase with these in mind; a change that breaks one of them is a change to the security posture, not a refactor.
- Read-only. No collector writes, creates, deletes or signals. The files the process writes are its own: the audit log, and in the SaaS modes the credential and certificate store under the config directory. The
apikeyandservicesubcommands write the key store and the service unit. - No shell. All execution goes through
cmdexecwith parameterised argv. PowerShell scripts quote every interpolated value withPSQuote. - One scope per tool. The scope is set at registration and is the only thing consulted at both authorization stages.
- Empty, not error, off-platform. A tool that does not apply returns an empty document.
- Typed results. Every tool returns a
pkg/typesstruct marshalled to JSON; nothing returns raw command output. - Policy is global and installed before serving. Redaction, path policy and probe policy are process-wide, so a new collector inherits them.
- Registration is the only control without an identity. Anything that must not be reachable on stdio must not be registered.
Related pages
Security model · Scopes and authorization · Authentication · JSON-RPC API · Tool reference · Configuration reference · Contributing · Documentation home
Built 2026-09-19. Source: levantar-ai/secronyx. Found a mistake? Tell us.