Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const config: ApiConfig = {
apiKey: process.env.AGENTGATE_API_KEY ?? '',
approvalWaitMs: Number(process.env.AGENTGATE_APPROVAL_WAIT_MS ?? 0) || 0,
approvalPollMs: Number(process.env.AGENTGATE_APPROVAL_POLL_MS ?? 2000) || 2000,
guardrailFailOpen: /^(1|true|yes)$/i.test(process.env.AGENTGATE_GUARDRAIL_FAIL_OPEN ?? ''),
};

const server = new Server(
Expand Down
22 changes: 20 additions & 2 deletions packages/mcp/src/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ describe('guardrailBlockResult', () => {
expect(guardrailBlockResult({ decision: 'allow' })).toBeNull();
expect(guardrailBlockResult(null)).toBeNull();
});

it('returns an isError block for an error verdict (guardrail failing closed)', () => {
const r = guardrailBlockResult({ decision: 'error', reason: 'guardrail unavailable: boom' });
expect(r?.isError).toBe(true);
const text = JSON.stringify(r);
expect(text).toContain('blocked');
expect(text).toContain('guardrail unavailable');
});
});

describe('buildListParams', () => {
Expand Down Expand Up @@ -568,9 +576,19 @@ describe('authorizeTool (MCP guardrail, #14)', () => {
expect(verdict?.requestId).toBe('req-9');
});

it('fails OPEN (returns null) on a network/auth error', async () => {
it('fails CLOSED by default on a network/auth error (returns an error verdict)', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 500, text: () => Promise.resolve('') });
const verdict = await authorizeTool(config, 'file.read', {});
expect(verdict?.decision).toBe('error');
// and that verdict blocks the tool call
const blocked = guardrailBlockResult(verdict);
expect(blocked?.isError).toBe(true);
});

it('fails OPEN (returns null) when guardrailFailOpen is set', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 500, text: () => Promise.resolve('') });
expect(await authorizeTool(config, 'file.read', {})).toBeNull();
const openCfg: ApiConfig = { ...config, guardrailFailOpen: true };
expect(await authorizeTool(openCfg, 'file.read', {})).toBeNull();
});
});

Expand Down
34 changes: 27 additions & 7 deletions packages/mcp/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,8 @@ export function formatError(error: unknown): ToolResult {
/**
* Map a guardrail verdict to a blocking tool result, or null when the call is
* allowed (the caller then runs the tool). A `deny` is a synchronous error
* result; `requires_approval` is a (non-error) pending result. Issue #14.
* result; `requires_approval` is a (non-error) pending result; `error` (the gate
* failing closed, see authorizeTool) is a blocking error result. Issue #14.
*/
export function guardrailBlockResult(
verdict: { decision: string; requestId?: string; reason?: string | null } | null,
Expand All @@ -236,6 +237,16 @@ export function guardrailBlockResult(
message: 'Tool call requires approval and was routed to AgentGate.',
});
}
if (verdict?.decision === 'error') {
return {
...formatResult({
status: 'blocked',
reason: verdict.reason ?? null,
message: 'Tool call blocked: AgentGate guardrail unavailable (failing closed).',
}),
isError: true,
};
}
return null;
}

Expand Down Expand Up @@ -465,10 +476,14 @@ export async function handleGetAuditActors(
/**
* Ask the server whether a tool call is allowed for this MCP server's identity
* (issue #14). The server keys the guardrail on the verified agent bound to the
* api key. Returns the verdict (allow / requires_approval / deny), or null to
* fail-open (allow) on a network/auth error.
* ponytail: fail-open on error; add an AGENTGATE_GUARDRAIL_STRICT flag if a
* deployment needs the gate to fail closed.
* api key. Returns the verdict (allow / requires_approval / deny).
*
* On a network/auth error the gate FAILS CLOSED by default: it returns an
* `error` verdict that `guardrailBlockResult` turns into a blocking result, so
* a down/unreachable AgentGate can't silently wave every tool call through.
* Set AGENTGATE_GUARDRAIL_FAIL_OPEN=1 (config.guardrailFailOpen) to restore the
* old fail-open behaviour (returns null → allow) for deployments where the
* guardrail is advisory rather than enforcing.
*/
export async function authorizeTool(
config: ApiConfig,
Expand All @@ -481,8 +496,13 @@ export async function authorizeTool(
params: args,
})) as { decision: string; status?: string; requestId?: string; reason?: string | null };
} catch (err) {
console.error(`[guardrail] authorize failed for ${toolName}, allowing:`, err);
return null;
if (config.guardrailFailOpen) {
console.error(`[guardrail] authorize failed for ${toolName}, failing OPEN (AGENTGATE_GUARDRAIL_FAIL_OPEN):`, err);
return null;
}
const reason = err instanceof Error ? err.message : String(err);
console.error(`[guardrail] authorize failed for ${toolName}, failing CLOSED:`, err);
return { decision: "error", reason: `guardrail unavailable: ${reason}` };
}
}

Expand Down
3 changes: 3 additions & 0 deletions packages/mcp/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ export interface ApiConfig {
approvalWaitMs?: number;
/** Poll interval (ms) while waiting for an approval decision. */
approvalPollMs?: number;
/** When the guardrail authorize call errors, allow the tool through instead of
* blocking it. Default false (fail closed). Set via AGENTGATE_GUARDRAIL_FAIL_OPEN. */
guardrailFailOpen?: boolean;
}

export interface ApiErrorResponse {
Expand Down