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 exponential-backoff retry for stale ledger resource limits (scecExceededLimit). - Framework adapters:
createLangChainGuardMiddleware: Halts tool execution if the interceptor blocks the planned action.createGuardValidator: ElizaOS action validator returning boolean verdicts before actions run.
- Telemetry listener (
GuardTelemetryListener): Tails both committed events and diagnostic streams, decoding contract topics and reason codes.
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 } from "stellar-agent-guard-sdk";
const interceptor = new PreFlightInterceptor({
server: new rpc.Server("https://soroban-testnet.stellar.org"),
networkPassphrase: "Test SDF Network ; September 2015",
guard: "CAPADGEK457RHKN4RYVUMDJTFHDSG7R5HREQONKLYK7MFKC5WFENPP44",
agent: Keypair.fromSecret(process.env.AGENT_SECRET!),
source: Keypair.fromSecret(process.env.SOURCE_SECRET!),
});
const decision = await interceptor.check({
contract: "CDCYDGBGS5AZ5BZS6XY2SK2PHJHSOEGTN3N4INCK34KF6GU2BGC7Z6MB",
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)");
}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.
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.
invoke() accepts an optional onStep callback. When omitted, behavior is
exactly as before — the hook is pure observability and the SDK itself never
logs anything (and takes no logger dependency; what you do with the events is
up to you):
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, since the SDK is logger-agnostic and has no sink to report
them to. 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)
},
});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)`);
}Plug-and-play middleware intercepts agent actions before tools are executed:
- LangChain:
createLangChainGuardMiddlewarewraps tool calls usingAgentMiddleware.wrap_tool_call. If the guard refuses or the verdict is undetermined, execution is halted client-side with a formattedToolMessagecarrying the contract reason code and explanation. 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 actions return booleanfalse, excluding them from candidate execution. See the full runnable ElizaOS example (examples/elizaos.ts).
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.
PreFlightInterceptorconstructor(options: PreFlightInterceptorOptions)— Passcache: { ttlMs }orcache: { ttlLedgers }to opt into the short-lived cache; omit it for fresh simulations.check(call: ContractCall): Promise<PreFlightDecision>— Returnsadmissible | blocked | undeterminedwithout throwing or broadcasting.assertAllowed(call: ContractCall): Promise<AdmissibleDecision>— Asserts allowed or throwsGuardBlockedError.invalidate(call?: ContractCall): void— Clears all cached decisions or only entries for one call.
CostPreCheckerconstructor(options: CostPreCheckerOptions)check(call: ContractCall): Promise<CostPreCheckResult>— Returnswithin_budget | over_budget | blocked | undetermined.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.
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 { formatFee } from "stellar-agent-guard-sdk";
cost.totalFeeStroops; // 12345n — stroops (exact, source of truth)
formatFee(cost.totalFeeStroops); // "0.0012345" — same value in XLM
formatFee(1n); // "0.0000001" — one stroop
formatFee(9_999_999n); // "0.9999999" — largest sub-XLM valuePreFlightInterceptor.check() answers may this proceed? and
CostPreChecker.check() answers what will it cost? — but calling both runs the
enforced simulation twice, against two ledger snapshots. The extra RPC is the
lesser problem: the fee reported for a call can then differ from the fee implied
by the verdict that was actually enforced, so the price no longer corresponds to
the approved decision.
CostPreChecker.checkWithCost() returns both from a single simulation:
const { decision, cost } = await costChecker.checkWithCost(call);
if (decision.kind === "blocked") {
console.log("refused:", decision.reason); // nothing was charged
} else if (cost.kind === "over_budget") {
console.log("too expensive:", formatFee(cost.totalFeeStroops), "XLM");
} else if (decision.allowed) {
console.log("approved at", formatFee(cost.totalFeeStroops), "XLM");
}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(signal?: AbortSignal): AsyncIterable<GuardEventPage>— Tails on-chain and uncommitted events.
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.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 | nullguardEventsFromDiagnostics(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.explainReason(reason: string | number): string— Human-readable explanation of contract reason codes.isDeadManFrozen(status: GuardStatus): booleandeadManRemaining(status: GuardStatus, policy: PolicyConfig | null): bigint | null
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)
| 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.
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.
| Name | GitHub | Telegram |
|---|---|---|
| Hybrid | @aigbagbobila | @aigbagbobila |
- GitHub issues: https://github.com/aigbagbobila/stellar-agent-guard-sdk/issues
- Maintainer (GitHub): @aigbagbobila
- Security disclosures: see SECURITY.md (Telegram, the Stellar ecosystem norm)
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.
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.
