RSS Amplifier

Sentinel Den · Engineering blog · Aug 3, 2026

AgenticGuard + IntentKit + RuntimeGuard: the verify-classify-defend stack

0
Sign in to vote or save

Muhammad Khan · Sentinel Den

An LLM-agent app ships on iOS. The user types “summarize the email Mom sent yesterday and message her back with my flight number.” Three things have to happen in the right order, and one of them is invisible to the product spec.

The product spec says: classify the input into a tool call, scope what that tool call can do, audit the result. That’s two layers. The invisible third layer is: before any of that runs, decide whether you trust the runtime to host it. If the device has Frida injected, a debugger attached, or MobileSubstrate swizzling the very classifier you’re about to call, every other defense is theater. The agent guardrails run inside the attacker’s code.

Most teams ship the two visible layers and skip the third. They classify the input (or send it to a cloud LLM, which is its own problem). They scope the tool call (sometimes). They forget that an attacker who owns the runtime owns the classifier, the scoper, and the audit log too. The hash chain signs whatever the attacker wants it to sign.

The fix is three SDKs in a fixed order. RuntimeGuard establishes the floor. IntentKit’s IntentEngine classifies the natural-language input. AgenticGuard’s verify(intent:) enforces fail-closed tool dispatch. Each one owns one job and refuses to run if its precondition fails. This post is the integration shape.

Why no amount of agent guardrails saves a compromised runtime

The threat is concrete. An attacker who has Frida (or any equivalent injection framework) running inside your process can:

  • Hook IntentSession.extract(userMessage:) to return the attacker’s preferred IntentPayload instead of the model’s actual output.
  • Hook AgenticGuard.verify(intent:) to return .allow for any intent, regardless of policy.
  • Replace the Secure-Enclave-backed signing key with one the attacker controls, producing an audit chain that looks pristine to anyone who doesn’t have the original public key pinned somewhere else.
  • Strip prompt-injection classifier results before they reach your dispatcher.

Each of these is a function call away in Frida’s REPL. None require kernel privileges, only the ability to load a dylib into your process. On a jailbroken device, that’s LD_PRELOAD equivalents in MobileSubstrate. On a re-signed binary distributed outside the App Store, it’s the resign tool’s dylib_inject flag. On a debugged build during a security review, it’s lldb and a breakpoint.

The defense is to refuse to run the agent code at all when the runtime is in this state. Not to “warn.” Not to “log and continue.” Refuse. RuntimeGuard is the floor that decides whether the floor exists.

Order: RuntimeGuard, then IntentKit, then AgenticGuard

The three SDKs initialize in a fixed sequence at application(_:didFinishLaunchingWithOptions:). The order is not a stylistic preference. Each later SDK assumes its predecessor has already armed.

import RuntimeGuardSDK
import IntentKitSDK
import AgenticGuardSDK

@main
struct AgentApp: App {
    init() {
        Task { @MainActor in
            do {
                // Layer 1: floor. Refuses on compromised runtime.
                let runtime: any RuntimeGuard = RuntimeGuardCore()
                try await runtime.start(
                    apiKey: ProcessInfo.processInfo.environment["RUNTIMEGUARD_KEY"] ?? "",
                    environment: .production
                )

                // Layer 2: classifier. Refuses if floor not armed.
                let intentKit = try await IntentKit.start(
                    apiKey: ProcessInfo.processInfo.environment["INTENTKIT_KEY"] ?? "",
                    environment: .production
                )

                // Layer 3: tool dispatcher. Refuses if floor not armed.
                let sandbox = AgenticSandboxConfig.builder()
                    .allow(domains: ["api.example.com", "imap.example.com"])
                    .register([readEmailTool, sendMessageTool, summarizeTool])
                    .failClosed(true)
                    .build()

                let agent = try await AgenticGuard.make(
                    licensing: ProcessInfo.processInfo.environment["AGENTICGUARD_KEY"] ?? "",
                    configuration: sandbox,
                    environment: .production
                )

                await AppState.shared.markAgentReady(runtime: runtime, agent: agent)
                // Hold `runtime` and `agent` for the app lifetime; both are
                // consulted on every user turn below.
            } catch RuntimeGuardError.environmentCompromised {
                await AppState.shared.markFloorRefused()
            } catch AgenticGuardError.environmentCompromised {
                await AppState.shared.markFloorRefused()
            } catch {
                await AppState.shared.markStartupFailed(error)
            }
        }
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(AppState.shared)
        }
    }
}

A few things to notice about the start sequence.

It is awaited, sequentially, not concurrent. Running these in async let would lose the ordering guarantee. RuntimeGuard’s start performs the five-scanner sweep before returning; only if every scanner returns .secure (or the host explicitly tolerates .elevated) does it succeed. AgenticGuard runs its own integrity attestation on arm and refuses with AgenticGuardError.environmentCompromised when the process is compromised. There is no race window where AgenticGuard arms before RuntimeGuard has decided whether the device is compromised.

Each SDK has its own license. All three are bundled under the Agent Stack Pack subscription (Indie $1,099, Pro $4,399), but the license tokens are per-SDK and the activation endpoints are per-SDK. There is no shared singleton. This matters because a license-server compromise affecting one SDK doesn’t cascade; each SDK independently verifies its activation against api.sentinelden.com/verify, pinned, ECDSA-signed.

environmentCompromised has the same spelling in RuntimeGuard and AgenticGuard. Both RuntimeGuardError and AgenticGuardError expose an environmentCompromised case, so a single catch arm at the top level decides “the device isn’t trusted; render the support contact and refuse the agent UI.” The user sees one message, not several confusing ones in sequence.

The hot path: classify, then verify

Once the three layers have armed, every user turn flows through the same shape:

func handleUserTurn(_ message: String) async throws -> TurnResult {
    // 1. RuntimeGuard's current report is checked implicitly by IntentKit
    //    on every classify, and by AgenticGuard on every verify. If the
    //    floor degrades mid-session (debugger attached after launch), the
    //    next call here will throw `.environmentCompromised`.

    // 2. IntentKit classifies natural-language input into a typed IntentPayload.
    //    `session` was built once at setup via
    //    `intentKit.makeEngineFactory().makeEngineAndSession(configuration:...)`.
    let payload: IntentPayload = try await session.extract(userMessage: message)

    // 3. Project the typed payload into an AIIntent.toolCall for AgenticGuard.
    //    Host glue maps each IntentArgument to a ToolArgumentValue.
    let intent = AIIntent.toolCall(
        name: payload.tool.rawValue,
        arguments: toolArguments(from: payload.arguments)
    )

    // 4. AgenticGuard.verify(intent:) is fail-closed.
    let decision = await agent.verify(intent: intent)

    switch decision {
    case .allow:
        return try await dispatchTool(payload)
    case .allowWithConditions(let reason, let conditions):
        return try await dispatchTool(payload, conditions: conditions, reason: reason)
    case .deny(let reason, let code):
        throw TurnError.refused(reason: reason, code: code)
    }
}

IntentSession.extract(userMessage:) runs an on-device SLM through Core ML, parses the model’s emitted JSON against the tool registry’s schema, and returns a typed IntentPayload. The user’s raw text never leaves the device. No network I/O. The prompt buffers are zeroed inside the engine on return. The attacker capabilities this layer assumes, and the boundaries it defends, are laid out in the IntentKit threat model.

agent.verify(intent:) doesn’t throw. It returns an IntentDecision. If the prompt-injection classifier flags a tool argument, if the candidate tool isn’t in the sandbox’s tool registry, if the candidate arguments violate the domain allowlist, the decision is .deny with a structured code you branch on. There is no try! to forget. There is no error path that defaults to .allow.

That’s the whole hot path. Three calls in sequence. Each one fails closed.

Audit-chain coordination across three SDKs

Each SDK signs its own audit entries with its own Secure-Enclave-resident key. They don’t share a chain.

This is deliberate. A shared chain would mean a single key compromise (or a single bug in one SDK) corrupts the entire forensic record. By giving each SDK its own key, an off-device verifier can independently validate each chain and notice if one is tampered with while the others aren’t.

The forensic question is “did this tool call really happen, and what was the runtime state at the time?” Answering it means joining three chains by timestamp and turn-ID:

  • RuntimeGuard’s chain records each SecurityReport (the five-scanner output) and any state transition (.secure to .elevated, etc.) with a turn-correlation ID.
  • IntentKit’s chain records each IntentPayload it emitted (tool, arguments, confidence, model digest, latency) with the same turn ID.
  • AgenticGuard’s chain records each verify(intent:) decision (allow / deny, reason, code) with the same turn ID.

The join key is the turn ID, which your app threads through all three on every user turn. Off-device, a verifier reconstructs the per-turn story:

turn 4f2a:
  runtime:    .secure, 5 scanners green
  intent:     tool=send_message, args={recipient: "Mom", body: "Flight ABC123"}, confidence=0.94
  agentic:    .allow, reason: "tool in turn scope, domain on allowlist, no injection signal"

If any one chain is missing a turn that the other two have, that’s evidence of tampering. The off-device verifier can flag the discrepancy without needing to trust any single SDK.

Tier note: only the Pro tier of each SDK enables exportAuditTrail(). The Agent Stack Pack’s Pro subscription ($4,399) unlocks audit export across all three; the Indie tier ($1,099) gives you auditChainHead() read-only access (enough for runtime self-verification, not enough for off-device forensics). This isn’t an arbitrary feature gate; the Secure-Enclave signing throughput, key-rotation flow, and chain-export endpoints are materially more expensive to operate, hence the price differential.

What the stack doesn’t fix

Four limits to be explicit about. The stack is a structural defense, not a substitute for the rest of your security posture.

Bad user-input shaping. If the user types “send Mom $5,000 instead of the flight number,” the classifier will correctly extract the transfer intent, AgenticGuard will correctly verify it against the tool registry, and the audit chain will faithfully record it. The user got what the user asked for. If your tool registry includes a transfer tool that should not have been invokable in this conversational context, that’s an AgenticSandboxConfig problem (per-turn scope was too broad), not a stack failure. Test your tool scopes by adversarially scripting plausible-but-wrong user inputs.

Network egress. AgenticGuard’s domain allowlist gates which hosts a tool call can name. It does not gate the actual URLSession traffic; that’s the host app’s responsibility. If your send_message tool resolves “Mom” to imap.example.com and then your separate networking layer happily ships traffic to attacker.example.com because of a header injection elsewhere, the stack didn’t fail; the network layer did. See the sealing payloads above TLS post for the egress half.

Host backend that trusts the agent without verification. If your backend processes “the agent said to transfer $5,000” without checking an App Attest receipt, a runtime risk-level header, or a request signature, you’ve created a server that an attacker can hit directly with a forged client. The stack’s audit chain is local-first; the backend needs its own verification layer. The App Attest combined with DeviceCheck post covers the server-side companion.

A malicious on-device model. IntentKit ships a vendored, SHA-256-pinned model. If you swap in a model you fine-tuned and it has learned to classify “summarize” as “transfer,” the entire stack will faithfully execute the malicious classification. Model provenance is a supply-chain question, not a runtime one. The ED25519-signed model artifacts post covers that boundary.

The stack closes the verify-classify-defend triangle. Verify the runtime is trustworthy. Classify the user input into a typed payload. Defend the tool dispatch with structural fail-closed scoping. Each layer has a single job, refuses to run if its precondition fails, and signs its own audit entries with its own key. Order matters. RuntimeGuard first, IntentKit second, AgenticGuard third, the floor before the walls before the roof.

See /sdk/bundles/agent-stack for the bundle marketing, /docs/runtimeguard, /docs/intentkit, and /docs/agenticguard for the integration references, and the companion post on wiring an iOS LLM-agent app end-to-end for the worked-example version of this architecture.

Read the original on sentinelden.com

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.