100% free and open source · every tool, every host, no licence key · read the licence
Secronyx

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 windows

The 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) string

This 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.

  1. 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 apikey and service subcommands write the key store and the service unit.
  2. No shell. All execution goes through cmdexec with parameterised argv. PowerShell scripts quote every interpolated value with PSQuote.
  3. One scope per tool. The scope is set at registration and is the only thing consulted at both authorization stages.
  4. Empty, not error, off-platform. A tool that does not apply returns an empty document.
  5. Typed results. Every tool returns a pkg/types struct marshalled to JSON; nothing returns raw command output.
  6. Policy is global and installed before serving. Redaction, path policy and probe policy are process-wide, so a new collector inherits them.
  7. Registration is the only control without an identity. Anything that must not be reachable on stdio must not be registered.

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.