Non-custodial TypeScript SDK and pre-flight policy interception firewall for AI agents on Stellar.
An autonomous agent holding a wallet has a single point of failure: one prompt-injection or one buggy loop can drain it. Stellar Agent Guard makes that impossible on-chain — the agent's funds stay in its own smart account, and every transaction the account must authorize is intercepted by the contract's and rejected pre-broadcast unless it satisfies the operator's installed policy: per-transaction spend caps, a rolling-window spend limit, recipient/asset allowlists, protocol allowlists, a pause switch, and a dead-man switch. This SDK provides the integration layer: pre-flight simulation interception, zero-broadcast fee estimation, agent-auth transaction signing, and dual-stream event telemetry for AI agent frameworks (LangChain, ElizaOS).
Status: Phase 2 complete — and the package is published. stellar-agent-guard-sdk@0.1.1 is live on the npm registry (npm install stellar-agent-guard-sdk). All five enforcement scenarios were proven against live Stellar testnet (protocol 28) with real contract IDs, transaction hashes, and diagnostic events — evidence is recorded in tests/fixtures/integration-evidence.md. Phase 2 code is merged into main with green CI (ci status check). For historical release notes and publish pipeline reconciliation, see docs/publishing-history.md.
Enforcement happens inside the account itself, via Soroban's native Custom Account Abstraction — not in a wrapper contract in front of funds, and not in an off-chain service.
- Pre-flight simulation without broadcast: The SDK evaluates guard approval against Soroban RPC before a single byte hits the network. If the transaction violates policy, it is rejected client-side with the contract's own reason code, incurring zero network fees.
- Dual-stream telemetry: Blocked decisions never commit to the ledger because Soroban rolls back failed authorizations. A listener that only tails committed ledger events sees a guard that appears to approve everything. The SDK extracts
event_auth_checkedfrom simulation diagnostics as well as committed blocks. - In-process simulation pricing:
CostPreCheckercomputes network resource and inclusion fees directly from the enforced simulation, avoiding dependencies on external profiling tools. - Framework middleware: Plug-and-play middleware for LangChain and validators for ElizaOS halt execution before external tool calls run.
⚠️ Disclaimer: This is unaudited security tooling that gates real fund access. Do not deploy to mainnet without an independent audit. See the contracts repo's SECURITY.md.
- Pre-flight policy interception (
PreFlightInterceptor): Intercepts contract calls before broadcast, simulates auth authorization, and returns a discriminatedadmissible,blocked, orundeterminedverdict. Never throws on policy refusal; an opt-in short-lived cache can reduce repeated simulation RPC calls within the current ledger. - In-process cost pre-checking (
CostPreChecker): Prices transaction execution from simulation results, reporting resource fees, inclusion fees, and total fees against an optional ceiling. - Autonomous transaction execution (
invoke()): Executes the full Soroban lifecycle: probe simulation, auth signing for custom accounts, enforced simulation, and broadcast with bounded retry for stale ledger resource limits (scecExceededLimit) and minimum-fee rejections (tx_insufficient_fee). - Framework adapters:
createLangChainGuardMiddleware: Halts tool execution if the interceptor blocks the planned action.createGuardValidator: ElizaOS action validator returning boolean verdicts before actions run.createVercelAIGuard: Vercel AI SDK tool wrapper asking the guard before a tool'sexecuteruns.
- Telemetry listener (
GuardTelemetryListener): Tails both committed events and diagnostic streams, decoding contract topics and reason codes. Opt-in sliding-window counters expose agent self-monitoring viastats().
⚠️ Trust & limitations: pre-flight is an advisory, zero-broadcast guardrail — it reports what the simulation predicts the guard will do, and it can be wrong if the RPC lies. It does not replace the contract: every authorized call is enforced on broadcast by__check_auth, and a malicious RPC can feed a fake pre-flight verdict but cannot forge the agent's signature or the guard's own decision event. Seedocs/threat-model.mdfor the full threat model (verdict-inversion, stale-cache verdicts, compromised runtime, fake-RPC simulation, single-dependency supply chain).
A verdict is a prediction, not a settlement guarantee. Before relying on a pre-flight approval, read Fidelity & limits — the four known gaps in simulation fidelity, and what the SDK does about each.
npm install stellar-agent-guard-sdkModule Format & Environment Note:
stellar-agent-guard-sdkis published strictly as pure ESM ("type": "module") targeting Node.js >= 24.0.0 (declared inengines).If your project or toolchain runs in CommonJS (e.g. legacy LangChain setups, Jest configs, or
.cjsscripts), load the SDK using the dynamicawait import()pattern:// CommonJS (.cjs or package without "type": "module") async function run() { const { PreFlightInterceptor, CostPreChecker } = await import("stellar-agent-guard-sdk"); // use interceptor, cost pre-checker, etc. }
(Or build locally from source with Node 24+)
git clone https://github.com/aigbagbobila/stellar-agent-guard-sdk.git
cd stellar-agent-guard-sdk
npm ci
npm run buildThe unit suite needs no credentials. The live suite (npm run test:integration)
runs real transactions against the deployed Phase 2 testnet instance and reads
its signing keys from a gitignored .env.phase2. Start from the committed
template, which documents every key and what each one unlocks without carrying a
value:
cp .env.phase2.example .env.phase2 # then fill in the values
npm run deploy:phase2 # or provision a fresh instance and write it for you
npm run test:integrationtests/integration/harness.ts validates the file up front. When it is
incomplete it fails once with every missing key named — not one key per run,
which would make setup a guessing game of five round trips:
.env.phase2 is incomplete: 3 required key(s) are missing:
- PHASE2_ADMIN_SECRET
- PHASE2_RECIPIENT_SECRET
- PHASE2_OUTSIDER_SECRET
Copy the documented template and fill it in: cp .env.phase2.example .env.phase2
Or provision a fresh instance (writes the file, including PHASE2_ISSUER_SECRET): npm run deploy:phase2
The required keys are PHASE2_GUARD, PHASE2_TOKEN, PHASE2_ADMIN_SECRET,
PHASE2_AGENT_SECRET, PHASE2_RECIPIENT_SECRET and
PHASE2_OUTSIDER_SECRET, plus optionally PHASE2_RPC_URL;
PHASE2_ISSUER_SECRET is additionally required to (re)deploy. .env.phase2 is
gitignored (as are all .env.* values files — only *.example templates are
committable); never commit the filled-in copy.
import { Keypair, rpc } from "@stellar/stellar-sdk";
import { PreFlightInterceptor, isContractAddress, type ContractAddress } from "stellar-agent-guard-sdk";
// Validate contract address from environment
const guardAddress = process.env.GUARD_ADDRESS;
if (!isContractAddress(guardAddress)) {
throw new Error(`Invalid guard contract address: ${guardAddress}`);
}
// For known hardcoded addresses, use type assertion
const contractAddress = "CDCYDGBGS5AZ5BZS6XY2SK2PHJHSOEGTN3N4INCK34KF6GU2BGC7Z6MB" as ContractAddress;
const interceptor = new PreFlightInterceptor({
server: new rpc.Server("https://soroban-testnet.stellar.org"),
networkPassphrase: "Test SDF Network ; September 2015",
guard: guardAddress, // Type-safe: validated as ContractAddress
agent: Keypair.fromSecret(process.env.AGENT_SECRET!),
source: Keypair.fromSecret(process.env.SOURCE_SECRET!),
});
const decision = await interceptor.check({
contract: contractAddress,
fn: "transfer",
args: [/* from, to, amount */],
});
if (decision.kind === "admissible") {
console.log("Allowed! Resource fee:", decision.estimatedResourceFee);
} else if (decision.kind === "blocked") {
console.log("Blocked by guard:", decision.reason);
} else {
console.log("Undetermined (fails closed)");
}Both framework adapters wrap the same PreFlightInterceptor but expose different contracts, so the same verdict maps to different observable behavior per adapter. The table below is the source of truth enforced by the shared adapter test harness (tests/integration/adapters.test.ts); each row is one adapter × one verdict, and the expected-behavior columns differ only where the adapter contracts say they differ.
| Verdict | LangChain (createLangChainGuardMiddleware) |
ElizaOS (createGuardValidator) |
|---|---|---|
admissible |
Passes through; the wrapped tool handler runs. | Returns true; the action is a candidate for execution. |
blocked(reason) |
Halts the tool call and returns a ToolMessage carrying the contract reason and explanation; the tool handler never runs. |
Returns false; the action is excluded from candidate execution. |
undetermined(cause) |
Halts the tool call with a formatted ToolMessage (fails closed); the tool handler never runs. |
Returns false (fails closed); the action is excluded from candidate execution. |
Mapping error (toContractCall returns null/malformed) |
Halts with a formatted ToolMessage describing the mapping failure; the tool handler never runs. |
Returns false; the action is excluded from candidate execution. |
This version introduces branded types to distinguish contract addresses (C...) from account addresses (G...) at compile time, preventing a common source of bugs where an address is used in the wrong context.
Type Guards:
import {
isContractAddress, // Validates C... addresses
isAccountAddress, // Validates G... addresses
isStrKeyAddress, // Validates any StrKey (C... or G...)
isPublicKeyHex, // Validates 64-char hex public keys
} from "stellar-agent-guard-sdk";
// Runtime validation before use
if (!isContractAddress(userInput)) {
throw new Error("Expected contract address (C...)");
}Migration: If upgrading from an earlier version, see MIGRATION.md for guidance on updating your code to use typed addresses.
This version introduces branded types to distinguish contract addresses (C...) from account addresses (G...) at compile time, preventing a common source of bugs where an address is used in the wrong context.
Type Guards:
import {
isContractAddress, // Validates C... addresses
isAccountAddress, // Validates G... addresses
isStrKeyAddress, // Validates any StrKey (C... or G...)
isPublicKeyHex, // Validates 64-char hex public keys
} from "stellar-agent-guard-sdk";
// Runtime validation before use
if (!isContractAddress(userInput)) {
throw new Error("Expected contract address (C...)");
}Migration: If upgrading from an earlier version, see MIGRATION.md for guidance on updating your code to use typed addresses.
The same doctrine extends to adapter construction: misconfiguration throws at construction; blocked actions return/throw as documented. Adapter factories (createLangChainGuardMiddleware, createGuardValidator) validate their options synchronously before returning, so a missing toContractCall, an interceptor lacking a check method, or an empty options object fails fast at setup — never mid-loop on the first live tool call. See Adapter options validation below.
Pre-flight policy interception makes an intentional asymmetric distinction between programmer errors and policy outcomes:
- Input validation throws
InvalidInputError(synchronous): If aContractCallis malformed (invalid StrKey contract ID, missing or non-symbol-shaped function name, invalid arguments array, or non-i128amount),interceptor.check()throwsInvalidInputErrorsynchronously without dispatching any network RPC request. - Policy refusals return a verdict (
kind: "blocked"): When input is valid but policy disallows the action (spend cap exceeded, recipient not allowlisted, account paused), this represents expected guardrail operation.check()returns{ allowed: false, kind: "blocked", reason, explanation, ... }instead of throwing. - Callers requiring a throw-on-refusal flow can use
interceptor.assertAllowed(call), which throwsGuardBlockedErroronblockedandPreFlightUndeterminedErroronundetermined.
Adapter factories validate their options synchronously at construction time, before any action runs. A misconfigured adapter throws a typed GuardError (subclass AdapterConfigError) naming the offending field and its expected shape — it never surfaces as a mid-loop failure on the first production tool call.
Pre-flight simulation is a prediction made against one ledger snapshot, not a settlement guarantee. These are the four known limitations of that prediction, each with the mitigation the SDK applies and the residual risk that remains. Every claim is pinned to the function that implements it so a reviewer can check it rather than trust it.
-
Window state can move between
checkand broadcast (concurrent spenders).- Mitigation:
PreFlightInterceptor.check()never broadcasts; it callsenforceCall()(src/invoke.ts) to run the guard's real__check_authagainst live ledger state, which is what makes a refusal free. Underinvoke(),withAccountQueue()(src/invoke.ts) serializes fetch → build → submit per source account, and the committed contract re-evaluates the rolling window atomically. If the window moved anyway, the post-inclusion refusal is still reported asblockedwithcharged: true(src/invoke.ts). - Residual risk: a spender that does not route through this SDK's queue, or that uses a different source account, can consume the window after an
admissibleverdict; the transfer can then be refused on-chain and, when it reaches inclusion, charged. The optional cache reuses verdicts within one ledger, so a cachedadmissiblecan be staler than one admitted transfer (src/preflight.ts).
- Mitigation:
-
The fee market can move (fee-bump).
- Mitigation:
CostPreChecker.checkWithCost()andcostOf()(src/cost.ts) derive the quote from the same enforced simulation that produced the verdict, so price and verdict never describe two snapshots, andfeeBreakdown()uses the sameINCLUSION_FEEthe submitted envelope declares (src/tx.ts). - Residual risk: Soroban fees are dynamic and the SDK does not implement fee-bump handling. A fee-bump or an inclusion/base-fee change between simulation and broadcast can move the real price.
maxFeeStroopsinCostPreCheckeris a pre-flight ceiling, not a network guarantee.
- Mitigation:
-
Simulation does not execute — return-value-dependent effects are invisible.
- Mitigation: the enforced simulation runs the real
__check_auth(Step 3 ofenforceCall(),src/invoke.ts), and refusals are recovered from simulation diagnostics byreasonFromDiagnosticEvents()(src/invoke.ts) anddecodeAuthDecision()(src/events.ts) even though a rolled-back block never commits to a ledger. - Residual risk: simulation rolls back its writes; it cannot reveal effects that depend on a return value or on state another call would produce (an oracle price, a swap quote, a balance read mid-transaction). Such a call can be
admissiblepre-flight and still behave differently on-chain. TelemetrytelemetryFromDecision()(src/telemetry.ts) observes the decision event, not the call's return value.
- Mitigation: the enforced simulation runs the real
-
Multi-context batch semantics are approximated (batch).
- Mitigation:
PreFlightInterceptor.checkBatch()andpreflightBatch()(src/preflight.ts) simulate each call in sequence with staged window accounting, so a call that passes in isolation but pushes cumulative spend pastwindow_capis reportedblockedwithwindow_cap_exceeded. - Residual risk: it is sequential single-call simulation, not atomic batch simulation. State mutations between calls other than guard window spend are not observed, intra-batch window expiry is not modelled, and
totalEstimatedResourceFeeis the sum of per-call estimates rather than one envelope's fee (src/preflight.ts:571). The intended fix is to route to a contract-sidecheck_batchonce it lands, keeping this sequential staging as the fallback.
- Mitigation:
PreFlightInterceptor always performs a fresh simulation by default. For agent
loops that repeatedly check the same call, caching can be enabled explicitly:
const interceptor = new PreFlightInterceptor({
server,
networkPassphrase,
guard,
agent,
source,
cache: {
ttlLedgers: 1, // maximum one approximate five-second ledger window
policyRevision: () => readPolicyRevision(),
},
});
const first = await interceptor.check(call);
const second = await interceptor.check(call); // may reuse the first verdict
interceptor.invalidate(); // clear all entries
interceptor.invalidate(call); // clear one call's entriesThe cache is disabled unless cache is supplied. It stores only actual
admissible and blocked decisions; transient undetermined results are not
cached. A cache key includes the contract, function, canonical XDR argument
fingerprint, interceptor identity, and the supplied policy revision. The cache
is discarded when the observed ledger advances, when the TTL expires, or when
invalidate() is called. ttlMs and ttlLedgers are both capped at one
approximate ledger-close interval; if both are supplied, ttlMs takes
precedence.
A cached verdict can be staler than one admitted transfer. The rolling spend window can change after a simulation while a cached result is still being reused, so callers that cannot tolerate that tradeoff should leave caching off, use a shorter TTL, provide a policy revision, and invalidate after policy or account-state changes.
import { CostPreChecker } from "stellar-agent-guard-sdk";
const checker = new CostPreChecker({
interceptor,
maxFeeStroops: 50_000n,
policySource: "CAPADGEK457RHKN4RYVUMDJTFHDSG7R5HREQONKLYK7MFKC5WFENPP44", // opt-in policy source
});
const result = await checker.check({
contract: "CDCYDGBGS5AZ5BZS6XY2SK2PHJHSOEGTN3N4INCK34KF6GU2BGC7Z6MB",
fn: "transfer",
args: [/* from, to, amount */],
});
console.log("Cost verdict:", result.kind); // within_budget | over_budget | blocked | undetermined
console.log("Policy context:", result.policyContext);When an opt-in policySource (contract address or GuardPolicy/PolicyConfig) is configured in options, CostPreCheckResult exposes additive policyContext:
perTxCapOk: Whether the transaction fits within the policy's per-transaction cap (trueif within cap,falseifper_tx_cap_exceeded, ornullif not determinable).windowRemainingEstimate: Estimated remaining budget in the current rolling window. Alwaysnullwhen the contract does not expose sufficient window state.nullmeans "not available / cannot be determined from the current contract state", not "zero remaining budget". The SDK never fabricates a zero budget.reason: The contract's policy block reason (e.g."window_cap_exceeded"or"per_tx_cap_exceeded"), ornullwhen allowed.- When no policy source is configured in options,
policyContextisnull(no policy call is made).
Time-dependent operations (cache TTL, transaction polling) support optional Clock injection for deterministic testing without real delays.
For tests, use FakeClock to control time:
import { FakeClock, PreFlightInterceptor } from "stellar-agent-guard-sdk";
test("cache entry expires", async () => {
const clock = new FakeClock(0);
const interceptor = new PreFlightInterceptor({
server,
guard,
agent,
source,
cache: { ttlMs: 5000 },
clock, // Inject the fake clock
});
const decision1 = await interceptor.check(call);
// Advance clock without real delays
clock.advance(6000); // Skip to t=6000ms (past the 5000ms TTL)
const decision2 = await interceptor.check(call); // Cache expired, fresh lookup
});For production, no action is needed: modules default to the system clock. The Clock interface is purely optional and for testing.
Key methods on FakeClock:
now()— returns current time in millisecondssleep(ms)— returns a promise (resolves instantly when time allows)advance(ms)— move the clock forward deterministicallysetTime(ms)— set clock to an absolute time
Time-dependent modules (preflight cache, transaction polling) accept an optional clock parameter. When omitted, they use the system clock (Date.now(), real setTimeout). Tests pass a FakeClock to eliminate real waits and make timing deterministic. For full guidance, see CONTRIBUTING.md under "Deterministic time control in tests".
invoke() accepts an optional onStep callback. When omitted, behavior is
exactly as before — the hook is pure observability and writes nothing itself
(what you do with the events is up to you; for the SDK's own diagnostics see
Optional logging):
const outcome = await invoke({
server,
source,
call,
networkPassphrase,
guardAuth,
onStep(step) {
// consumer decides how to display/log the event
console.log(`[${step.attempt}] ${step.name} ${step.status} in ${step.durationMs}ms`);
},
});Event shape (InvokeStepEvent):
| Field | Meaning |
|---|---|
name |
Pipeline stage: probe → sign → simulate → broadcast (the shared TRACE_STEP_NAMES vocabulary). |
status |
start (emitted immediately before the stage runs), then ok or fail. |
durationMs |
Elapsed time of this stage attempt in milliseconds — not the total invoke() duration. Always 0 on start. |
attempt |
0-based retry index. 0 for the first pass; 1 on the built-in stale-ledger re-run. Always present. |
The callback is optional, receives every stage attempt (a retried invoke emits
a full probe → sign → simulate → broadcast sequence per attempt, each tagged
with its attempt index), and callback exceptions are isolated: a throwing
onStep never breaks the pipeline, never turns a successful invoke into a
failure, and never masks the original pipeline error — callback errors are
swallowed silently, because the callback is the caller's own code and not the
SDK's sink to report through. Step names come from the same shared vocabulary
the dry-run trace uses (TRACE_STEP_NAMES), so consumers of either see
identical stage names.
The LangChain adapter exposes the same capability:
const middleware = createLangChainGuardMiddleware({
interceptor,
toContractCall: (request) => (/* ... */),
onStep(step) {
// enforcement-stage events (probe → sign → simulate; never broadcast)
},
});A library that prints pollutes its host's logging pipeline — structured logs,
level filtering, redirection — so the SDK writes nothing by default. The
audit behind that claim, run for this change: src/ contained exactly one
console.* call, an env-gated debug dump in the invoke pipeline
(SAG_DEBUG_RESOURCES=1). It is gone, and its description now goes to an
injected logger, or nowhere. A unit test scans src/ for console calls and
direct stdout/stderr writes, so a new one fails npm test rather than quietly
appearing in a host's output.
What the SDK knows is still available, through an optional logger on every
config that has a decision point — PreFlightInterceptor, CostPreChecker,
GuardTelemetryListener and invoke(). Pass any object with the four levels;
console itself satisfies the interface, as does a pino or winston child
logger. Each call gets a complete message plus optional structured detail, so a
logger that ignores the detail still reads well:
import { PreFlightInterceptor } from "stellar-agent-guard-sdk";
const interceptor = new PreFlightInterceptor({
server,
networkPassphrase,
guard,
agent,
source,
logger: {
debug: (message, meta) => log.debug({ ...meta }, message), // stages, cache hits, retries
info: (message, meta) => log.info({ ...meta }, message), // a guard refusal
warn: (message, meta) => log.warn({ ...meta }, message), // undetermined, fee ceiling, coverage gap
},
});
const decision = await interceptor.check(call); // same verdict, same fees — now audibleA level the host omits is dropped rather than routed elsewhere, and a level that
throws is isolated: a broken sink cannot turn an admissible verdict into an
error, change a retry count, or fail a broadcast. Logging is observability, and
observability never decides whether a transaction runs. logger: console is
the one-liner that restores the removed SAG_DEBUG_RESOURCES=1 behaviour
deliberately, instead of a library making that choice for its host.
When deploying fleets of hundreds or thousands of autonomous agents derived from identical templates or scheduled loops, fixed poll intervals (e.g. exactly every 5s) cause all instances to poll RPC nodes in lockstep phase. This creates synchronized traffic spikes (thundering herds) against public Soroban RPC endpoints, triggering aggressive HTTP 429 rate limits and cascade backpressure errors.
GuardTelemetryListener.watch() defaults to jitter: 'full', which uniformly randomizes each poll delay in [intervalMs * (1 - j), intervalMs] with j = 0.2 (a 20% variance window). This breaks lockstep fleet synchronization while keeping polling responsive and bounded. Deterministic fixed interval cadence can be restored when needed by specifying jitter: 'none'.
// Follow event telemetry with full jitter (default)
for await (const events of listener.watch({
pollIntervalMs: 5_000,
jitter: "full", // uniformly distributed in [4000ms, 5000ms]
})) {
console.log(`Received ${events.length} guard event(s)`);
}watch({ signal }) ends the stream — aborting is a normal exit, never a throw.
Abort is honoured at loop boundaries: before the first request (an
already-aborted listener issues no RPC call at all, not even the
getLatestLedger probe that resolves a default startLedger), before each
poll, and during the delay between polls. The default delay's timer is cleared
on abort, so a stopped listener leaves no open handle behind for a Node process
or a test suite to hang on.
const controller = new AbortController();
for await (const events of listener.watch({ signal: controller.signal })) {
handle(events);
}
// Runtime teardown, a new tool call, or a shutdown hook:
controller.abort(); // the loop ends, and no further getEvents is issuedOne limitation, stated rather than papered over: a request already in flight
is not cancelled. @stellar/stellar-sdk ^17 (the version this package
depends on, dependencies in package.json) declares
getEvents(request: Api.GetEventsRequest) with no AbortSignal parameter, and
its internal JSON-RPC postObject helper takes no per-request config, so there
is no supported way to plumb a signal through to the socket. The listener
consequently stops issuing requests immediately but cannot cancel one already
sent: the worst case between signal.abort() and the iterator ending is one
request duration — never a whole poll interval. The rejection of that
in-flight request (or of an abort-aware sleep) is swallowed as teardown, so an
aborted watch ends quietly in a for await loop instead of surfacing an
AbortError or an unhandled rejection.
Revisit this when the SDK adds per-request signals to getEvents; until then,
read signal as stop soon and stop asking, not cancel the socket.
check(), CostPreChecker.check() and invoke() each accept two optional
caller-owned bounds on the RPC calls they make:
signal?: AbortSignal— your runtime's cancellation (a tool-loop deadline, a shutdown hook, a user's "stop"). An already-aborted signal returns before any RPC call is issued.timeoutMs?: number— a per-RPC-call budget in milliseconds. It defaults toDEFAULT_RPC_TIMEOUT_MS(30s), because a network partition that leaves a guardrail call pending forever is a hang, not a fail-closed decision. PasstimeoutMs: 0to opt out and rely onsignalalone.
The typed error is the same; where it surfaces is deliberately not. A bound
that fires raises one class, RpcTimeoutError (carrying stage and whether it
was an aborted signal or a timeout), but the entry points answer different
questions:
| Call | Fired bound |
|---|---|
check() / CostPreChecker.check() |
the fail-closed undetermined verdict (decision.error instanceof RpcTimeoutError) — the guard reached no decision, which is what undetermined already means |
invoke() |
invoke's error outcome (outcome.error instanceof RpcTimeoutError) — the pipeline never reached broadcast |
const decision = await interceptor.check(call, { timeoutMs: 5_000 });
if (decision.kind === "undetermined" && decision.error instanceof RpcTimeoutError) {
// Fail closed: the guard could not be reached in time.
}Branch on the class, never on detail.
One limitation, stated rather than papered over: the request itself is never
cancelled. @stellar/stellar-sdk ^17's Server methods take no
AbortSignal, so the SDK races the in-flight promise against your signal and
the timeout. The HTTP request therefore continues server-side until it completes
or the connection drops; the bound cancels your wait, not the socket. What it
changes is bounded: a fired bound can only stop the SDK from waiting — it can
never relax a verdict, a cap or a block reason, and a call that is neither
aborted nor timed out behaves exactly as it did before.
Plug-and-play middleware intercepts agent actions before tools are executed:
- Framework adapters:
- LangChain:
createLangChainGuardMiddlewarewraps tool calls usingAgentMiddleware.wrap_tool_call. If the guard refuses (blocked) or the verdict isundetermined, execution is halted client-side with a formattedToolMessagecarrying the contract reason code and explanation (fails closed). The tool handler never runs, avoiding network submission fees. See the full runnable LangChain example (examples/langchain.ts). - ElizaOS:
createGuardValidatorandguardActioncompose pre-flight simulation intoAction.validate. Refused (blocked) andundeterminedactions both return booleanfalse(fails closed), excluding them from candidate execution. See the full runnable ElizaOS example (examples/elizaos.ts). - MCP:
guardMcpToolHandlerwraps a Model Context Protocol tool handler (registerTool/tool/setRequestHandler(CallToolRequestSchema)), andguardMcpCallToolwraps a client'scallToolbefore the request leaves the process. A refusal is returned as anisError: trueresult and the tool body never runs. See the MCP integration example. - Vercel AI SDK:
createVercelAIGuardwraps a tool's ownexecutefunction — the earliest pre-execution point theaipackage exposes.admissible→ the tool runs;blocked→GuardBlockedErrorthrown beforeexecute;undetermined→PreFlightUndeterminedErrorthrown beforeexecute(fail-closed, matching the other adapters' refusal behavior). Written structurally against theToolshape, soaistays an optional peer, not a dependency.
- LangChain:
A refusal is visible to the agent (its tool result says so), but not to the human watching dashboards — telemetry is a separate opt-in. Every adapter takes an optional onBlocked callback that fires once per halt, so a refusal can be wired to a webhook, log, or alert channel:
const middleware = createLangChainGuardMiddleware({
interceptor,
toContractCall,
onBlocked: ({ adapter, kind, reason, call, explanation }) => {
console.warn(`[${adapter}] ${kind}: ${reason ?? "undetermined"} on ${call.fn} — ${explanation}`);
},
});The payload is the same on every adapter ({ adapter, kind, reason, call, explanation }): kind is "blocked" when the guard refused and "undetermined" when enforcement could not decide (in which case reason is null and explanation carries the failure detail — the halt still happens, because the adapter fails closed). The callback is called after the halt is decided and its errors are swallowed, so a broken alerting sink can never turn a refusal into an executed action or a thrown error. A swallowed hook failure is silent by default; pass blockedHookLogger: { error(...) } when you want that diagnostic. Omit onBlocked for exactly the pre-existing behavior.
A tool that performs several guarded contract calls can map the whole action with the additive toContractCalls mapper instead of toContractCall. The complete ordered batch is checked before the handler runs; if any call is blocked or undetermined, the action is refused and no handler side effect occurs (all-or-nothing). onDecision receives every per-call verdict in order, and a batch halt's onBlocked payload also carries calls and verdicts:
const middleware = createLangChainGuardMiddleware({
interceptor,
toContractCalls: (request) => [approveCall, swapCall],
onDecision: (_request, verdict) => report(verdict.kind),
onBlocked: ({ calls, verdicts }) => alert(`${verdicts.length} calls checked; refused`),
});toContractCalls returns null or [] for a tool that moves no funds, and takes precedence over toContractCall when both are supplied.
ElizaOS note (0.2.x): the ElizaOS adapter previously passed the raw
PreFlightDecisiontoonBlocked; it now receives this structured payload, matching LangChain and MCP. Readinfo.reason/info.explanation(orinfo.kind) instead ofdecision.reason/decision.explanation.
Before encoding and submitting a policy on-chain, validate it client-side with validateGuardPolicy.
This provides a fail-before-broadcast safety rail that catches configuration errors before burning transaction fees or facing on-chain contract rejections.
Unlike fail-fast validators, validateGuardPolicy returns all failures at once (PolicyFailure[]), which is critical for dashboard form UX where an operator needs to see all field-level issues simultaneously. The failure rule identifiers align with SPEC §8 rules:
import {
validateGuardPolicy,
type PolicyConfig,
type PolicyFailure,
} from "stellar-agent-guard-sdk";
const draftPolicy: PolicyConfig = {
per_tx_cap: 10_000n,
window_secs: 0n, // Incompatible with window_cap > 0
window_cap: 50_000n,
assets: [], // Empty assets vector is a no-op
protocols: [],
recipients: ["GAOBCRXTCO4ZCBNHALJUMJJ5JDXNOUZ7U6VZJX4UBTXAHQEO66IPU6PH"],
allow_any_recipient: false,
active_from: 1000n,
active_until: 500n, // Inverted active window (active_until <= active_from)
paused: false,
dms_grace_secs: 0n,
};
const failures: PolicyFailure[] = validateGuardPolicy(draftPolicy, {
guardAddress: "CAPADGEK457RHKN4RYVUMDJTFHDSG7R5HREQONKLYK7MFKC5WFENPP44",
});
if (failures.length > 0) {
for (const failure of failures) {
console.error(`[${failure.rule}] at ${failure.field}: ${failure.message}`);
}
// Safe-exit before encode or broadcast
} else {
// Proceed with policyToScVal(draftPolicy) and broadcast
}decodePolicy is the canonical read-side counterpart to policyToScVal. It returns
bigint for every integer field, keeps ProtocolRule.fns: null distinct from an empty
array, normalizes Stellar addresses, and rejects missing, duplicate, unknown, unsorted, or
wrongly typed fields with a path-bearing PolicyDecodeError.
import {
decodePolicy,
policyToScVal,
type PolicyConfig,
} from "stellar-agent-guard-sdk";
const policy: PolicyConfig = {
per_tx_cap: 1_000n,
window_secs: 60n,
window_cap: 150n,
assets: ["CDCYDGBGS5AZ5BZS6XY2SK2PHJHSOEGTN3N4INCK34KF6GU2BGC7Z6MB"],
protocols: [
{
contract: "CDCYDGBGS5AZ5BZS6XY2SK2PHJHSOEGTN3N4INCK34KF6GU2BGC7Z6MB",
fns: ["transfer"],
},
],
recipients: ["GAOBCRXTCO4ZCBNHALJUMJJ5JDXNOUZ7U6VZJX4UBTXAHQEO66IPU6PH"],
allow_any_recipient: false,
active_from: 0n,
active_until: 0n,
paused: false,
dms_grace_secs: 0n,
};
const encoded = policyToScVal(policy);
const decoded = decodePolicy(encoded); // exactly equal to policyThe decoder accepts the direct ScVal::Map returned by the deployed policy() read and
also the one-element Vec representation used by some RPC/host surfaces for
Some(PolicyConfig). ScVal::Void means no policy is installed and therefore throws
rather than fabricating a default-deny config. Canonical u64/i128 variants are range
checked exactly; base-10 ScVal::String integers are accepted as a deliberate
compatibility path for stringly-typed RPC/telemetry payloads and normalized to bigint.
Dry run executes the real probe, authorization signing, and enforced-simulation path,
then returns the verdict, diagnostics, network-derived fees, and per-stage timings. It
stops before final transaction assembly and cannot call sendTransaction, so its result
has no transaction hash or submission object.
import { invoke } from "stellar-agent-guard-sdk";
const debug = await invoke({
server,
source,
call: blockedTransferCall,
networkPassphrase,
guardAuth: { guard, agent },
dryRun: true,
});
if (debug.kind === "dry_run") {
console.log({
admissible: debug.admissible,
verdict: debug.verdict,
reason: debug.reason,
fees: debug.fees,
});
console.table(debug.steps);
}A blocked or undetermined dry run reports an explicit all-zero charged fee breakdown;
an admissible dry run reports the simulation's resource fee plus the SDK's 100-stroop
inclusion floor. Missing, negative, malformed, unsafe-number, or out-of-u64-range fee
payloads are ContractResponseError failures and remain undetermined—never free.
steps[].ok describes whether a stage completed, not whether policy approved the call;
the separate verdict field is the policy answer. The probe → sign → simulate stages
are measured through the same onStep hook a live invocation uses, so a dry-run trace
and an onStep trace are the same measurement of the same code.
verifyAgentSignature lets an agent runtime check that a signature belongs to the key it
believes is registered before entering an agent loop. The helper is verify-only: it never
accepts, signs with, stores, or derives a private key. Other SDK APIs continue to accept
caller-created Keypair objects for transaction/authorization signing as before.
import { verifyAgentSignature } from "stellar-agent-guard-sdk";
function signerMatches(
registeredPublicKey: string | Uint8Array,
hostSignaturePayload: Uint8Array,
signature: Uint8Array,
): boolean {
return verifyAgentSignature(
registeredPublicKey,
hostSignaturePayload,
signature,
);
}hostSignaturePayload must be the exact 32-byte host digest covered by the signature;
the helper verifies those bytes without re-hashing and is not SEP-53 message signing. A
successful result proves only the key/payload/signature relationship—it does not validate
network ID, invocation, nonce, expiration ledger, or transaction freshness.
All SDK-owned errors now share a GuardError base. invoke() remains result-oriented:
inspect outcome.error with instanceof when outcome.kind === "error".
import {
BroadcastError,
GuardError,
SigningError,
SimulationError,
} from "stellar-agent-guard-sdk";
if (outcome.kind === "error") {
if (outcome.error instanceof SigningError) {
console.error("agent signer is wrong or unavailable");
} else if (outcome.error instanceof SimulationError) {
console.error("enforcement could not be determined");
} else if (outcome.error instanceof BroadcastError) {
console.error("submission failed", outcome.error.transactionHash);
} else if (outcome.error instanceof GuardError) {
console.error(outcome.error.message);
}
}A pipeline stage that throws — a dropped RPC connection, say — is reported the same way:
as kind: "error" carrying the original error as the typed error's cause, never as a
rejection. A guardrail that could not reach a verdict has no business throwing at its
caller, and the original error object stays reachable for diagnostics.
If ledger state changes after enforced simulation and the included transaction is then
refused by the guard, invoke() still returns kind: "blocked" with the contract reason
and diagnostics. That charged outcome additionally carries transactionHash and
charged: true; it is not flattened into a technical BroadcastError.
GuardBlockedError keeps its published name, message, fields, and instanceof behavior
and now extends GuardError; PreFlightUndeterminedError extends SimulationError.
The hierarchy, decodePolicy, and verifyAgentSignature are additive to 0.1.x callers.
The one intentional behavior change is for callers already using dryRun: true: the old
success sentinel (kind: "error") is replaced by the structured kind: "dry_run" result
documented above. Non-dry-run callers keep their existing outcome shapes.
An agent that stops heartbeating freezes. startHeartbeat schedules the heartbeat() calls and makes the failure modes visible instead of silent — an ad-hoc setTimeout loop drops a beat on an event-loop stall and turns an RPC blip into an unhandled rejection.
import { startHeartbeat } from "stellar-agent-guard-sdk";
const hb = await startHeartbeat({
server,
guard: GUARD_ID,
signer: agentKeypair, // Keypair or AgentSigner
intervalMs: 30_000,
maxSkewMs: 2_000, // tolerated drift before a beat counts as missed
onBeat: ({ lateMs }) => { if (lateMs > 2_000) alert("heartbeat late"); },
onError: (error) => logger.error("heartbeat failed", error),
graceSecs: 150, // optional pre-read: validated as interval <= grace/3
});
// on shutdown:
await hb.stop();- The first beat fires immediately, then every
intervalMson a fixed cadence — a slow beat surfaces as lateness on the next one instead of pushing the whole schedule. - A beat later than
maxSkewMsincrementshb.missedBeatsand is reported aslateMsinonBeat; alert on lateness, not only on failure.hb.lastBeatAtis the epoch ms of the last successful beat. - Every submission rejection goes to
onError; nothing escapes as an unhandled rejection. stop()(or thesignal) tears down cleanly: no beat is sent after it, and it resolves once an in-flight beat has settled.- When the grace window is readable (
graceSecs, or areadPolicyread), an interval longer thangrace / 3throwsHeartbeatIntervalErrorbefore the first beat; an unreadable policy warns viaonWarningand starts anyway. - A beat that would fall in the same wall-clock second as the previous accepted one is skipped client-side; the contract deduplicates independently.
- Every input is validated before the scheduler starts: a negative or non-finite
maxSkewMs, a grace window that is not a non-negative safe integer or bigint, a callback that is not callable, or an unusable retry budget is rejected with an error naming the field — before the first beat, and before the grace window is even read from the chain.
A failed heartbeat used to wait for the next fixed interval, so an RPC outage that landed near the grace boundary could freeze the account even after connectivity returned. retry adds a bounded, jittered recovery:
const hb = await startHeartbeat({
server,
guard: GUARD_ID,
signer: agentKeypair,
intervalMs: 30_000,
retry: { maxAttempts: 3 }, // opt-in — the default is one attempt per beat
onWarning: (message) => logger.warn(message),
onError: (error) => logger.error("heartbeat failed", error),
});- Only a transport failure is retried (a
BroadcastError, anRpcTimeoutError, aSimulationError). A policy block is never retried: the guard made a decision, and hammering a call it has already refused only adds load. An error the SDK cannot classify is reported, not retried. - The delay before the next attempt is a full-jitter sample of a bounded window, so agents that hit the same outage do not retry in a synchronized herd.
- The attempt that failed and the delay before the next one are announced through
onWarning; the failure that ends the beat is reported once throughonError, with the original error. A successful beat reports how many attempts it took inonBeat'sattempt.
The combined heartbeat-loop + pre-flight pattern is tracked as an integration guide in #74.
0.x API Policy & Deprecations: During
0.x, this package adheres to an additive-only within minor policy (0.1.xreleases are additive and fixes only; breaking changes and deprecation removals occur only at minor boundaries like0.2.0). For full policy details, deprecation mechanics, and the tracking table, seedocs/deprecations.md. Release process and versioning checklist:docs/releasing.md.
PreFlightInterceptorconstructor(options: PreFlightInterceptorOptions)— Passcache: { ttlMs }orcache: { ttlLedgers }to opt into the short-lived cache; omit it for fresh simulations.check(call: ContractCall, options?: PreFlightCheckOptions): Promise<PreFlightDecision>— Returnsadmissible | blocked | undeterminedwithout throwing or broadcasting.options.signal/options.timeoutMsbound the RPC calls it makes; a fired bound returns the fail-closedundeterminedverdict carrying a typedRpcTimeoutError— see Bounding a check or an invoke.assertAllowed(call: ContractCall): Promise<AdmissibleDecision>— Asserts allowed or throwsGuardBlockedError.invalidate(call?: ContractCall): void— Clears all cached decisions or only entries for one call.
CostPreCheckerconstructor(config: CostPreCheckConfig)— Acceptsinterceptor, optionalmaxFeeStroops, and optionalpolicySource(contract address orGuardPolicy).check(call: ContractCall, options?: CostPreCheckOptions): Promise<CostPreCheckResult>— Returnswithin_budget | over_budget | blocked | undeterminedwith additivepolicyContext.checkWithCost(call: ContractCall): Promise<{ decision, cost }>— Returns the interceptor verdict and the cost of the same single simulation. Prefer this over callingcheck()on both classes.
invoke(options: InvokeOptions): Promise<InvokeResult>— End-to-end pipeline: probe, sign auth, simulate, and broadcast. Accepts an optionalonStep(step: InvokeStepEvent)hook reporting per-stagestart/ok/failtiming events with a 0-based retryattemptindex; callback exceptions are isolated and omitting the hook changes nothing.options.signal/options.timeoutMsstop a stalled run: the bound is reported throughinvoke'serroroutcome as a typedRpcTimeoutError.startHeartbeat(options): Promise<HeartbeatHandle>— Drift-aware dead-man keep-alive. Firesheartbeat()on an interval, reports lateness (handle.missedBeats,onBeat'slateMs), routes every failure toonError, validatesinterval <= grace/3before the first beat when the grace window is readable, and stops cleanly (await handle.stop()). See “Keep the dead-man switch alive”.submitHeartbeat(params): Promise<HeartbeatSubmission>— Signs and submits a singleheartbeat()with the agent's guard authorization; the default submissionstartHeartbeatuses.HeartbeatBlockedError/isRetryableHeartbeatFailure(error)— The typed refusal of a blocked heartbeat, and the classifier that decides whether a failure is a transport failure (retryable) or a decision (never retried). See Bounded retry for a transient outage.
CostPreChecker reports fees as exact integer stroops — the raw value is the
source of truth, and it is what a maxFeeStroops ceiling is compared against.
formatFee() renders the same number in XLM, the unit operators think in, using
integer math only (XLM has 7 decimal places; float rounding on
money-adjacent output in a security tool is not acceptable) and with no trailing
zeros:
import { startHeartbeat } from "stellar-agent-guard-sdk";
const hb = await startHeartbeat({
server,
guard: GUARD_ID,
signer: agentKeypair, // Keypair or AgentSigner
intervalMs: 30_000,
maxSkewMs: 2_000, // tolerated drift before a beat counts as missed
onBeat: ({ lateMs }) => { if (lateMs > 2_000) alert("heartbeat late"); },
onError: (error) => logger.error("heartbeat failed", error),
graceSecs: 150, // optional pre-read: validated as interval <= grace/3
});
// on shutdown:
await hb.stop();- The first beat fires immediately, then every
intervalMson a fixed cadence — a slow beat surfaces as lateness on the next one instead of pushing the whole schedule. - A beat later than
maxSkewMsincrementshb.missedBeatsand is reported aslateMsinonBeat; alert on lateness, not only on failure.hb.lastBeatAtis the epoch ms of the last successful beat. - Every submission rejection goes to
onError; nothing escapes as an unhandled rejection. stop()(or thesignal) tears down cleanly: no beat is sent after it, and it resolves once an in-flight beat has settled.- When the grace window is readable (
graceSecs, or areadPolicyread), an interval longer thangrace / 3throwsHeartbeatIntervalErrorbefore the first beat; an unreadable policy warns viaonWarningand starts anyway. - A beat that would fall in the same wall-clock second as the previous accepted one is skipped client-side; the contract deduplicates independently.
- Every input is validated before the scheduler starts: a negative or non-finite
maxSkewMs, a grace window that is not a non-negative safe integer or bigint, a callback that is not callable, or an unusable retry budget is rejected with an error naming the field — before the first beat, and before the grace window is even read from the chain.
A failed heartbeat used to wait for the next fixed interval, so an RPC outage that landed near the grace boundary could freeze the account even after connectivity returned. retry adds a bounded, jittered recovery:
const hb = await startHeartbeat({
server,
guard: GUARD_ID,
signer: agentKeypair,
intervalMs: 30_000,
retry: { maxAttempts: 3 }, // opt-in — the default is one attempt per beat
onWarning: (message) => logger.warn(message),
onError: (error) => logger.error("heartbeat failed", error),
});- Only a transport failure is retried (a
BroadcastError, anRpcTimeoutError, aSimulationError). A policy block is never retried: the guard made a decision, and hammering a call it has already refused only adds load. An error the SDK cannot classify is reported, not retried. - The delay before the next attempt is a full-jitter sample of a bounded window, so agents that hit the same outage do not retry in a synchronized herd.
- The attempt that failed and the delay before the next one are announced through
onWarning; the failure that ends the beat is reported once throughonError, with the original error. A successful beat reports how many attempts it took inonBeat'sattempt.
The combined heartbeat-loop + pre-flight pattern is tracked as an integration guide in #74.
The complete SDK API reference lives in docs/api-reference.md — method signatures, options, return shapes and examples for PreFlightInterceptor, CostPreChecker, GuardTelemetryListener, validateGuardPolicy, the framework adapters and the invoke() pipeline. The README keeps this stub so the npm landing page stays readable; the reference itself grows alongside the other docs/ guides.
Prefer this over calling interceptor.check(call) and costChecker.check(call)
in sequence. That two-call pattern still works and its types are unchanged, but
it carries the fee-drift caveat above. precheckCostWithDecision() is the
one-shot form.
docs/event-schema.md— every telemetry event and field, each labelled with its stability tier: Stable (relied on), Append-only (new values may appear, existing ones will not be removed or renamed), Best-effort (may change in any release), Internal (implementation detail, not a contract).GuardTelemetryListenerconstructor(options: GuardTelemetryListenerOptions)watch(params?: GuardTelemetryWatchParams): AsyncIterable<GuardEventPage>— Tails on-chain and uncommitted events.params.signalaborts at loop boundaries: no RPC call before the first pull, no poll after an abort, and the delay between polls is cut short. A request already in flight cannot be cancelled — see Aborting a watch.watchAll(params?: GuardTelemetryUnifiedParams): AsyncIterable<GuardEvent>— Merges the committed ledger stream with thediagnosticsbatches you feed it into one ordered, de-duplicated stream, so a single loop sees blocked decisions too. Each event carriesstream: 'committed' | 'diagnostic'and, for diagnostics,observedAt. Ordering and de-duplication rules:docs/event-schema.md.serializeEvent(event: GuardEvent): string— Canonical single-line JSON for deterministic JSON-lines log shipping. Fixed key order, dropsundefined, keepsnull, rendersbigintas a decimal string. Contract:docs/event-schema.md.
validateGuardPolicy(policy: unknown, options?: ValidatePolicyOptions | string): PolicyFailure[]— Validates policy configuration against SPEC §8 rules prior to broadcast, accumulating all failures for complete form UX.POLICY_RULE_IDS— Canonical array of SPEC §8 validation rule identifiers.policyToScVal(policy: PolicyConfig): xdr.ScVal— Encodes a policy as the contract's canonical sorted ScVal struct.decodePolicy(scVal: xdr.ScVal): PolicyConfig— Strictly decodespolicy()/set-policy ScVal data, including Option/Vec and numeric normalization.policyFromScVal(scVal)— Alias for callers using the issue's original function name.describePolicy(policy: PolicyConfig | null, options?: DescribePolicyOptions): string— A compact, log-friendly rendering of the policy in force, including the recipient caps, the blocklist and the active window with its state. Recipient lists are reported as counts unlessdetail: trueasks for the addresses.invoke(options)/invoke({ ...options, dryRun: true })— Broadcasts an admissible result, or returns the full pre-broadcast dry-run trace.verifyAgentSignature(publicKey, payload, signature): boolean— Verify-only Ed25519 check against a strkey or raw 32-byte key.GuardError,SimulationError,SigningError,BroadcastError,PolicyDecodeError, andContractResponseError— Typed failure hierarchy.decodeCheckResult(raw): CheckResult— DecodesAllowedorBlocked(reason).decodeAuthDecision(event: SorobanRpc.Api.GetEventsResponse.Event): AuthDecisionEvent | nulldecodeGuardEventXdr(xdrBase64: string, source?: 'ledger' | 'diagnostic'): GuardAuthDecision | null— Offline decode of a raw base64 event XDR. Accepts either aDiagnosticEvent(whatgetEvents()and a simulation error carry) or aContractEvent(what a block explorer exposes) and returns the same decision the object-path decode produces. Malformed base64, an XDR that is not a contract event, and an event that is not anevent_auth_checkeddecision all returnnull— it never throws, so fixture checks and operator copy-paste cannot crash a long-running process.guardEventsFromDiagnostics(events: xdr.DiagnosticEvent[]): GuardEvent[]— Each decodedGuardEventcarries a stableid:ledger:<txHash>:<topic>for committed events,diag:<sha256>for blocked ones (which are rolled back and have no hash to anchor on). Same event re-parsed → same id; two different blocks in one simulation → different ids. Format and collision notes:docs/event-schema.md.GuardLogger,GuardLoggerInput,GuardLogMeta,GUARD_LOG_LEVELS— the optional, structurally-typed log sink shared by every config above;SILENT_LOGGERis what a config resolves to when none is supplied. See Optional logging.explainReason(reason: string | number, locale?, overrides?): string— Human-readable explanation of contract reason codes. The default call is unchanged; the optionallocalecatalog and per-reasonoverrideslocalise or reword a refusal without an i18n dependency. Reason keys, titles and remediation text:reasonMessages/reasonMessagesEn.isDeadManFrozen(status: GuardStatus): booleandeadManRemaining(status: GuardStatus, policy: PolicyConfig | null): bigint | nulldmsUrgency(status: GuardStatus, policy: PolicyConfig | null, nowSecs?: bigint, warnRatio = DMS_WARN_RATIO_DEFAULT): 'ok' | 'warn' | 'expired' | 'unknown'— Dead-man countdown urgency for dashboards:warnfromwarnRatio(defaultDMS_WARN_RATIO_DEFAULT= 0.8) of the grace period,expiredonce the grace has elapsed.unknownexactly wheredeadManRemainingisnull(no policy, switch disabled, never heartbeated — never ≠ expired).policyDiff(a: PolicyConfig, b: PolicyConfig): PolicyChange[]— Structured change list between two policies for operator display (e.g. when the policy revision bumps; dashboard change-history feed: stellar-agent-guard-dashboard#17). Lists compare as sets, so a pure reorder is no change; protocols match by contract and reportfnschanges at paths likeprotocols[0].fns[1].
A consumer that wants "the last N decisions, right now" — a dashboard panel, an agent status endpoint — can opt into a bounded in-memory window instead of maintaining its own store:
import { GuardTelemetryListener } from "stellar-agent-guard-sdk";
const listener = new GuardTelemetryListener({
server,
guard: GUARD_ID,
buffer: { max: 200 }, // opt-in; omitted → no buffer is allocated
});
// ...drive it with listener.watch() or listener.watchAll(), then read on demand:
const lastBlocked = listener.recent({ stream: "diagnostic" });
const capRefusals = listener.recent({ reason: "per_tx_cap_exceeded" });
const recentWindow = listener.recent({ fromLedger: 4_700_000 });recent(filter?) returns the retained events oldest-first, filtered by any of stream, reason, fromLedger, toLedger. The buffer is FIFO and non-durable: it holds only what this listener decoded in this process, and a restart empties it. Persistence across restarts is a cursor store (tracked separately), not something this buffer pretends to provide.
What it returns is a detached snapshot: the array, the events in it, their decoded data/decision and the opt-in raw payload are all copies, so editing a returned event cannot change what the listener still retains. A listener with no buffer configured copies nothing — recent() returns [].
When a decoded verdict looks wrong, attach the undecoded source event to the SDK bug report. Decoding discards it by default — the raw payload carries XDR ScVals and host-shaped objects, and retaining one per event is a memory decision — so it is opt-in:
// Committed feed: opt in on the listener.
const listener = new GuardTelemetryListener({ server, guard: GUARD_ID, includeRaw: true });
// Diagnostic feed: opt in at the decode site.
const events = guardEventsFromDiagnostics(diagnosticEvents, GUARD_ID, { includeRaw: true });
// Every event then carries `raw`: the `rpc.Api.EventResponse` for a committed
// event, or the host-shaped diagnostic object for a blocked one.
const event = (await listener.poll({ startLedger })).events[0];
console.error(JSON.stringify(event.raw, (_key, value) =>
typeof value === "bigint" ? value.toString() : value));When includeRaw is not set, raw is absent (undefined) — nothing is retained. Leave it off in a long-running fleet; turn it on for the debugging session or the bug-report window. The id field stays stable either way, so a raw event can be correlated with the decoded event it produced.
reasonMessages exposes each reason's numeric code and its stable title/body/remediation keys, and reasonMessagesEn holds the built-in English text — so a dashboard can render a table, and a consumer can key its own translations, without the SDK taking an i18n dependency:
import { reasonMessages, reasonMessagesEn, explainReason } from "stellar-agent-guard-sdk";
for (const [reason, { code, titleKey, remediationKey }] of Object.entries(reasonMessages)) {
console.log(code, reasonMessagesEn[reason as keyof typeof reasonMessagesEn].title, remediationKey);
}
// Localise one reason; a missing key falls back to English.
explainReason("per_tx_cap_exceeded", {
[reasonMessages.per_tx_cap_exceeded.bodyKey]: "Le montant dépasse le plafond par transaction.",
});
// Or override individual reasons outright.
explainReason("paused", undefined, { paused: "Agent paused by the operator." });serializeEvent(event) gives you one canonical JSON line per event, so a log pipeline gets a stable, diffable record with no logger dependency in the SDK:
import { serializeEvent } from "stellar-agent-guard-sdk";
for await (const event of listener.watchAll({ diagnostics })) {
logger.info(serializeEvent(event)); // one deterministic JSON line per event
}Key order, the drop-undefined/keep-null policy, and the bigint-to-decimal-string normalization are documented in docs/event-schema.md.
Stellar Agent Guard operates across three dedicated repositories:
┌─────────────────────────────────────────────────────────────────────────┐
│ Operator (Browser / Freighter) │
│ │ │
│ ▼ │
│ stellar-agent-guard-dashboard (Next.js / UI) │
└─────────────────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ AI Agent Runtime (LangChain / ElizaOS) │
│ │ │
│ ▼ │
│ stellar-agent-guard-sdk (TypeScript / RPC) │
│ • Pre-flight policy check • Cost pre-checks │
│ • Agent-auth tx signing • Event telemetry │
└─────────────────────────────────────┬───────────────────────────────────┘
│
▼ Soroban RPC
┌─────────────────────────────────────────────────────────────────────────┐
│ stellar-agent-guard-contracts (Soroban / Rust) │
│ • CustomAccount interface (`__check_auth`) │
│ • Spend caps, rolling window, allowlists, dead-man switch │
└─────────────────────────────────────────────────────────────────────────┘
| Repository | Role | Documentation |
|---|---|---|
| stellar-agent-guard-contracts | Soroban smart contracts implementing Custom Account Abstraction and spending policy firewall | GitBook Docs |
| stellar-agent-guard-sdk (this repo) | TypeScript SDK for pre-flight interception, simulation pricing, and AI agent framework integration | GitHub |
| stellar-agent-guard-dashboard | Client-side operator dashboard for policy deployment, inspection, and emergency panic-button freeze | GitHub |
Proven against a real deployed instance on Stellar testnet (protocol 28, Test SDF Network ; September 2015):
- Guard (custom account):
CAPADGEK457RHKN4RYVUMDJTFHDSG7R5HREQONKLYK7MFKC5WFENPP44 - SAC Token:
CDCYDGBGS5AZ5BZS6XY2SK2PHJHSOEGTN3N4INCK34KF6GU2BGC7Z6MB - WASM bytecode hash:
f47919f92e78fdd034836aa61955fc338dd56a218c448c37df1867a8c3da0f63(identical to Phase 1 artifact)
Verify a downloaded artifact against the pinned hash before deploying — the same constant the dashboard checks, so there is one source of truth rather than a copy per consumer:
import { readFileSync } from "node:fs";
import { verifyGuardWasm } from "stellar-agent-guard-sdk";
const result = await verifyGuardWasm(readFileSync("guard.wasm"));
if (!result.ok) {
throw new Error(`WASM mismatch: got ${result.actual}, expected ${result.expected}`);
}verifyGuardWasm hashes with WebCrypto (crypto.subtle), so the identical call runs in Node and in the browser and needs no extra dependency. It is async, because crypto.subtle.digest is. GUARD_WASM_HASH is exported if you need the constant on its own.
| Scenario | Condition | Result | Evidence |
|---|---|---|---|
| 1. Within caps | Transfer 100 within caps (cap: 1000, window: 150) | Allowed | Tx hash f8f5b3c51b85c8777c956d71330d015fcf72fa57548077d8b32969f8ba9c762e at ledger 4704849 |
| 2. Per-tx cap | Transfer 1001 > 1000 cap | Blocked (per_tx_cap_exceeded) |
Diagnostic event event_auth_checked, blocked, per_tx_cap_exceeded, pre-broadcast, 0 fees |
| 3. Rolling window | Transfer 76 + 76 = 152 > 150 window cap | Blocked (window_cap_exceeded) |
Rolling window accumulation refusal, balances untouched |
| 4. Recipient allowlist | Transfer to unlisted recipient | Blocked (recipient_not_allowed) |
Default-deny address check refusal |
| 5. Account paused | Call while paused = true |
Blocked (paused) |
Account-state refusal cleanly distinguished from policy caps |
Complete run output and assertion logs are preserved in tests/fixtures/integration-evidence.md.
- Enforcement boundary for arbitrary calls: Full amount/recipient limits are native to SAC token transfers. Arbitrary Soroban contract calls are enforced via protocol/function allowlists, active window, pause, and dead-man switches; per-call amount limits are not available generically from host auth contexts (tracked as v2).
- Single-key agent signing today, multi-key prepared: A guard account registers one Ed25519 agent key, and
buildGuardAuthEntrysigns the authorization digest with it. The signing path is now a seam (AgentSigner: sign a 32-byte digest, return signature bytes) and every public config accepts either anAgentSigneror a plainKeypair— so the current single-key behaviour is unchanged, and threshold/multi-key agent signing lands behind the same interface when the contracts repo's v2 decision does. Research, the recommended wire shape, and the revisit trigger:docs/concepts/multi-key-agent-signing.md. - AutoGPT integration: AutoGPT lacks an extensible pre-execution interceptor hook at the surveyed revision; findings and future integration paths are documented in
docs/integration-hooks.md. - Testnet signing credentials: Running
npm run test:integrationrequires.env.phase2populated with funded testnet keypairs. The live suite is not run on every PR: it is (a) required locally before any PR that touches the enforcement path (src/tx.ts,src/invoke.ts,src/policy.ts,src/preflight.ts), with fresh evidence committed totests/fixtures/integration-evidence.mdand CI-verified as present, and (b) run automatically on a weekly schedule (.github/workflows/live-suite.yml) to catch host/testnet drift. A greencitherefore means the required checks ran — not that the live suite ran against this change. - Pre-flight is a prediction, not a settlement guarantee: the four known pre-flight fidelity limits (moving window state, a moving fee market, simulation not executing, approximated batch semantics) are enumerated with their mitigations, residual risks, and code citations in Fidelity & limits.
Full recipient/amount enforcement — spend caps, allowlists, per-transaction limits — is native and automatic for SAC token transfers (transfer/transfer_from), since these are the calls whose arguments the Soroban auth context exposes for inspection. For other Soroban contract calls made by the guarded account (arbitrary DEX/lending/protocol calls), the policy engine still enforces window and pause state, but per-call amount/recipient limits are not yet enforced — extending fine-grained enforcement to arbitrary calls is tracked as a v2 item, not implied as already covered.
This boundary is an inherent property of the platform (the auth context does not expose arbitrary call arguments generically), not a gap this project hides or overclaims. The classification that produces this boundary (AssetTransfer vs Protocol vs Unknown default-deny) is spelled out in SPEC §6.
stellar, soroban, ai-agents, guardrails, custom-account, pre-flight,
spend-limits, cost-estimation, langchain, elizaos, mcp, telemetry, typescript
| Name | GitHub | Telegram |
|---|---|---|
| Hybrid | @aigbagbobila | @aigbagbobila |
- GitHub issues: https://github.com/aigbagbobila/stellar-agent-guard-sdk/issues
- Maintainer (GitHub): @aigbagbobila
- Security disclosures: see the local SECURITY.md for SDK issues; contract/protocol issues and deployed-instance incidents go to the contracts repo's SECURITY.md
Licensed under MIT. This is unaudited security tooling that gates real fund access — see the contracts repo's SECURITY.md before considering mainnet use.
See CONTRIBUTING.md for details on coding standards, PR process, and
project structure — including the strict one-commit-per-logical-unit rule and the
TypeScript strictness ratchet
(npm run check:strict-ratchet).
Before changing the public surface, the dependency set, or the runtime the SDK runs
in, read docs/design-principles.md: fail-closed
verdicts, no secret handling, one runtime dependency, additive-only 0.x surface,
a browser-safe core, and logger-optional silence. Departures are maintainer
decisions (tier:maintainer-decision), not silent exceptions.
Looking for something to work on? The issue backlog holds scoped issues with Summary / Acceptance Criteria / Tech Stack — good first tasks for the Drips Stellar Wave contributor sprints.
