Security Model - joeylking/agent-runtime GitHub Wiki

Security Model

SECURITY.md in the repository is the authoritative statement of what the runtime defends against; it is organized by mechanism. This page restates it by who the attacker is, because that is how an evaluator usually asks the question: if the model is hostile, what holds? If the MCP server is? If another account on the machine is? If a dependency is?

For each position: what the runtime does, what it leaves to the operator, and the test or probe that backs the claim where docs/status.md names one. A claim with no test named is one the repository states without a dedicated test; it is marked as such rather than dressed up.

The premise comes from decision 3: repository content, dependency sources, tool output, and an MCP server's tool list are all untrusted input that reaches the model. The model is expected to be wrong, or to be talked into something. What the runtime guarantees is that whatever it concludes, the set of actions it can take is bounded by code it cannot influence.

Reporting a vulnerability

GitHub's private vulnerability reporting is not enabled on the repository: as SECURITY.md records, gh api repos/joeylking/agent-runtime/private-vulnerability-reporting returned "enabled": false on 2026-09-29. Until it is turned on, do not use the Security tab. Instead:

  1. Open an ordinary issue saying you have a report to make and asking for a private channel.
  2. Put no details in the issue, not even in the title.
  3. The maintainer follows up with somewhere private to send the report.

Never put a vulnerability in a public issue, a pull request, or a discussion. Once private reporting is enabled, prefer it: the Security tab, then Report a vulnerability, which opens a draft advisory only the maintainer can read. Expect an acknowledgement within a week. A fix ships as a patch release of the affected module with an advisory naming the versions.

Only the current release line is fixed: the latest minor of the core, and the latest tag of each nested module. A nested module names the core version it requires, so a core fix may need that module re-tagged too.

A hostile model

The model reads untrusted content and may be steered by it. Assume it asks for the worst thing available.

What the runtime does.

  • Nothing runs without the schema and the policy. Every request is recorded verbatim, validated against the tool's JSON schema, and then evaluated by a policy the agent has no handle to. Malformed arguments become an invalid_decision observation and never reach the policy. (TestDriver_InvalidArgumentsBecomeObservation, TestDriver_PolicyDenyIsObservation, TestDriver_PolicyAbortEndsRun)
  • A partial tool call cannot execute. A reply cut off by the output cap or refused carries no tool uses out of the adapter, and render.Decide records such replies as deliberately invalid decision kinds the driver will not act on. (TestDriver_PseudoDecisionKindsAreInvalid)
  • A policy is handed one form of the request. Since v0.5.0 the arguments and the tool's schema reach the policy as PolicyJSON: keys sorted, no insignificant whitespace, numbers as written, no HTML escaping, on the loop, on resume, through a gate, and in a re-check. Before, the loop handed the agent's own bytes and a resume handed the stored, HTML-escaped request, so a policy that matched bytes could decide differently for the same request. A model cannot choose a spelling that a byte-matching policy sees on one path and not another. (TestPolicy_LoopAndResumeHandedTheSameBytes, TestPolicy_GateProposeAndAttachHandedTheSameBytes, TestRecheck_ByteReadingPolicyDecidesAlike) See Upgrading to v0.5.
  • An approval binds the exact request. The hash covers kind, capability, presentation, and the request; the recorded request is what executes; policy and the tool's current schema are checked again on resume; only a step's latest approval can be a grant. The model cannot swap the action after a human said yes. (TestApproval_ApproveThenResumeExecutesRecordedRequest, TestResume_ApprovedArgumentsMeetTheCurrentSchema, TestResume_OnlyTheLatestApprovalOfAStepGrants)
  • What the operator reads is what the operator decides. cmd/agentrt approve and reject pass the hash of the approval they printed to ApproveShown or RejectShown, which compare it inside the deciding transaction, with the update conditional on it. (TestDecide_BindsToTheHashActuallyShown, TestApproval_ShownDecisionIsAtomic)
  • Model text cannot rewrite the operator's terminal. Every model-, policy-, or server-chosen field agentrt prints in text mode passes through trace.Sanitize: control characters, bidirectional overrides, zero-width and invisible characters, line and paragraph separators, and the tag block become visible escapes; piles of combining marks are cut. The approval prompt prints one field per line with its byte size, shows a string over 2000 bytes by head and tail, and ends with a line agentrt writes itself. A tool result cannot clear the screen, forge an approval block, or push the decision line off screen. (TestSanitize_BidiOverridesEscaped, TestSanitize_ZeroWidthAndInvisibleEscaped, TestShow_BidiAndCombiningMarksInArgumentsEscaped, TestShow_LongArgumentTruncatedHeadAndTail)
  • Pathological JSON is refused where it enters. Arguments, results, tool content, capabilities, presentations, and a reconciliation's result nested deeper than 256 levels, or carrying invalid UTF-8, an unpaired escaped surrogate, a repeated key, or a number past the bound below, are refused as an invalid decision, a tool error keeping the bytes, or a run failed as internal_error. Nothing stored or hashed is replaced by a stand-in. (TestCheckJSON_RefusesDeepNesting, TestDriver_DeepJSONIsRefusedWhereItEnters, TestCheckJSON_RejectsWhatWouldHashLossily)
  • A model-chosen number cannot end the process. In every release through v0.3.2 the schema library panicked validating a number whose exponent, written or implied, is beyond its reach, such as {"n":1e-10000000} or 0.1e-9223372036854775808, against minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf, or uniqueItems, and nothing recovered it: one argument ended a Driver consumer, or anything calling CheckArgs or ToolSchema.Check, whenever a tool's schema bounds a number. Since v0.4.0 consumer JSON is refused where it enters when a number's literal is longer than 10,000 characters or its exponent is beyond plus or minus 10,000 (arguments become an invalid_decision, tool content a tool_error, a capability fails the run, and a tool's input schema with such a number fails registration), and a panic inside validation is recovered as a refusal of the arguments, never an acceptance. Upgrade; until then, recover around Start, Resume, and CheckArgs. See Upgrading to v0.4. (TestCheckJSON_RefusesNumbersPastTheBound, TestDriver_HugeNumbersAreRefusedWhereTheyEnter, TestGate_HugeNumberIsAnInvalidDecision, TestCheckArgs_RefusesNumbersPastTheBound, TestValidate_APanicInTheLibraryIsARefusal)
  • It cannot run forever or spend without bound. Steps, consecutive failures, loops, model calls, tokens, cost, and time are capped by the driver, and token and cost limits stop before the call that would cross them. (TestDriver_StepLimit, TestDriver_IdenticalCallAndResultStops, TestModel_TokenAndCostLimitsProjected)
  • A crash does not let it ask for a side effect again. An interrupted side effect waits for the consumer's reconciliation or an operator. (TestResume_InterruptedSideEffectWaitsForAnOperator)

What it leaves to the operator.

  • What the policy allows. Prompt injection is not detected. If a policy allows a class of action, an injected instruction can use it. The runtime's answer is the bound, not detection.
  • What a tool does with its arguments. Schema validation says the arguments fit the schema; path traversal, command construction, and authorization inside a tool are the tool's job. See Writing Tools.
  • Secrets in the record. Decisions, arguments, observations, and presentations are stored verbatim. A tool that returns a secret puts it in the database and the audit log; nothing scans or redacts it.
  • An approved re-run. Approving an interrupted_side_effect runs a call that may already have taken effect. The approval says so; the choice is the operator's.

A hostile server or endpoint

This covers a model provider's endpoint, anything on the network path to it, and an MCP server, stdio or HTTP. Each is code this project did not write.

What the runtime does with a provider endpoint.

  • Usage it reports is not believed blindly. A negative count, more cached input than input, or a count that is not an integer or does not fit is charged the conservative estimate instead; the reply is not used, not retried, and the run fails as model_unavailable. Totals and costs saturate rather than wrap negative, so a huge count trips the limits. (TestModel_UnusableUsageIsChargedTheEstimate, TestModel_TotalsSaturateRatherThanWrap, TestUsageCount_OnlyANonNegativeIntegerThatFits, TestGenerate_UnusableUsageIsChargedNothing in each adapter)
  • A served but unusable reply is still charged. It comes back as a ServedError carrying the usage the provider reported. (TestModel_ServedErrorIsChargedAndNotRetried)
  • Redirects are not followed, by any adapter or by the MCP HTTP client: a 3xx is a permanent error naming only the host it pointed to, so the key and the prompt stay home. (TestClient_RefusesRedirects, TestGenerate_RedirectIsRefusedAndTheKeyStaysHome, TestHTTP_RedirectIsNotFollowed)
  • A key does not leave the machine over plain http. Over http:// a key goes only to exactly localhost or a literal loopback address, and only over a connection whose dialled address is loopback, so a resolver or a proxy cannot carry it off. (This holds for a client whose transport is an *http.Transport; a consumer's own RoundTripper is left as given.) (TestCheckEndpoint_KeyOverPlainHTTPOnlyToLoopback, TestLoopbackOnly_RefusesAConnectionThatLeavesTheMachine)
  • Keys stay out of logs. The OpenAI and Anthropic Config and Model print, marshal, and log redacted, and an Anthropic SDK error, which holds the request and its key header, is converted before it leaves the adapter. (TestConfigAndModel_EncodeWithoutTheKey)
  • Bodies are bounded at 16 MiB. (TestReadBody_RefusesABodyOverTheLimit)

What the runtime does with an MCP server.

  • It classifies nothing. A tool registers only with an operator rule naming its side-effect class; annotations may refuse a registration and never relax one. (TestLoad_UnclassifiedToolIsNotRegistered, TestLoad_HintMismatchIsRefusedAndOverridable)
  • The pin catches a changed schema or description, hashed from the raw tools/list bytes with keys matched exactly as the SDK matches them, so a page carrying both tools and Tools cannot pin one list and register the other, and a number past 2^53 is not rounded away. (TestLoad_DescriptionChangeRefusesTheTool, TestLoad_SchemaChangeRefusesOnlyThatTool, TestPin_RawListingMatchesKeysExactly, TestMessageTap_SkipsEventsTheSDKIgnores)
  • A schema cannot make the runtime read a file or fetch a URL. mcp.Load refuses a schema with an outside $ref, $id, or $schema, and the core compiler would refuse it again. (TestLoad_RefusesASchemaThatReachesOutsideItself, TestNewDriver_SchemaReferencesOutsideItselfAreRefused)
  • It cannot hang the client. Connecting, initializing, listing, and Load's checks fall inside Server.ConnectTimeout; a listing is bounded at MaxToolPages pages and MaxTools tools; an HTTP body is bounded at 16 MiB. (TestLoad_TheChecksFallInsideTheConnectTimeout)
  • It cannot override the operator's fixed values, nor set a denied parameter: such a call fails. (TestDeny_RemovedFromTheSchemaAndRefusedAtCall)
  • Its text cannot rewrite the terminal. The load report and Pin's and Load's errors are sanitized. (TestReport_StringEscapesWhatTheServerChose, TestPin_ErrorEscapesTheServersMessage)
  • Results are bounded by MaxContentBytes, counted on the encoded bytes. (TestCall_OversizedContentIsTruncated)
  • A stdio server gets a minimal environment (PATH, HOME, TMPDIR, plus what the operator names), runs in its own process group on Unix, and is killed with its descendants on Close; on Linux the kernel kills it if this process dies first. (TestStdio_CloseKillsTheServersDescendants; the environment rule has no dedicated row in docs/status.md.)

What it leaves to the operator.

  • What a server does when called. The pin covers what a server presents, not what it does. A server that behaves differently after Load, or differently from its description, is not caught. Its results are untrusted model input.
  • Files and network. A stdio server runs as the operator. The minimal environment withholds variables, not files: credential files under HOME (~/.aws, ~/.ssh, a token cache), anything else the account can read, and any directory on PATH it can write remain reachable, as does the network. Nothing sandboxes it.
  • Annotations and output schemas are outside the hashes; tools/list_changed is ignored until the next Load.
  • A provider that bills differently from what it reports within plausible numbers. Implausible usage is refused; plausible but wrong usage is believed.

Another user on the machine

Anyone who can write the database can forge an approval, because an approval's hash has no secret in it. This position is about the file.

What the runtime does.

  • The database is owner-only. A new database and its WAL files are created 0600 under any umask. A database or WAL file group or others can write is refused with ErrInsecureMode; one they can only read is tightened by a writer that owns it; a WAL file owned by another user, or a WAL path that is not a regular file, is refused. (TestStore_FilesAreOwnerOnlyUnderAnyUmask, TestStore_ModesInAChildProcess, TestStore_RefusesADatabaseOthersCanWrite, TestStore_TightensADatabaseOthersCanRead)
  • The operator opens what they named. OpenExisting, which agentrt uses, refuses a symbolic link with ErrSymlink and anything else that is not a regular file. (TestOpenExisting_RefusesASymbolicLink)
  • A crafted database cannot run its author's SQL. A file holding a trigger or a view is refused with ErrUnsafeSchema, and every connection runs with trusted_schema off and SQLite's defensive mode on. (TestStore_RefusesTriggersAndViewsAndHardensItsConnections)
  • A crafted database cannot exhaust the operator's memory. runs, show, and events read only the page they print, with every text column capped at MaxPageText and totals from COUNT. (TestStore_PagesAreBounded, TestRead_OnlyThePageIsReadFromAHugeDatabase)
  • The events are a hash chain, which finds corruption and a careless edit. Each event's hash covers the previous event's and its own fields as stored, written in the same transaction, across every run. agentrt verify and Store.VerifyEvents report the first event that was altered, deleted, or reordered by anything that did not rewrite the chain after it. (TestVerifyEvents_FindsTheFirstAlteredEvent, TestVerify_BrokenChainExitsOne)
  • Each refusal is one sentence. agentrt turns an insecure mode, a trigger or view, and a symlink into one sentence at exit 1. (TestOpen_InsecureFileModeRefusedWithOneSentence, TestOpen_TriggerCarryingDatabaseRefusedWithOneSentence, TestOpen_SymlinkRefusedWithOneSentence)
  • Recordings are private. replay.Recorder creates its directory 0700 and writes each recording 0600 through a temporary file of its own, synced, then renamed. (TestRecorder_WritesOwnerOnlyThroughItsOwnFile)
  • The examples keep their files private. examples/live and examples/mcp keep their database, sandbox, and manifest under os.UserCacheDir()/agentrt, created 0700, and examples/mcp refuses a manifest or rules file others can write. (TestReadOwned_RefusesAFileOthersCanWrite)

What it leaves to the operator.

  • The chain is not a defence against whoever can write the file. They can recompute every hash after the row they changed, or cut events from the end, and the chain still verifies. It shows the record unaltered only up to a head kept where that writer cannot reach, from agentrt verify or export.Follower.Head, and compared later with agentrt verify -head SEQ:HASH, which fails unless the chain reaches that seq intact and its event still has that hash, so a cut end is caught. (TestVerify_CutDatabaseFailsAKeptHead) Keeping the head somewhere else is the operator's job. Events stored before migration 6 were hashed when it ran, so their hashes say nothing about what happened to them before, and the state tables are not chained. An approval's own hash still binds the approval. See Re-checking a Run.
  • A re-check trusts what it reads. Recheck proves a policy's answer, not that the record is the one the runtime wrote. It also cannot hold a policy that reads outside state to its record, repo-steward's among them.
  • Root and the owner's own account. Whoever can write runs.db can approve anything in it. The mode checks make another account's write impossible, not the owner's. The main database file's owner is not checked, only its mode.
  • Identity. There are no users, roles, or authentication. -by and decided_by are labels recorded exactly as given.
  • Shared storage. SQLite on a network filesystem is unsupported, and so is clock skew between hosts sharing a database; leases and expiry read the local clock. See Run Lease.
  • Windows. The mode rules are skipped where file modes do not mean owner, group, and others.

Someone who can reach the approval channel

approver/webhook is the reference portable approval channel, added in v0.3.1. It is a position of its own, because whoever can reach it over the network can ask it to decide approvals, and the attacker here is a network peer or a holder of a captured request. It is not part of the core's guarantees unless an operator runs it; see Approval Channels.

What it does.

  • Every request is signed, reads included. Each carries a timestamp, a nonce, and an HMAC-SHA256 under a shared secret from configuration, never the URL, computed over the timestamp, nonce, method, path, and body (X-Approval-Timestamp, X-Approval-Nonce, X-Approval-Signature), and compared in constant time. The secret must be at least 32 bytes. (TestHandler_SignatureSkewAndReplay)
  • A captured request cannot be replayed. A timestamp more than Config.Skew from the handler's clock, five minutes by default, is refused, and so is a signature seen before within the window.
  • It is bounded before it reads. Requests are rate limited before their bodies are read, by default 10 a second with a burst of 20, and a body over Config.MaxBody, 16 KiB by default, is refused with 413. (TestHandler_BodyAndRateBounds)
  • What the channel showed is what it decides. A decision must name the run and approval of the path, the hash the caller was shown, and an identity, and is recorded through agentrt.ApproveShown or RejectShown. (TestHandler_HashBindsAgainstAConcurrentChange, TestHandler_StaleHashIsRefusedAndNothingDecided)
  • Every route names one run and nothing lists runs. A caller holding one run's id learns nothing about another's. (TestHandler_NotFoundAndNoCrossRunAccess)
  • It honours expiry: an expired approval is shown as 410, and a decision on it runs the runtime's own expiry path. (TestHandler_ExpiryIsReportedAndTheRuntimeExpires)
  • Model-chosen text in the plain-text rendering is escaped with trace.Sanitize, and the last line is one the renderer writes itself. (TestHandler_TextRenderingIsSanitised)

What it leaves to the operator.

  • Identity. The handler has no users or sessions: by is a label, the secret is the only authentication, and whoever holds it can decide any approval whose run id they know. Hold it where the operator's chat or web front end runs, not in a browser.
  • Anyone who can write the database can approve without it. The database, not the handler, is the trust boundary.
  • Transport. The handler speaks plain HTTP and must sit behind TLS and a network boundary the operator controls. A proxy that adds a path prefix must strip it before the handler, so the signed path is the one it sees.

A caller of a gate

A gate, added in v0.4.0, holds the same bounds for a loop the runtime does not own. This position is about what the caller's loop is trusted with.

What the gate does.

  • What runs is the recorded or approved request. The caller proposes, and the gate validates the arguments, evaluates policy, binds approvals by hash, enforces the limits, takes the lease, and executes the tool itself. The verdict's request is a copy, so a caller that changes it changes nothing about what runs, and after an approval Execute runs exactly the recorded request, whatever the caller does to the verdict it was shown. (TestGate_ApprovedStepExecutesTheRecordedRequest)
  • A grant is judged again at Execute. A caller can hold an approved step and wait where a Driver cannot, so Limits.GrantTTL is judged at Attach and again at Execute on a fresh clock reading. A grant that expired since cancels the run as approval_expired, executes nothing, and ends the session. This was never in a release, because the gate is new. (TestGate_GrantTTLHoldsUntilExecute)
  • A session that is abandoned leaves what a crash would. An abandoned allowed step runs nothing, and a step abandoned during Execute waits for an operator as an interrupted side effect. (TestGate_AbandonedAfterAllowedRunsNothing, TestGate_AbandonedDuringExecutePausesForAnOperator)
  • A session whose lease was lost refuses every call and starts nothing. (TestGate_CallsAfterTheLeaseIsLostAreRefused)

What it leaves to the operator.

  • The caller's loop is trusted as an Agent implementation is. It is the consumer's own code in the consumer's own process. The gate bounds what the loop can make happen, not what it proposes, and a loop that is steered by untrusted content is bounded as a steered model is.
  • A caller that calls its own tools outside the gate is outside every control here, as an agent's tool code is.
  • The gap between Propose and Execute of a fresh step is covered by the lease and by no other limit; the time limits are checked when the step starts.

The MCP proxy

agentrt-proxy (MCP Proxy) applies the gate to the tool calls of an MCP host, a program the operator runs and the proxy does not control. Its scope is the first thing to know: it governs only calls that go through it. A host that gives the model a shell, file access, or tools of its own lets the model bypass the proxy, and approve its own request with the operator command or by writing the database. The proxy does not separate the host's user from the operator, sees no model call or prompt, and has no limit or policy over a conversation, since each call is its own run.

What it trusts.

  • Its configuration file and the manifests, read only when the current user owns them and their group and others cannot write them (mcp.ReadOwned), and refused with a repeated key, because whoever writes them chooses how every tool is classified. (TestConfig_RefusesFilesOthersCanWrite, TestConfig_RefusesWhatCouldReadTwoWays, TestReadOwned_RefusesAFileOthersCanWrite)
  • The database, under the rules above, and its file's owner, which the proxy also checks before and after opening it, refusing a symbolic link as agentrt does.
  • The operator's decisions, made with agentrt or the webhook approver on that database.
  • The upstream servers' reach, which must not include the proxy's configuration, manifests, or database. The servers run as the same user as the proxy, and a server that can write them can rewrite the policy or forge an approval. The proxy refuses to start a stdio server whose arguments name one of them or a directory holding one, in the spellings it reads, unless the configuration sets skip_path_check for it. That is a best-effort guard against a mistake in the configuration, not a boundary: it does not see what a server can reach by other means, and it protects neither the proxy's binary nor the host's own configuration. (TestConfig_RefusesAServerThatReachesTheProxysFiles, TestConfig_ReachCheckReadsCommonSpellings)

What it does not trust.

  • The host and the model behind it, whose calls are requests the gate decides, and the upstream servers, as for MCP Tools. Every call is checked against the pinned, restricted schema and evaluated by the policy. What executes after an approval is the recorded request, under the operator's fixed values as they were approved, never the bytes of a later call. (TestProxy_ApprovalRoundTrip, TestProxy_ChangedFixedValueAsksAgain)
  • Nothing the model is shown names the operator command, the database, the configuration, or a run id, and the proxy offers no tool that approves and asks the host nothing, with no elicitation or sampling. (TestProxy_ModelVisibleTextNamesNoOperatorPath)

What else it does.

  • Calls are matched by agentrt.CanonicalJSON of their arguments, the form an approval's hash binds, so only an identical request collects an approval or counts as a repeat. Arguments the runtime refuses as JSON match nothing and are recorded invalid. (TestRequestKey_NoCollisions, TestProxy_ArgumentsTheRuntimeRefusesNeverCollect)
  • A call that executed is not executed again for an identical call within the repeat window, so a host's blind retry does not repeat a side effect. A mutating call whose server answered with content the runtime refused counts as executed. (TestProxy_HostCancelLetsTheCallFinish, TestUnknown_UnrecordedResultCountsAsExecuted)
  • A mutating call whose outcome is unknown is not run again as the identical request without an operator's approval, at any age and after a rejection. Every other mutating call to its tool is refused until an operator resolves it, by approving a re-run whose outcome is then known, or by rejecting it. An expiry resolves nothing, and the question is asked again, so the tool is never blocked with nothing for an operator to act on. (TestUnknown_TimeoutAfterTheEffectWaitsForAnOperator, TestUnknown_RejectionUnblocksAndAnIdenticalCallAsksAgain, TestUnknown_ExpiryAsksAgainRatherThanUnblocks)
  • Text the model, a server, or the operator chose is escaped with trace.Sanitize in the prose the proxy writes, to the model and to stderr. A tool's result returned to the model is passed back as the server sent it, as data. stdout carries the protocol only. (TestProxy_ServerTextInProseIsEscaped)
  • It opens no network listener. The host reaches it over its own stdin and stdout.
  • It writes pin manifests 0600, and refuses a configuration, manifest, or database another user owns or its group or others can write. (TestCLI_PinAndCheck)

What it leaves to the operator.

  • What the host lets the model do besides the proxy. See the scope above. These limits are stated first in docs/proxy.md and in the command's help (TestCLI_HelpStatesTheScope).
  • A request spelled differently. 7 for 7.0, or with an optional field added, is a different request: after a rejection it runs under the ordinary policy. An approval for a blocked tool is still collected, including one granted after the block began.
  • What a pinned server does. The pin covers what a server presents, not what it does, as for mcp.
  • Re-running an unknown outcome. An operator who approves an interrupted_side_effect runs a call that may already have taken effect. The approval says so; the choice is the operator's.

Where exported events go

export.Follower, added in v0.3.1, only reads: it opens the database read-only and never writes. The OpenTelemetry exporter in export/otel cuts model-chosen text (goals, reasons, arguments, messages, a provider's error text) at a bound and never uses it as a span name, but what remains is still sent to whatever collector the operator configured. The runtime does not redact it, and a collector is outside what the runtime defends. A tool that returned a secret has already put it in the database and the audit log, and the exporter forwards a bounded copy of what it maps. (TestExporter_TruncatesModelChosenText) See Exporting Events. Since v0.5.0 the follower can also name the head of the event chain it has delivered up to (Follower.Head), which is worth keeping where the database's writers cannot reach; export/otel carries a decision's policy_id and spec_hash as span attributes.

The supply chain

What the project does.

  • Few dependencies, isolated. The core module has exactly two direct dependencies, a JSON Schema validator and SQLite. A provider SDK or the MCP SDK lives in a nested module with its own go.mod, so a vulnerability in one never reaches the core.
  • The example server is pinned. examples/mcp runs the filesystem server pinned by version and integrity hash in package-lock.json, installed with npm ci --ignore-scripts and started with node, instead of whatever npx fetched that day. (TestServer_RunsThePinnedPackageWithNode)
  • CI is locked down. Checkouts set persist-credentials: false, every job runs go mod verify before building, workflows run with read-only permissions, and every action is pinned to a commit. govulncheck runs in the lint job and the release check. Dependabot holds a version back before proposing it: 3 days for a Go patch, 7 for a minor, 14 for a major, 7 for GitHub Actions. (Implemented; .github/workflows/ci.yml, release-check.yml, dependabot.yml)
  • Releases are verifiable. A re-pin is never pushed through by bypassing the checksum database, and a pushed tag is never moved. See Releasing.
  • Known advisories are patched. v0.3.0 moved every module past GO-2026-5970 in golang.org/x/text (the affected symbol was not called) and patched modernc.org/sqlite and golang.org/x/sys.

What it leaves to the operator.

  • The MCP servers you choose. Pinning examples/mcp's server is the example's choice; a consumer pins its own.
  • Memory safety of the SQLite driver. modernc.org/sqlite is C SQLite transpiled to Go, outside the memory-safety guarantees the rest of the program has, as a cgo binding to the original C would be.
  • The process. The agent, tools, policy, and runtime share one address space. There is no sandbox: a tool can do whatever the process can, and a tool's side-effect class is a claim the operator makes, not something the runtime verifies.

Next

Safety Properties is the same ground stated as guarantees. Run Lease and Persistence and Inspection are the mechanisms behind the third position, Approval Channels the one behind the approval channel, The Gate and MCP Proxy the last two positions, and Exporting Events what leaves the machine.