Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/Shashank-H/gaiter-gaurd/llms.txt

Use this file to discover all available pages before exploring further.

Overview

When a request exceeds the risk threshold, it enters the approval queue and follows a 5-state state machine:
PENDING → APPROVED → EXECUTED
        ↓           ↘
     DENIED       EXPIRED
Agents poll for status changes and execute approved requests within a TTL window.
The default TTL is 1 hour after approval (configurable via APPROVAL_EXECUTE_TTL_HOURS). Approved requests that aren’t executed within the TTL expire automatically.

State Machine

The approval state machine is implemented in backend/src/services/approval.service.ts.

States

StateDescriptionTerminal?
PENDINGAwaiting human decisionNo
APPROVEDHuman approved, agent can executeNo
DENIEDHuman denied, agent should handle gracefullyYes
EXPIREDApproval TTL passed without executionYes
EXECUTEDAgent executed the request, response cachedYes

Transitions

State transitions are race-safe using conditional WHERE clauses:
// approval.service.ts:75
export async function transitionStatus(
  actionId: string,
  fromStatus: string,  // Current expected status
  toStatus: string,    // Target status
  extras?: { ... }
): Promise<boolean> {
  const result = await db
    .update(approvalQueue)
    .set({ status: toStatus, ...extras })
    .where(
      and(
        eq(approvalQueue.actionId, actionId),
        eq(approvalQueue.status, fromStatus)  // Only update if current status matches
      )
    )
    .returning({ id: approvalQueue.id });
  
  return result.length > 0;  // Returns false if status already changed
}
Conditional updates prevent race conditions. If two processes try to update the same action simultaneously, only one succeeds.

Transition Diagram

Agent Polling Protocol

When an agent receives a 428 response, it should poll GET /status/:actionId until the status changes.

Response Shapes by Status

{
  "status": "PENDING",
  "action_id": "550e8400-e29b-41d4-a716-446655440000",
  "created_at": "2026-03-03T10:30:00.000Z"
}
Agent action: Continue polling every 5-10 seconds

Polling Implementation

Implementation in backend/src/routes/approval.ts:21:
export async function handleApprovalStatus(
  req: Request,
  params: { actionId: string }
): Promise<Response> {
  const { agentId } = await requireAgentAuth(req);
  
  const row = await getApprovalQueueEntry(params.actionId);
  
  // Return 404 if not found or agent doesn't own it (prevents info leakage)
  if (!row || row.agentId !== agentId) {
    return errorResponse('Action not found', 404);
  }
  
  // Return status-specific response shape
  switch (row.status) {
    case 'PENDING': return Response.json({ status: 'PENDING', ... });
    case 'APPROVED': return Response.json({ status: 'APPROVED', execute_url: ... });
    // ... etc
  }
}

Polling Best Practices

Recommended: 5-10 secondsWhy:
  • Too fast: wastes server resources, may trigger rate limits
  • Too slow: delays execution, poor user experience
Example (Python):
import time

while True:
    response = requests.get(f"{BASE_URL}/status/{action_id}", headers=headers)
    status = response.json()["status"]
    
    if status == "APPROVED":
        break
    elif status in ["DENIED", "EXPIRED"]:
        raise Exception(f"Request {status.lower()}")
    
    time.sleep(7)  # Poll every 7 seconds
Set a maximum polling duration to avoid infinite loops:
import time

MAX_WAIT = 300  # 5 minutes
start_time = time.time()

while time.time() - start_time < MAX_WAIT:
    # ... poll logic
    time.sleep(7)

raise TimeoutError("Approval timeout after 5 minutes")
Handle transient errors gracefully:
import requests
from requests.exceptions import RequestException

retries = 0
while retries < 3:
    try:
        response = requests.get(f"{BASE_URL}/status/{action_id}", headers=headers)
        response.raise_for_status()
        break
    except RequestException as e:
        retries += 1
        time.sleep(5 * retries)  # Exponential backoff

Human Approval

Humans approve or deny requests via the dashboard API.

Approve Action

PATCH /approvals/:actionId/approve
Authorization: Bearer <jwt>
Effect:
  • Transitions status: PENDING → APPROVED
  • Sets resolvedAt timestamp
  • Sets approvalExpiresAt to now + APPROVAL_EXECUTE_TTL_HOURS
Response:
{
  "success": true,
  "action_id": "550e8400-e29b-41d4-a716-446655440000"
}

Deny Action

PATCH /approvals/:actionId/deny
Authorization: Bearer <jwt>
Effect:
  • Transitions status: PENDING → DENIED
  • Sets resolvedAt timestamp
Response:
{
  "success": true,
  "action_id": "550e8400-e29b-41d4-a716-446655440000"
}

List Pending Approvals

GET /approvals/pending
Authorization: Bearer <jwt>
Response:
[
  {
    "id": 123,
    "action_id": "550e8400-e29b-41d4-a716-446655440000",
    "agent_name": "Production Agent 1",
    "service_id": 5,
    "method": "DELETE",
    "target_url": "https://api.github.com/repos/octocat/hello-world",
    "intent": "List all repositories for user octocat",
    "risk_score": 0.9,
    "risk_explanation": "Intent claims to list repos but request is DELETE to specific repo",
    "request_headers": "{\"Content-Type\": \"application/json\"}",
    "request_body": null,
    "created_at": "2026-03-03T10:30:00.000Z"
  }
]
The request_headers and request_body fields have auth headers stripped. Credentials are never stored in the approval queue.

Execution Flow

After approval, the agent calls POST /proxy/execute/:actionId to execute the request.

Execution Steps

Implemented in backend/src/routes/proxy.ts:141:
  1. Authenticate agent — verify Agent-Key header
  2. Fetch approval entry — look up by actionId
  3. Ownership check — verify agent owns the action (return 404 if not)
  4. Status check — must be APPROVED (409 Conflict otherwise)
  5. TTL check — if approvalExpiresAt passed, transition to EXPIRED and return 410 Gone
  6. Credential injection — decrypt vault secrets and inject into stored headers
  7. Forward request — send stored request to target API
  8. Cache response — transition to EXECUTED and store response in database
  9. Return response — send cached response to agent

Execution Request

POST /proxy/execute/550e8400-e29b-41d4-a716-446655440000
Agent-Key: gg_...
No request body needed — the gateway uses the stored request from the approval queue.

Execution Response

The response mirrors the target API response:
HTTP/1.1 200 OK
Content-Type: application/json
X-Proxy-Status: executed-approved

{"success": true}
The X-Proxy-Status header distinguishes executed approvals from low-risk passthrough requests.

TTL Expiration

If the agent doesn’t execute within the TTL window:
HTTP/1.1 410 Gone
Content-Type: application/json

{
  "error": "Approval has expired — resubmit request via POST /proxy"
}
The agent must resubmit the original request, which starts the approval flow over.

Background Cleanup Job

A background job expires stale approvals every 5 minutes (server.ts:195):
setInterval(() => {
  expireStaleApprovals().catch((err) => {
    logger.error('Approval TTL cleanup error:', err);
  });
}, 5 * 60 * 1000); // Every 5 minutes

Cleanup Implementation

// approval.service.ts:119
export async function expireStaleApprovals(): Promise<void> {
  const result = await db
    .update(approvalQueue)
    .set({ status: 'EXPIRED' })
    .where(
      and(
        eq(approvalQueue.status, 'APPROVED'),
        lt(approvalQueue.approvalExpiresAt, new Date())
      )
    )
    .returning({ actionId: approvalQueue.actionId });
  
  if (result.length > 0) {
    logger.info(`Expired ${result.length} stale approvals`);
  }
}
Race safety: Only updates rows still in APPROVED status. If an agent executes just before cleanup runs, the conditional WHERE prevents the race.

Database Schema

The approval_queue table schema (backend/src/db/schema.ts:151):
export const approvalQueue = pgTable('approval_queue', {
  id: integer().primaryKey().generatedAlwaysAsIdentity(),
  actionId: varchar({ length: 36 }).notNull().unique(), // UUID v4
  agentId: integer().references(() => agents.id, { onDelete: 'cascade' }),
  serviceId: integer().references(() => services.id, { onDelete: 'cascade' }),
  
  // Stored request (auth headers stripped)
  method: varchar({ length: 10 }).notNull(),
  targetUrl: varchar({ length: 2048 }).notNull(),
  requestHeaders: text(), // JSON-serialized
  requestBody: text(),
  intent: varchar({ length: 500 }).notNull(),
  
  // Risk assessment result
  riskScore: real().notNull(),
  riskExplanation: text().notNull(),
  
  // State machine
  status: varchar({ length: 20 }).notNull().default('PENDING'),
  approvalExpiresAt: timestamp(), // Set when APPROVED
  
  // Timestamps
  createdAt: timestamp().defaultNow().notNull(),
  resolvedAt: timestamp(), // When APPROVED or DENIED
  executedAt: timestamp(), // When EXECUTED
  
  // Cached execution result
  responseStatus: integer(),
  responseHeaders: text(), // JSON-serialized
  responseBody: text(),
});

Sequence Diagram

Complete flow from block to execution:

Error Scenarios

Scenario: Agent polls too slowly and TTL expiresResponse:
HTTP/1.1 410 Gone
{"error": "Approval has expired — resubmit request via POST /proxy"}
Recovery: Resubmit original request via POST /proxy
Scenario: Human reviews request and denies itResponse:
{"status": "DENIED", "action_id": "...", "resolved_at": "..."}
Recovery: Agent should handle gracefully (log, notify user, adjust behavior)
Scenario: Service is deleted while request is pendingResponse:
HTTP/1.1 410 Gone
{"error": "Service no longer exists"}
Recovery: Cannot execute, request must be abandoned
Scenario: Agent tries to execute an action belonging to a different agentResponse:
HTTP/1.1 404 Not Found
{"error": "Action not found"}
Security: Ownership check prevents info leakage (returns 404, not 403)

Configuration

TTL Duration

Configure the execution window in .env:
APPROVAL_EXECUTE_TTL_HOURS=1  # Default: 1 hour
The TTL is set when the human approves the request (backend/src/routes/dashboard.ts):
const approvalExpiresAt = new Date(
  Date.now() + env.APPROVAL_EXECUTE_TTL_HOURS * 60 * 60 * 1000
);

await transitionStatus(actionId, 'PENDING', 'APPROVED', {
  resolvedAt: new Date(),
  approvalExpiresAt,
});

Cleanup Frequency

The cleanup job runs every 5 minutes (hardcoded in server.ts:195). To change:
setInterval(() => {
  expireStaleApprovals().catch((err) => {
    logger.error('Approval TTL cleanup error:', err);
  });
}, 10 * 60 * 1000); // Change to 10 minutes

Risk Assessment

Learn how requests enter the approval queue

Architecture

See where the approval queue fits in the system

Security Model

Understand why credentials are re-injected at execution

Build docs developers (and LLMs) love