Zero-Trust Agent Tool Execution: From Prompt Injection to MCP Authorization in Go
The article presents a reference implementation for secure agent tool execution, establishing trust boundaries where only an independent executor authorizes side effects, covering Go parameter parsing, PostgreSQL approval workflows with leases and fencing, Kubernetes least-privilege deployment, and audit logging, while noting the code is unverified and not production-ready.
1. The Problem Is Not Whether the Model Listens, But Who Holds Execution Rights
The article opens with a concrete scenario: an agent integrated with a GitHub MCP Server can read issues and post comments after human confirmation, but the installed credentials also grant access to other private repositories. An attacker injects a malicious issue asking the model to read deployment credentials from another repo and post them as a comment, claiming admin approval. The system must assume the model will produce the attacker's desired call, yet the executor must independently verify principal, tenant, task scope, resource, policy, approval, and execution state.
All inputs — prompts, tool descriptions, web pages, issues, logs, model summaries, tool returns, and even approved=true flags — are untrusted and cannot serve as sources of identity, permission, or approval facts.
2. Trust Boundaries: Untrusted Content Cannot Become Control Facts
A diagram illustrates four domains: Side Effect Domain, Trusted Control Domain, Model Planning Domain, and Untrusted Content Domain. The real violation occurs when declarations inside content are promoted to control facts: reading user_approved=true to skip approval, treating a model-supplied tenant_id as the tenant, validating a repo ID but passing an arbitrary URL downstream, approving a tool name but letting the model rewrite the body, or writing upstream tokens and full context into checkpoints.
Tool results may carry metadata like source, retrieved_at, classification, trust=untrusted for display and audit, but type tags are not taint tracking and cannot guarantee the model remains unaffected. Mandatory access control must happen before side effects.
3. Goals, Non-Goals, and Invariants
Version 3 exposes only two fixed tools: github.issue.read (read issue within task scope and stable repo ID) and github.issue.comment (post fixed body to specified issue, requires approval). db.execute, arbitrary HTTP, URLs, shell, file I/O, and Kubernetes writes are not in the registry; each future addition must design resource, parameters, downstream identity, and recovery semantics.
Seven invariants are listed, verifiable by code and integration tests:
Untrusted content cannot increase permissions; unknown tools, parse errors, policy errors default deny.
Identity, run ownership, task scope, resource authorization precede approval decisions.
Approval binds the complete, immutable action, not just tool name or partial parameters.
Recovery loads action from trusted storage; model, client, HTTP request must not re-supply body.
Approval does not override revocation, expiry, or hard deny; recovery must re-authorize.
Before write side effects, execution intent, approval consumption, and audit evidence must atomically persist.
When external side effect is unknown, enter reconciliation; timeout ≠ not executed, blind retry forbidden.
The effective execution permission is the intersection of user permission, client permission, task scope, MCP server policy, resource permission, current upstream credential permission, and valid approval for high-risk ops. This is an application authorization model, not automatically provided by MCP. Task scope must bind resource, allowed actions, deadline, and budget; never expanded by model from issues or web pages.
4. MCP, Business Principals, and Downstream Credentials
Three identities must be audited simultaneously: user principal, MCP Client acting on behalf of user, and the execution workload actually accessing GitHub. They must not be collapsed into a vague agent_id.
In MCP HTTP auth, MCP Server is OAuth resource server, MCP Client is OAuth client, authorization server interacts with resource owner and issues access tokens. Server must verify access token and its audience; token must not be in URL; every request must carry auth. stdio should not directly reuse HTTP OAuth flow but obtain credentials via controlled local process, environment, and credential boundaries.
A table maps token usage and verifier for two hops: MCP Client → MCP Server (access MCP resource, verified by MCP Server) and MCP Server/Credential Proxy → GitHub (access GitHub installation and resources, verified by GitHub).
MCP tokens must not be passed through to GitHub. Upstream credentials should come from independent authorization relationships, ideally short-lived, resource-scoped credentials issued by a credential proxy. TLS and controlled service address answer "connect to whom"; token answers "who requests"; per-request business authorization answers "what is allowed now". The three cannot substitute each other.
Auth layer must verify: signature algorithm whitelist, trusted issuer, audience set, expiry, time constraints, revocation policy, scope, and trusted tenant mapping. Do not assume all tokens are JWT; do not coerce arbitrary claims to strings. Invalid/expired token → 401; authenticated but insufficient permission → 403; errors must not echo token, internal policy, or resource existence.
4.1 HTTP Auth Middleware Responsibilities
Auth middleware only solves "who sent the request", cannot replace resource authorization. It should output a minimal, unforgeable Principal struct (tenant, subject, client, scopes) rather than propagating raw claims map. Code example shows Principal struct and AccessTokenVerifier interface. Implementation must distinguish failure modes: missing credential, signature/introspection failure, untrusted issuer, audience mismatch, expiry, insufficient scope, and "token valid but no business resource permission". First five typically 401; scope/resource auth failure typically 403. Do not use "token parsed successfully" as "token usable for this resource".
MCP Authorization spec requires client to send resource parameter in auth and token requests, and resource server to verify token issued for itself. Protected resource metadata, bearer challenge, auth code with PKCE are part of HTTP interoperability; a dozen lines of handwritten JWT parsing is not a complete MCP auth implementation.
5. Beyond RBAC: Task Scope and Resource Authorization
A reviewer role reading issues does not imply reading arbitrary repos. Production decision input includes at least: subject, client, tenant, run, tool, tool_version, resource_id, action, environment, policy_version, task_scope.
Resources should use stable identifiers like GitHub repository ID, not mutable owner/repo. Server resolves resource's tenant, GitHub installation relation, user permissions, then intersects with run's allowed resource set; adapter then accesses only the normalized ID target.
SQL schema for resource_grants table shown with composite primary key (tenant_id, subject_id, client_id, resource_id, action) and check constraints on action ( issue.read, issue.comment) and effect ( allow, deny). Role-to-grant mapping maintained only by trusted admin entry. Cross-tenant always hard deny; multiple rules use explicit deny > ask > allow > default deny, not undefined glob priority or empty array semantics.
Sensitive read → public write treated as independent risk: even if both RBAC checks allow, private source code, logs, attachments may leak via comments. Minimum requirement: isolate sensitive reads from public posts, approve post bodies, show data source and classification on approval page. Automated information flow control is future work, not claimed implemented.
6. ToolExecutor: Model Supplies Only Business Parameters
A flow diagram shows the executor pipeline: trusted Principal and Run → typed parameter parsing → tool/version/resource normalization → task scope and base authorization → policy → deny/audit → controlled read adapter → persist action and pending approval → independent human approval → load action from trusted storage → re-authorize, consume approval, create operation → fixed write adapter → success or unknown then reconcile. Ask means "base permissions satisfied but this specific action needs extra confirmation", never a bypass of resource checks.
6.1 Parameter Parsing: Size, Duplicate Keys, Full Read
Request DTO rejects subject, tenant, approved, token, fence, arbitrary URLs, shell commands. Uses tool-specific DTO, unknown field rejection, body size limit, business constraints.
Critical nuance: io.LimitReader cannot prove true EOF of original request. Valid JSON ending before limit with trailing bytes may cause parser to see truncated EOF. Therefore HTTP boundary must use http.MaxBytesReader to limit entire request, then ensure exactly one JSON value. Go code example demonstrates parseArgs using http.MaxBytesReader, json.NewDecoder with DisallowUnknownFields, decoding once, then checking for extra content (must be EOF), and validating business constraints (positive IDs, body length ≤ 32KB). encoding/json 's DisallowUnknownFields does not automatically reject duplicate keys. If multiple components parse same raw text and security semantics depend on field consistency, explicitly detect duplicate keys at entry or parse once and pass structured DTO downstream.
6.2 Approval Binds Complete Action
Approval must bind principal, client, run, logical call, server, tool and version, policy version, and full parameters. Fixed Go struct serialization can be local encoding contract within single service; cross-language, do not let Go and Java re-serialize separately and assume digest matches; instead compute digest over server-generated, persisted immutable bytes, or use explicit canonicalization standard.
Code example shows Action struct with JSON tags and Digest() method using json.Marshal (local v1 contract, not cross-language canonical JSON) and SHA-256 hex digest. Digest only binds content, not identity, confidentiality, or DB tamper-proofing. If approval proof leaves trusted storage, apply signature or MAC with issuer, audience, expiry, one-time nonce.
Same comment appearing again may be a new legitimate business action; cannot permanently deduplicate by (run, tool, args_hash). Server should allocate call_id: same recovery/retry reuses ID, new business action generates new ID.
6.3 Policy Component Boundaries
For two tools, explicit Go policy is often more auditable than introducing a general policy language. Policy engine inputs must come entirely from trusted layer: verified Principal, normalized resource, fixed tool version, task scope, current environment. Model provides only business parameters.
Code example shows Decide function checking principal fields, tenant consistency, resource ID match, then switching on tool: github.issue.read allows if grant permits read and body empty; github.issue.comment returns Ask if grant permits comment and body non-empty; else deny. This code intentionally does not handle token validation, run ownership, task scope, resource catalog lookup — those must be done by Authority.Resolve before building Action. As rules grow, can evaluate OPA, Cedar, or existing permission systems, but must fix policy version, define conflict semantics, provide rule tests, and verify policy service unavailable → deny not allow.
7. Approval Is Persistent Workflow, Not Suspended HTTP Request
Approval page must show: requesting user, client, tenant, repo visibility, issue, full body, tool and policy versions, action digest, data classification, deadline. Body must be escaped as untrusted text, no HTML execution or auto-loading external links. High-risk ops should support separation of requester and approver; approver must have approval permission on target resource and action.
State machine defines states ( waiting_approval, approved, executing, unknown, terminal), allowed transitions, and prohibitions. Key points: waiting_approval holds no execution lease, calls no upstream; approved requires verifier, digest, expiry match; executing atomically consumes approval and writes operation, no replay of model action; unknown means external request uncertain or persistence failed, no auto re-POST.
Distinguish OAuth/delegation expiry, approval expiry, worker lease expiry. Approval may wait minutes; lease covers only acquisition and execution seconds; do not occupy lease while waiting for human. On recovery, re-verify delegation, resource permissions, tool/policy versions, action digest; approval does not auto-extend delegation nor override revocation.
7.1 Approval and Recovery Sequence
A sequence diagram (textual) shows: GitHub Approver → PostgreSQL → Executor → Agent Client. Steps: propose fixed comment params, verify identity/run/task scope/resource, save Action + digest + audit → waiting_approval / approval_id; approver approves specific digest → conditional update approved + audit; worker acquires lease and fence; from Store reads action and re-authorizes; consumes approval, creates operation; posts comment with fixed body; external comment ID; operation succeeded + audit; if timeout or persistence failure → unknown / discovered by patrol.
Checkpoint saves logical call ID, immutable action, tool and policy versions, approval status, operation ID, external resource refs, necessary time info. Body may be sensitive: restrict DB read access, encrypt, define retention. Do not save full model context, upstream tokens, or HTTP Authorization headers. Java services likewise must rebuild recovery context from persisted state; ThreadLocal, HTTP Session, waiting threads are not recoverable workflows.
8. PostgreSQL: Approval, Lease, Operation, Audit
SQL migrations are reference only, not validated on PostgreSQL instance. Production needs migration versions, DB roles, tenant isolation, indexes, retention policies, real concurrency tests.
Two tables: tool_calls (primary key tenant_id, run_id, call_id) with fields for action_json, action_hash, policy_version, state (enum), approval_expires_at, decided_by/at, consumed_at, lease_owner, lease_until, fence (monotonic counter), timestamps. tool_operations (operation_id PK) with tenant_id, run_id, call_id, action_hash, fence, status (sending/succeeded/unknown), upstream_id, timestamps, unique on (tenant_id, run_id, call_id), FK to tool_calls. action_json and action_hash both modifiable by execution role → hash loses tamper-proof meaning. Only trusted submission entry may create immutable action; execution role must not have update rights on action fields. Enforce via separate DB accounts, column-level permissions, or controlled stored procedures. Critical state transitions and audit_events must be in same transaction.
8.1 Approve, Acquire, Atomic Consume
Approval API verifies approver identity and resource approval permission, then conditional UPDATE: set state= approved, decided_by, decided_at, updated_at where tenant/run/call match, state= waiting_approval, approval not expired, action_hash matches. Zero rows updated = failure.
Lease acquisition uses DB time, active lease not overwritten: UPDATE sets lease_owner, lease_until (now + 30s), fence+1, updated_at where state= approved, consumed_at null, approval not expired, and (lease_until null or lease_until ≤ now). Returns fence, lease_until, action_json, action_hash.
Lease renewal must constrain current owner, fence, unexpired lease; renewal does not increment fence. Any condition update returning zero rows → worker must stop. Do not delete and recreate same logical call reusing its ID, else fence resets.
Before calling GitHub, worker loads action by (tenant_id, run_id, call_id) from trusted storage, re-checks identity mapping, delegation, resource permissions, current policy. Then in one transaction: advance state to executing, consume approval, create unique operation, write audit. Only on confirmed COMMIT success call GitHub. If commit response drops, query DB by operation ID first, cannot guess transaction rollback.
If GitHub succeeded but Complete persistence fails, must transition to unknown or let patrol discover hanging sending/executing. Returned error must express "reconciliation required", not drive caller to re-POST.
8.2 Concurrency and Fault Injection Must Be Actually Verified
SQL snippets ≠ concurrency correctness. Must run experiments in isolated PostgreSQL test DB:
Two connections simultaneously claim same approved row → at most one gets lease.
One worker renews lease while another attempts takeover → owner, fence, expiry conditions all effective.
Approval submit and approval expiry concurrent → only one legal state transition succeeds.
Dual workers simultaneously consume approval → only one operation created, at most one permitted send.
Operation transaction commits then kill worker → recovery reconciles first, not re-send.
GitHub mock receives comment but response times out → state enters unknown, recovery does not re-POST.
GitHub succeeds but Complete transaction fails → patrol can discover and handle hanging sending/executing.
After approval, revoke resource permission or change tool/policy version → recovery blocked or requires re-approval.
Tests must retain SQL, request counts, event sequences, external resource IDs; saving only final PASS insufficient to debug recovery issues. Do not replace DB concurrency tests with in-memory mutex.
9. Fencing Boundaries: GitHub Doesn't Know Your DB Token
Fencing only works when protected resource's commit point validates the token. Worker A gets fence=8, pauses, lease expires; Worker B gets new fence; A resumes and POSTs to GitHub. GitHub doesn't understand fence=8, may still accept late write.
Summary of achievable protections and remaining limits for four targets:
Own DB writes: enforce fence in business update conditions; all write paths must comply.
External APIs with idempotency keys: stable operation ID and request digest; relies on counterparty's true idempotency semantics.
Ordinary comment API: unique execution intent, fixed egress, result reconciliation; cannot claim exactly-once.
Response timeout: mark unknown, then query result; may need manual confirmation if uncorrelatable.
Attaching operation marker to comment aids reconciliation but is not atomic unique constraint. Custom HTTP headers don't automatically create idempotency; must verify each third-party API's guarantees individually.
10. Go Execution Core: Recovery Entry and Audit Semantics
Recovery entry does not accept caller-supplied Action. It receives only trusted locating keys, loads already-approved immutable action from Store. Prevents future callers from feeding model or HTTP request body into recovery flow.
Code shows ApprovedCall struct (Action, Fence), Store interface (Suspend, LoadApproved, BeginOperation, Complete, MarkUnknown), ExecuteRead (runs read, records audit event read_succeeded or read_failed), and Resume (loads approved call, prepares, verifies digest and verdict= Ask, begins operation, calls GitHub comment, on error marks unknown with 3s timeout, on success completes operation, else returns reconciliation required error). Prepare still requires Authority.Resolve to verify run/call ownership, task scope, resource normalization, fixed server/tool versions; CurrentPolicy error must fail closed. Code is interface core, not complete OAuth, Store, or GitHub adapter.
11. Audit: Execution Evidence Cannot Be Just "Allowed"
High-risk writes require atomic commit of execution intent and audit record. If DB unavailable, stop new writes; deny events may go to separate reliable log and alert, but must not allow due to audit failure.
Example JSON audit event includes tenant_id, subject_id, client_id, workload_id, run_id, call_id, tool, resource_id, action_hash, decision, reason_code, policy_version, approval_id, operation_id, fence, event_type. Do not log plaintext tokens, full sensitive bodies, passwords, or entire model context. Sensitive bodies stored in restricted storage, audit references controlled ref; short input hashes may be dictionary-guessed, use keyed digest when needed. Append-only table ≠ tamper-proof; stronger evidence needs separate audit account, remote append storage, export and integrity verification.
12. Kubernetes: Workload Identity Does Not Replace Business Authorization
GitHub tool service not accessing Kubernetes API should not mount API token. SRE tools deployed separately with dedicated ServiceAccount and minimal RBAC; do not let all tool pods inherit cluster read permissions.
YAML example for
ServiceAccount mcp-githubwith automountServiceAccountToken: false, Deployment with 2 replicas, securityContext (runAsNonRoot, runAsUser=10001, seccompProfile RuntimeDefault), container securityContext (allowPrivilegeEscalation false, readOnlyRootFilesystem true, drop ALL capabilities). Image reference uses registry.example.com with tag w06-example (comment: pin digest before release).
Not a deployable Helm Chart: missing Service, TLS, health checks, image digest, credential proxy, egress rules. Default deny egress blocks GitHub, DNS, identity service, DB; full whitelist must be validated per CNI and topology. Standard NetworkPolicy ≠ GitHub domain whitelist; domain control usually needs egress proxy or CNI capability.
Kubernetes RBAC follows least privilege, especially avoid secrets, pods/exec, impersonate, bind, escalate, broad ClusterRoleBinding, cluster-admin.
12.1 Minimal Permissions Example for Independent SRE Read-Only Tool
If SRE query tool needed, use different ServiceAccount ( mcp-sre-reader) with permissions scoped to target namespace. YAML shows Role aegis-workload-reader in namespace aegis-runtime allowing get/list pods and deployments; RoleBinding to mcp-sre-reader SA in agent-tools namespace. Intentionally no secrets, pods/log, pods/exec, create/delete permissions. Logs may contain passwords and user data; opening requires separate review.
Post-deployment verify with independent test identity:
kubectl auth can-i get pods -n aegis-runtime --as=system:serviceaccount:agent-tools:mcp-sre-reader→ expected yes
kubectl auth can-i get secrets -n aegis-runtime --as=system:serviceaccount:agent-tools:mcp-sre-reader→ expected no
kubectl auth can-i create pods/exec -n aegis-runtime --as=system:serviceaccount:agent-tools:mcp-sre-reader→ expected no
These are not actual cluster outputs from article. kubectl auth can-i only covers RBAC decision; still need to verify token mount, read-only filesystem, egress rules, metadata address unreachable in real pod.
13. Test Plan and Evidence Standards
Article does not claim tests executed. Real delivery must preserve commands, dependency versions, test DB initialization, raw outputs, request counts, event sequences, fault injection points.
Test layers, mandatory scenarios, key assertions:
Go unit tests: malicious tool results, cross-tenant, param mutation, unknown tool → no Runtime access, no operation created, no approval entered.
Token integration tests: signature/issuer/audience/expiry/scope/opaque introspection → invalid token 401, insufficient permission 403, no fallback allow.
PostgreSQL concurrency: dual worker claim, renew vs takeover, approval vs expiry race → at most one valid lease, one operation, unique legal transition.
Fault injection: commit then kill worker, GitHub received but timeout, Complete fails → unknown, no second POST, reconcilable by operation.
GitHub mock: fixed target, redirects, error responses, idempotency capability → no arbitrary URL access, request matches bound action.
Kubernetes: token mount, read-only root, RBAC, DNS/egress → runtime properties and egress policies meet expectations.
Test "pass" definition must be explicit. E.g., "external write not blocked by local fence" passing proves counterexample holds, not that system strictly prevents replay.
14. Common Failure Patterns
Practice, why it fails, alternative:
Only strengthen system prompt → model may still follow untrusted content → executor independent auth, default deny.
Authorize by tool name only → read tool becomes arbitrary resource read → stable resource ID + tenant + run scope.
Ask first, then check resource → unauthorized resource enters approval → base auth first, then add approval.
Resume entire agent loop → model changes action → resume persisted immutable action.
Only use io.LimitReader → truncated tail may masquerade as EOF → full request limit + single-value parse.
Check fence once before call → pause then still POST → downstream idempotency, controlled egress, reconciliation.
Auto-retry all failures → timeout ≠ not executed → operation reconciliation, controlled retry.
Log all prompts/params → audit becomes secret copy → sanitize, reference, restricted storage.
Tool pod uses cluster-admin → bypass app auth when it fails → SA separation, minimal RBAC, egress narrowing.
15. Acceptance Criteria
Capability, article status, honestly claimable content:
Trust boundaries, auth chain, recovery design → delivered as doc → architecture review possible.
Go core interfaces and correction examples → provided in-article references → not compiled, not wired to real adapters.
HTTP token validation, OAuth discovery, MCP SDK → to implement → no interop conclusion.
Run ownership, resource catalog, task scope → to implement → reference interfaces not production boundaries.
Approval API, Store, DB migrations → to implement/verify → SQL ≠ concurrency correctness.
GitHub real side effects and reconciliation → to implement/verify → no exactly-once claim.
Kubernetes config → baseline fragments provided → not deployed, not verified.
Taint tracking, info flow control, delegation decay → to design → no claim of blocking all cross-tool leakage.
Real W06 acceptance should link repo path, commit, dependency lock, migration versions, threat model, test reports, deployment evidence. Suggested directory structure listed.
Conclusion
Agents can form action proposals from issues, logs, web pages, but cannot derive execution rights from them. A verifiable system continuously answers: which principal, via which client, in what run and resource scope, with which immutable parameters, under which policy and approval, by which worker executed, and whether result is determined.
Security boundary lies not in what the model said, but in what unavoidable checks exist before side effects, and whether reconcilable evidence remains after side effects become uncertain.
References
MCP 2025-11-25 Authorization: HTTP auth roles, resource discovery, resource parameter, token validation, error semantics.
MCP Security Best Practices: token passthrough, confused deputy, etc.
PostgreSQL 17 INSERT: unique constraints and ON CONFLICT behavior; cannot replace real concurrency tests.
Kubernetes RBAC Good Practices: least privilege and escalation risks.
Kubernetes Service Accounts: workload identity and token usage.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Ray's Galactic Tech
Practice together, never alone. We cover programming languages, development tools, learning methods, and pitfall notes. We simplify complex topics, guiding you from beginner to advanced. Weekly practical content—let's grow together!
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
