diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 34109b8..663f46a 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -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( diff --git a/packages/mcp/src/tools.test.ts b/packages/mcp/src/tools.test.ts index 67c9072..578b455 100644 --- a/packages/mcp/src/tools.test.ts +++ b/packages/mcp/src/tools.test.ts @@ -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', () => { @@ -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(); }); }); diff --git a/packages/mcp/src/tools.ts b/packages/mcp/src/tools.ts index c8b35cb..34b3f21 100644 --- a/packages/mcp/src/tools.ts +++ b/packages/mcp/src/tools.ts @@ -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, @@ -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; } @@ -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, @@ -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}` }; } } diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts index 19c230d..24113fc 100644 --- a/packages/mcp/src/types.ts +++ b/packages/mcp/src/types.ts @@ -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 {