首页 / 文章 / Building a Secure Context Compiler for AI Agents in .NET 10

本文以英文发布。

Building a Secure Context Compiler for AI Agents in .NET 10

Compile model context in .NET 10 with trust labels, freshness checks, redaction, injection quarantine, deterministic token budgets and a SHA-256 manifest you can test.

2001 词

When an agent misbehaves in production, people usually blame the model. A lot of the time the damage was done earlier, when the application glued instructions, retrieval hits, memory and untrusted web text into a single privileged string. That string is everything the model knows for the next inference. If an outdated policy beats the current one, a fetched page carries instructions, customer data crosses a boundary, or low-value history eats the context window, a cleverer prompt at the top won't repair the architecture.

The fix is to treat that assembly step as real software. Every input gets an identity and a trust level. Policies decide what's fresh enough and what must be hidden. A fixed budget forces explicit choices. The output can be reproduced, and it comes with evidence of what was included, truncated, redacted, quarantined or dropped. Below, that idea becomes a small .NET 10 service.

Why plain concatenation falls short

Anthropic's engineering writing frames context engineering as curating the entire context state for each inference, which goes well past polishing the wording of a single prompt. System instructions, tools, retrieved data, memory and the message history all compete for one finite resource, and bigger windows don't remove the relevance and pollution problems.

Security sharpens the selection problem. OpenAI's March 2026 piece on resisting prompt injection points out that capable attacks increasingly look like social engineering, so an input classifier alone is no complete defense. It also suggests thinking in terms of sources and sinks: where untrusted influence gets in, and which dangerous capability it can reach. Microsoft Research's PlugMem work adds a reliability angle, showing that raw memory retrieval can swamp an agent with long, low-value context.

The practical takeaway is that data needs an ingestion contract, policy checks, a budget and provenance before it ever reaches a model.

The compiler's shape

TrustedContextCompiler exposes a single endpoint:

POST /v1/context/compile

The request carries the task, the trusted system instructions and a list of identified sources, each declaring its kind, trust level, priority and retrieval time. What comes back is the assembled context, a rough token estimate, a SHA-256 fingerprint covering exactly that text, and a manifest with one decision for each source. The processing pipeline looks like this:

flowchart LR
    A[Retrieved sources] --> V[Validate ID and freshness]
    V --> T[Apply trust policy]
    T --> Q[Quarantine suspicious untrusted data]
    T --> R[Redact sensitive values]
    R --> B[Deterministic budget allocator]
    B --> C[Compiled context]
    B --> M[Decision manifest]
    C --> F[SHA-256 fingerprint]

Policy is kept apart from transport in the project layout:

TrustedContextCompiler/
├── src/TrustedContextCompiler.Core/
├── src/TrustedContextCompiler.Api/
├── tests/TrustedContextCompiler.Tests/
├── samples/compile-request.json
├── docs/verification.md
├── Dockerfile
└── TrustedContextCompiler.slnx

The core library has no dependency on any model. That's deliberate, because context policy ought to be deterministic and testable without spending tokens or waiting on a provider.

Identity and trust for every source

Anonymous strings aren't accepted. Each input is typed:

public enum TrustLevel
{
    Trusted = 0,
    Internal = 1,
    User = 2,
    Untrusted = 3
}
public sealed record ContextSource(
    string Id,
    string Kind,
    string Content,
    TrustLevel Trust,
    int Priority,
    DateTimeOffset RetrievedAt);

Id ties the output back to retrieval evidence. Kind separates policies, tickets, memory, web pages and other source types. Priority is a bounded input to policy, and RetrievedAt feeds the freshness decision.

The trust label has to come from the ingestion code and never from the document itself, so a web page can't announce that it's trusted. An internal database row isn't automatically trustworthy either. The label should describe who can influence the content and which validation path produced it.

Duplicate IDs are refused, as are missing fields and priorities outside 0 to 100. Failing early matters here, since a manifest built on ambiguous identities gives false confidence.

Instructions get their budget first

Before any retrieved data is considered, trusted instructions and the task get a dedicated slice of the budget:

var instructionBlock =
    quot;<trusted-instructions>\n" +
    quot;{request.SystemInstructions.Trim()}\n" +
    quot;Task: {request.Task.Trim()}\n" +
    quot;</trusted-instructions>";
var instructionTokens = EstimateTokens(instructionBlock);if (instructionTokens > options.ReservedInstructionTokens)
{
    throw new ArgumentException(
        "Instructions exceed the reserved instruction budget.",
        nameof(request));
}

This keeps a flood of retrieval results from quietly pushing out the control plane. It also stops an oversized system prompt from eating the whole data budget with no visible configuration error.

Token counting uses a plain estimate of four characters per token:

public static int EstimateTokens(string value) =>
    Math.Max(1, (value.Length + 3) / 4);

That isn't a billing-grade tokenizer. In production you'd use the chosen model's tokenizer and keep a safety margin for message wrappers and provider-specific encoding. An approximation that's deterministic still works for testing policy behavior, provided it's labeled honestly.

Dropping stale context

A source can be valid and still wrong for the task because it's old. Each one is compared with a configurable maximum age:

if (now - source.RetrievedAt >
    TimeSpan.FromMinutes(options.MaxSourceAgeMinutes))
{
    manifest.Add(Record(
        source,
        ContextDecision.Stale,
        0,
        0,
        hash,
        "Source exceeded the freshness policy."));
    continue;
}

In a bigger system freshness should vary per source type. A refund policy may need a short maximum age, whereas a product manual can stay stable for months. When the authoritative system supplies a version or validity interval, prefer that. Retrieval time is a fallback and doesn't prove content is current.

The important design decision is that stale sources stay in the manifest. They vanish from the model's context but remain in the operational record.

Quarantining injection attempts in untrusted data

Only Untrusted sources go through an injection heuristic. It looks for narrow patterns: attempts to override higher-priority instructions, to extract secrets, or to smuggle in role-style markup.

if (source.Trust == TrustLevel.Untrusted &&
    SafetyFilters.Injection().IsMatch(source.Content))
{
    manifest.Add(Record(
        source,
        ContextDecision.Quarantined,
        0,
        0,
        hash,
        "Untrusted content matched an " +
        "instruction-injection heuristic."));
    continue;
}

A match quarantines the whole source. Cutting out one sentence would be fragile, so the compiler gives up some recall in exchange for a decision that's easy to explain.

The regex isn't an "AI firewall" by any stretch.

Redaction before the model sees anything

Sources that survive quarantine are transformed before budgeting. The sample detects email addresses, Bearer tokens and values shaped like payment cards:

public static (string Value, int Count) Redact(string value)
{
    var count = 0;
    value = Email().Replace(value, _ =>
    {
        count++;
        return "[EMAIL]";
    });    value = Bearer().Replace(value, _ =>
    {
        count++;
        return "[SECRET]";
    });    value = Card().Replace(value, _ =>
    {
        count++;
        return "[CARD]";
    });    return (value, count);
}

Because redaction happens before inclusion, the original value never lands in the compiled context. The manifest keeps just a redaction count and a SHA-256 hash of the original source.

These patterns are samples and not a full DLP system. International identifiers, account numbers, company-specific secrets and free-form personal data all need suitable detectors. A content hash can also give away a low-entropy value if someone guesses it, so where that threat matters, use an HMAC with a protected key.

A deterministic budget

Candidates are sorted by priority, highest first, then by stronger trust, then by source ID:

foreach (var candidate in candidates
    .OrderByDescending(item => item.Source.Priority)
    .ThenBy(item => item.Source.Trust)
    .ThenBy(item => item.Source.Id, StringComparer.Ordinal))
{
    // Allocate the remaining budget.
}

The ID tie-breaker isn't decoration. Without it, two retrieval results with identical scores could yield different prompts, cache keys and model behavior depending on the order the database or network returned them.

Every source has a maximum allocation. One that doesn't fit is truncated to what's left of the budget, and one whose wrapper alone won't fit is dropped. Both outcomes show up in the manifest. Each source is wrapped with its ID, kind and trust metadata:

<source id="ticket-42" kind="ticket" trust="internal">
Contact [EMAIL]. Refund requested: $125.
</source>

Markers help with separation and observability, but inside the model they're not a cryptographic boundary. A model can still misread data or follow it, so tool controls must assume that some context attacks will work.

Fingerprinting the artifact

After deterministic assembly, the compiler hashes the exact UTF-8 text of the context:

var context = string.Join("\n\n", sections);
var fingerprint = Convert.ToHexString(
    SHA256.HashData(Encoding.UTF8.GetBytes(context)));

The fingerprint supports safe cache keys, regression comparisons and correlation with model calls, all without logging the full context. A production cache key should also include the compiler's policy version and the model identifier, since the same text under a changed policy or tokenizer may not mean the same thing.

The manifest is sorted by source ID so comparisons stay stable. For each source it records trust, decision, estimated included tokens, redaction count, hash of the original content and the reason.

The ASP.NET Core layer

A Minimal API adds the concerns a compiler service needs in production:

  • Strongly typed options, validated at startup
  • String enums in the JSON contract exposed to callers
  • A fixed-window rate limiter
  • Health checks, with errors returned as Problem Details
  • Source-generated structured logs that hold counts and never context
  • Cancellation, plus the standard ASP.NET Core request limits

Authentication is left out on purpose. Before the endpoint is reachable by anyone, add workload or service identity and authorization for tenants, source types and policy profiles,

Tests that check decisions

Five tests cover the main claims:

  1. An untrusted source that looks like an instruction gets quarantined and is missing from the context.
  2. Email and card values are replaced before inclusion.
  3. Feeding the inputs in reverse order gives the same context and fingerprint.
  4. When the budget runs out, a lower-priority source is truncated or dropped.
  5. A stale source is in the manifest but not in the context.

The determinism test is short and matters a lot:

var left = compiler.Compile(Request(first, second));
var right = compiler.Compile(Request(second, first));
Assert.Equal(left.Fingerprint, right.Fingerprint);
Assert.Equal(left.Context, right.Context);

The solution was run on .NET 10 under Windows. Restore succeeded, the Release build produced no warnings or errors, and all five tests passed. A live check then started the API and posted one request made of three sources: a trusted policy, an internal ticket with an email address in it, and an untrusted web note with an instruction attack.

Mistakes to avoid

  • Letting a source pick its own trust level
  • Treating delimiter tags as a sandbox
  • Logging the whole context for convenience
  • Using a retrieval score as unlimited authority
  • Dropping sources silently, with no manifest
  • Calling a heuristic detector a full injection defense
  • Presenting an approximate token count as a billing figure

One more rule: don't tune context in isolation from tools. An untrusted sentence is only as dangerous as the capability it can steer, and fetching a public document is a very different sink from sending out customer data, so each needs its own authorization.

What you get from this

Context is more than harmless text: whatever lands in it becomes the world an agent thinks in, and an untrusted idea can steer what it does next. A compiler like this won't make prompt injection go away. What it does is cut out the accidents. Sources carry identities, trust is set outside the content, old data is refused, sensitive values get transformed, budgets are predictable, suspicious inputs sit in quarantine, and every decision can be inspected. Context engineering stops being prompt craft and becomes a backend discipline you can test. Reviewers also get a concrete artifact to examine when agent behavior shifts between releases.