@ratifia/mcp 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -1
- package/dist/client.d.ts +115 -0
- package/dist/client.js +136 -0
- package/dist/gate.d.ts +10 -0
- package/dist/gate.js +212 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +18 -701
- package/dist/server.d.ts +22 -0
- package/dist/server.js +399 -0
- package/package.json +11 -3
package/README.md
CHANGED
|
@@ -84,7 +84,7 @@ request_decision(
|
|
|
84
84
|
|
|
85
85
|
| Tool | What it does |
|
|
86
86
|
|------|--------------|
|
|
87
|
-
| `request_decision` | Create a decision, notify the human, return a `decision_id`. |
|
|
87
|
+
| `request_decision` | Create a decision, notify the human, return a `decision_id`. `approvers` (emails) or `policy` (a policy key) say who answers for this call, overriding `RATIFIA_APPROVER_EMAIL` / `RATIFIA_POLICY`. |
|
|
88
88
|
| `check_decision` | Poll a decision's status once. |
|
|
89
89
|
| `await_decision` | Block until the verdict lands (or a max wait elapses). |
|
|
90
90
|
| `list_pending_decisions` | The agent's inbox — what's outstanding. |
|
|
@@ -201,6 +201,29 @@ Three properties worth knowing, because they shape how you should use it:
|
|
|
201
201
|
- **Re-attaching is free.** Identical content dedupes on its hash, so a retry
|
|
202
202
|
never produces a second panel for the reviewer.
|
|
203
203
|
|
|
204
|
+
## Hosted (no install)
|
|
205
|
+
|
|
206
|
+
Ratifia also runs this server for you at **`https://api.ratifia.com/mcp`** (Streamable
|
|
207
|
+
HTTP). Add that URL as a custom connector in claude.ai or ChatGPT and sign in with your
|
|
208
|
+
Ratifia account (OAuth); a client that sends a fixed header can use
|
|
209
|
+
`Authorization: Bearer <org API key>` instead. See
|
|
210
|
+
[docs.ratifia.com/mcp](https://docs.ratifia.com/mcp).
|
|
211
|
+
|
|
212
|
+
The hosted endpoint uses this package as a library:
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
import { createRatifiaMcpHandler } from '@ratifia/mcp'
|
|
216
|
+
|
|
217
|
+
const handler = createRatifiaMcpHandler({ version: '1.0.0' })
|
|
218
|
+
// After authenticating the request yourself:
|
|
219
|
+
const res = await handler.fetch(request, {
|
|
220
|
+
authInfo: { token, clientId, scopes: [], extra: { ratifia: { apiUrl, token, appUrl, approvers: [] } } },
|
|
221
|
+
})
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`createRatifiaServer(config, { version })` builds a single server (it is what the stdio
|
|
225
|
+
CLI runs). Neither reads env or credentials: the caller passes a `RatifiaConfig`.
|
|
226
|
+
|
|
204
227
|
## How it works
|
|
205
228
|
|
|
206
229
|
1. The agent calls `request_decision` before a consequential/irreversible step (deploy, delete, spend, send externally), an ambiguous call it shouldn't guess, or to get a human to pick/refine something.
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Ratifia API client shared by the MCP tools (stdio or hosted) and the
|
|
3
|
+
* `gate` hook. No env reading here: callers pass a RatifiaConfig, so one process
|
|
4
|
+
* can serve many tokens (the hosted endpoint builds one per request).
|
|
5
|
+
*/
|
|
6
|
+
export interface RatifiaConfig {
|
|
7
|
+
/** API base, e.g. https://api.ratifia.com (no trailing slash needed). */
|
|
8
|
+
apiUrl: string;
|
|
9
|
+
/** Sent as `Authorization: Bearer <token>`: an org API key, or a connector's OAuth access token. */
|
|
10
|
+
token: string;
|
|
11
|
+
/** Dashboard base for the decision link. */
|
|
12
|
+
appUrl: string;
|
|
13
|
+
/** Default approvers when a call names neither approvers nor a policy. */
|
|
14
|
+
approvers: string[];
|
|
15
|
+
/** Default policy key. */
|
|
16
|
+
policy?: string;
|
|
17
|
+
/** Default workflow decisions are filed under. */
|
|
18
|
+
workflow?: string;
|
|
19
|
+
/** Defaults to global fetch. The hosted endpoint passes an in-process one. */
|
|
20
|
+
fetch?: typeof fetch;
|
|
21
|
+
}
|
|
22
|
+
export declare const trimUrl: (u: string) => string;
|
|
23
|
+
/**
|
|
24
|
+
* Where a decision is filed. `workflowRef` is the category (the dashboard's
|
|
25
|
+
* Workflow filter groups by it — e.g. "deploys", "billing-agent");
|
|
26
|
+
* `stepRef` is the specific instance within it. Blank or whitespace-only values
|
|
27
|
+
* fall back rather than filing under an empty name.
|
|
28
|
+
*/
|
|
29
|
+
export declare function label(value: string | undefined, fallback: string, max: number): string;
|
|
30
|
+
/**
|
|
31
|
+
* Who answers (Ratifia PD-003): a policy owns its approvers and expiry, so with
|
|
32
|
+
* one set only its key is sent (approvers alongside it are refused, 422
|
|
33
|
+
* field_owned_by_policy). Without one, the decision is ad hoc to `approvers`.
|
|
34
|
+
*/
|
|
35
|
+
export declare function who(policy: string | undefined, approvers: string[]): Record<string, unknown>;
|
|
36
|
+
/** An API failure that knows whether trying again is worth anything. */
|
|
37
|
+
export declare class ApiError extends Error {
|
|
38
|
+
/** null = no response at all (network, DNS, or our own timeout). */
|
|
39
|
+
readonly status: number | null;
|
|
40
|
+
/** The API's machine-readable `error` code, when it sent one (e.g. `unknown_policy`). */
|
|
41
|
+
readonly code: string | null;
|
|
42
|
+
constructor(message: string, status: number | null, code?: string | null);
|
|
43
|
+
get retryable(): boolean;
|
|
44
|
+
}
|
|
45
|
+
/** FN-255: RATIFIA_POLICY names a policy the org doesn't have. A misconfiguration, not an outage. */
|
|
46
|
+
export declare const isUnknownPolicy: (err: unknown) => boolean;
|
|
47
|
+
export declare const unknownPolicyMessage: (policy: string | undefined) => string;
|
|
48
|
+
export declare const sleep: (ms: number) => Promise<unknown>;
|
|
49
|
+
/** Retry transient failures with backoff. One blip used to abort an entire
|
|
50
|
+
* wait, which reads to the agent as "this step failed" — the expensive
|
|
51
|
+
* outcome, since it may then re-ask a human who was already notified. */
|
|
52
|
+
export declare function apiRetry<T>(fn: () => Promise<T>, attempts?: number): Promise<T>;
|
|
53
|
+
/** The structured answer shape a decision expects (FN-180/181). */
|
|
54
|
+
export interface ResponseSpec {
|
|
55
|
+
type: 'approval' | 'select' | 'text';
|
|
56
|
+
options?: {
|
|
57
|
+
id: string;
|
|
58
|
+
label: string;
|
|
59
|
+
description?: string;
|
|
60
|
+
}[];
|
|
61
|
+
min?: number;
|
|
62
|
+
max?: number;
|
|
63
|
+
}
|
|
64
|
+
/** The human's structured answer, once decided. */
|
|
65
|
+
export interface ResponseValue {
|
|
66
|
+
type: 'approval' | 'select' | 'text';
|
|
67
|
+
decision?: 'approve' | 'reject';
|
|
68
|
+
selected?: string[];
|
|
69
|
+
text?: string;
|
|
70
|
+
}
|
|
71
|
+
/** Decision row as returned by GET /v1/worker/decisions/:id (API-key scope). */
|
|
72
|
+
/** An editable proposed artifact (e.g. an email body) — FN-185. */
|
|
73
|
+
export interface DecisionOutput {
|
|
74
|
+
title?: string;
|
|
75
|
+
format?: 'text' | 'markdown';
|
|
76
|
+
content: string;
|
|
77
|
+
}
|
|
78
|
+
export interface DecisionRow {
|
|
79
|
+
id: string;
|
|
80
|
+
status: string;
|
|
81
|
+
decision: string | null;
|
|
82
|
+
note: string | null;
|
|
83
|
+
responseSpec: ResponseSpec | null;
|
|
84
|
+
responseValue: ResponseValue | null;
|
|
85
|
+
output: DecisionOutput | null;
|
|
86
|
+
refinedOutput: DecisionOutput | null;
|
|
87
|
+
decidedBy: string | null;
|
|
88
|
+
decidedAt: string | null;
|
|
89
|
+
policyVersionId?: string | null;
|
|
90
|
+
policyVersion?: number | null;
|
|
91
|
+
}
|
|
92
|
+
/** FN-239: immutable material a decision is made ON (a Slack thread, an email). */
|
|
93
|
+
export interface EvidenceInput {
|
|
94
|
+
kind: 'slack_thread' | 'email' | 'document' | 'link' | 'note';
|
|
95
|
+
title?: string;
|
|
96
|
+
source_system?: string;
|
|
97
|
+
source_url?: string;
|
|
98
|
+
body?: string;
|
|
99
|
+
captured_at?: string;
|
|
100
|
+
}
|
|
101
|
+
export type RatifiaClient = ReturnType<typeof createClient>;
|
|
102
|
+
export declare function createClient(cfg: Pick<RatifiaConfig, 'apiUrl' | 'token' | 'fetch'>): {
|
|
103
|
+
api: <T>(path: string, init?: RequestInit & {
|
|
104
|
+
idempotencyKey?: string;
|
|
105
|
+
}) => Promise<T>;
|
|
106
|
+
getDecision: (id: string) => Promise<DecisionRow>;
|
|
107
|
+
attachEvidence: (decisionId: string, e: EvidenceInput) => Promise<{
|
|
108
|
+
evidence?: {
|
|
109
|
+
id: string;
|
|
110
|
+
contentHash: string;
|
|
111
|
+
} | null;
|
|
112
|
+
deduplicated?: boolean;
|
|
113
|
+
}>;
|
|
114
|
+
waitForDecision: (decisionId: string, budgetMs: number) => Promise<DecisionRow>;
|
|
115
|
+
};
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
export const trimUrl = (u) => u.replace(/\/+$/, '');
|
|
2
|
+
/**
|
|
3
|
+
* Where a decision is filed. `workflowRef` is the category (the dashboard's
|
|
4
|
+
* Workflow filter groups by it — e.g. "deploys", "billing-agent");
|
|
5
|
+
* `stepRef` is the specific instance within it. Blank or whitespace-only values
|
|
6
|
+
* fall back rather than filing under an empty name.
|
|
7
|
+
*/
|
|
8
|
+
export function label(value, fallback, max) {
|
|
9
|
+
const v = value?.trim();
|
|
10
|
+
return (v ? v : fallback).slice(0, max);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Who answers (Ratifia PD-003): a policy owns its approvers and expiry, so with
|
|
14
|
+
* one set only its key is sent (approvers alongside it are refused, 422
|
|
15
|
+
* field_owned_by_policy). Without one, the decision is ad hoc to `approvers`.
|
|
16
|
+
*/
|
|
17
|
+
export function who(policy, approvers) {
|
|
18
|
+
return policy ? { policy: { key: policy } } : approvers.length ? { approvers } : {};
|
|
19
|
+
}
|
|
20
|
+
/** Per-request ceiling. Node's fetch defaults run to ~300s, long enough to
|
|
21
|
+
* outlive a whole await_decision budget and strand the caller on a dead
|
|
22
|
+
* connection well past the deadline it was promised. */
|
|
23
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
24
|
+
/** An API failure that knows whether trying again is worth anything. */
|
|
25
|
+
export class ApiError extends Error {
|
|
26
|
+
/** null = no response at all (network, DNS, or our own timeout). */
|
|
27
|
+
status;
|
|
28
|
+
/** The API's machine-readable `error` code, when it sent one (e.g. `unknown_policy`). */
|
|
29
|
+
code;
|
|
30
|
+
constructor(message, status, code = null) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.status = status;
|
|
33
|
+
this.code = code;
|
|
34
|
+
}
|
|
35
|
+
get retryable() {
|
|
36
|
+
if (this.status === null)
|
|
37
|
+
return true;
|
|
38
|
+
if (this.status === 408 || this.status === 429)
|
|
39
|
+
return true;
|
|
40
|
+
return this.status >= 500;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function errorCode(body) {
|
|
44
|
+
try {
|
|
45
|
+
const code = JSON.parse(body).error;
|
|
46
|
+
return typeof code === 'string' ? code : null;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** FN-255: RATIFIA_POLICY names a policy the org doesn't have. A misconfiguration, not an outage. */
|
|
53
|
+
export const isUnknownPolicy = (err) => err instanceof ApiError && err.code === 'unknown_policy';
|
|
54
|
+
export const unknownPolicyMessage = (policy) => `Policy "${policy}" is not a policy key in this Ratifia org. Create a policy with this key in the Ratifia dashboard (Policies), or fix the policy you passed (or RATIFIA_POLICY in this MCP server's config). No decision was created: do not proceed with the action.`;
|
|
55
|
+
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
56
|
+
/** Retry transient failures with backoff. One blip used to abort an entire
|
|
57
|
+
* wait, which reads to the agent as "this step failed" — the expensive
|
|
58
|
+
* outcome, since it may then re-ask a human who was already notified. */
|
|
59
|
+
export async function apiRetry(fn, attempts = 3) {
|
|
60
|
+
for (let i = 0;; i++) {
|
|
61
|
+
try {
|
|
62
|
+
return await fn();
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
if (!(err instanceof ApiError) || !err.retryable || i >= attempts - 1)
|
|
66
|
+
throw err;
|
|
67
|
+
await sleep(500 * 2 ** i); // 500ms, then 1s
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Poll fast while the human is plausibly still at the keyboard, then back off.
|
|
72
|
+
* Most decisions land in the first few seconds, and a flat interval made every
|
|
73
|
+
* one of those wait out the full tick before the agent could move. */
|
|
74
|
+
const pollIntervalMs = (elapsedMs) => elapsedMs < 15_000 ? 1_000 : elapsedMs < 60_000 ? 3_000 : 5_000;
|
|
75
|
+
export function createClient(cfg) {
|
|
76
|
+
const apiUrl = trimUrl(cfg.apiUrl);
|
|
77
|
+
const doFetch = cfg.fetch ?? fetch;
|
|
78
|
+
/** `idempotencyKey` makes a retried write safe to collapse server-side; mint it
|
|
79
|
+
* once per tool invocation so two independent asks never share one. */
|
|
80
|
+
async function api(path, init) {
|
|
81
|
+
const { idempotencyKey, ...rest } = init ?? {};
|
|
82
|
+
try {
|
|
83
|
+
const res = await doFetch(`${apiUrl}${path}`, {
|
|
84
|
+
...rest,
|
|
85
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
86
|
+
headers: {
|
|
87
|
+
'Content-Type': 'application/json',
|
|
88
|
+
Authorization: `Bearer ${cfg.token}`,
|
|
89
|
+
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
|
|
90
|
+
...(rest.headers ?? {}),
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
const text = await res.text();
|
|
94
|
+
if (!res.ok)
|
|
95
|
+
throw new ApiError(`Ratifia ${path} → ${res.status}: ${text.slice(0, 300)}`, res.status, errorCode(text));
|
|
96
|
+
return (text ? JSON.parse(text) : {});
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
if (err instanceof ApiError)
|
|
100
|
+
throw err;
|
|
101
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
102
|
+
throw new ApiError(`Ratifia ${path} → unreachable: ${msg}`, null);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const getDecision = (id) => api(`/v1/worker/decisions/${encodeURIComponent(id)}`, { method: 'GET' });
|
|
106
|
+
/** Evidence lives on the dual-auth decisions route, not /worker — an API key
|
|
107
|
+
* authenticates there exactly as a dashboard session does. */
|
|
108
|
+
const attachEvidence = (decisionId, e) => api(`/v1/decisions/${encodeURIComponent(decisionId)}/evidence`, {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
body: JSON.stringify({
|
|
111
|
+
kind: e.kind,
|
|
112
|
+
...(e.title ? { title: e.title } : {}),
|
|
113
|
+
...(e.source_system ? { sourceSystem: e.source_system } : {}),
|
|
114
|
+
...(e.source_url ? { sourceUrl: e.source_url } : {}),
|
|
115
|
+
...(e.body ? { body: e.body } : {}),
|
|
116
|
+
...(e.captured_at ? { capturedAt: e.captured_at } : {}),
|
|
117
|
+
}),
|
|
118
|
+
});
|
|
119
|
+
/** Poll until the decision leaves PENDING or the budget runs out (returns it
|
|
120
|
+
* still PENDING in that case). Transient API failures are retried, not fatal. */
|
|
121
|
+
async function waitForDecision(decisionId, budgetMs) {
|
|
122
|
+
const start = Date.now();
|
|
123
|
+
let d = await apiRetry(() => getDecision(decisionId));
|
|
124
|
+
for (;;) {
|
|
125
|
+
if (d.status !== 'PENDING')
|
|
126
|
+
return d;
|
|
127
|
+
const elapsed = Date.now() - start;
|
|
128
|
+
const wait = pollIntervalMs(elapsed);
|
|
129
|
+
if (elapsed + wait > budgetMs)
|
|
130
|
+
return d;
|
|
131
|
+
await sleep(wait);
|
|
132
|
+
d = await apiRetry(() => getDecision(decisionId));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { api, getDecision, attachEvidence, waitForDecision };
|
|
136
|
+
}
|
package/dist/gate.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type RatifiaConfig } from './client.js';
|
|
2
|
+
/**
|
|
3
|
+
* `ratifia-mcp gate` — a Claude Code **PreToolUse hook**. Opens a Ratifia
|
|
4
|
+
* decision for the tool call (rendered as a `tool_call` context panel), blocks
|
|
5
|
+
* until a human answers, and returns allow/deny to the harness. The "list of
|
|
6
|
+
* tools" that trigger this is the hook's matcher in settings.json; narrow it
|
|
7
|
+
* further with RATIFIA_GATED_TOOLS. Fail-CLOSED (deny) on error/timeout unless
|
|
8
|
+
* RATIFIA_GATE_FAIL_OPEN=1 — a gate that fails open is no gate.
|
|
9
|
+
*/
|
|
10
|
+
export declare function runGate(cfg: RatifiaConfig): Promise<void>;
|
package/dist/gate.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { apiRetry, createClient, isUnknownPolicy, label, unknownPolicyMessage, who } from './client.js';
|
|
5
|
+
// ─── Gate mode (Claude Code PreToolUse hook) ─────────────────────────────────
|
|
6
|
+
/** Read the hook payload from stdin (Claude Code pipes the PreToolUse JSON). */
|
|
7
|
+
function readStdin() {
|
|
8
|
+
return new Promise((resolve) => {
|
|
9
|
+
if (process.stdin.isTTY)
|
|
10
|
+
return resolve('');
|
|
11
|
+
let data = '';
|
|
12
|
+
process.stdin.setEncoding('utf8');
|
|
13
|
+
process.stdin.on('data', (c) => (data += c));
|
|
14
|
+
process.stdin.on('end', () => resolve(data));
|
|
15
|
+
setTimeout(() => resolve(data), 3000); // don't hang if the stream never ends
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/** Emit Claude Code's PreToolUse permission decision (allow | deny) as JSON. */
|
|
19
|
+
function gateDecision(decision, reason) {
|
|
20
|
+
process.stdout.write(JSON.stringify({
|
|
21
|
+
hookSpecificOutput: {
|
|
22
|
+
hookEventName: 'PreToolUse',
|
|
23
|
+
permissionDecision: decision,
|
|
24
|
+
permissionDecisionReason: reason,
|
|
25
|
+
},
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Where the agent is working, read straight from `.git` rather than shelling out
|
|
30
|
+
* to git — a hook runs on every gated tool call and must stay cheap.
|
|
31
|
+
*/
|
|
32
|
+
function repoContext(cwd) {
|
|
33
|
+
let dir;
|
|
34
|
+
try {
|
|
35
|
+
dir = resolve(cwd);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
for (let i = 0; i < 6; i++) {
|
|
41
|
+
try {
|
|
42
|
+
const dotGit = join(dir, '.git');
|
|
43
|
+
if (existsSync(dotGit)) {
|
|
44
|
+
// A worktree's `.git` is a FILE ("gitdir: <path>"); a normal repo's is a
|
|
45
|
+
// directory. Read HEAD from whichever this is.
|
|
46
|
+
const gitDir = statSync(dotGit).isDirectory()
|
|
47
|
+
? dotGit
|
|
48
|
+
: resolve(dir, readFileSync(dotGit, 'utf8').replace(/^gitdir:/, '').trim());
|
|
49
|
+
const head = readFileSync(join(gitDir, 'HEAD'), 'utf8').trim();
|
|
50
|
+
// Keep the whole branch name: "feat/x" is not "x".
|
|
51
|
+
const branch = head.startsWith('ref: refs/heads/')
|
|
52
|
+
? head.slice('ref: refs/heads/'.length)
|
|
53
|
+
: head.startsWith('ref: ')
|
|
54
|
+
? head.slice(head.lastIndexOf('/') + 1)
|
|
55
|
+
: head.slice(0, 7);
|
|
56
|
+
return { repo: basename(dir), branch };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// `.git` is a directory (readFileSync throws EISDIR) or unreadable — the
|
|
61
|
+
// repo name is still the useful half.
|
|
62
|
+
return { repo: basename(dir) };
|
|
63
|
+
}
|
|
64
|
+
const up = dirname(dir);
|
|
65
|
+
if (up === dir)
|
|
66
|
+
break;
|
|
67
|
+
dir = up;
|
|
68
|
+
}
|
|
69
|
+
return { repo: basename(resolve(cwd)) };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The user's most recent request, from the transcript the harness points at.
|
|
73
|
+
* OFF unless RATIFIA_GATE_INCLUDE_PROMPT=1: it is case content, and the gate is
|
|
74
|
+
* otherwise metadata only. The file is JSONL and written asynchronously, so the
|
|
75
|
+
* newest lines may lag; a miss just means no block.
|
|
76
|
+
*/
|
|
77
|
+
function lastUserRequest(transcriptPath) {
|
|
78
|
+
if (process.env.RATIFIA_GATE_INCLUDE_PROMPT !== '1')
|
|
79
|
+
return null;
|
|
80
|
+
if (typeof transcriptPath !== 'string' || !transcriptPath)
|
|
81
|
+
return null;
|
|
82
|
+
try {
|
|
83
|
+
const lines = readFileSync(transcriptPath, 'utf8').split('\n').filter(Boolean);
|
|
84
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
85
|
+
const row = JSON.parse(lines[i]);
|
|
86
|
+
if (row.type !== 'user' && row.message?.role !== 'user')
|
|
87
|
+
continue;
|
|
88
|
+
const content = row.message?.content;
|
|
89
|
+
const text = typeof content === 'string'
|
|
90
|
+
? content
|
|
91
|
+
: Array.isArray(content)
|
|
92
|
+
? content
|
|
93
|
+
.filter((c) => typeof c === 'object' && c !== null && c.type === 'text')
|
|
94
|
+
.map((c) => c.text)
|
|
95
|
+
.join('\n')
|
|
96
|
+
: '';
|
|
97
|
+
const trimmed = text.trim();
|
|
98
|
+
// Skip the harness's own injected turns — they are not what the user asked.
|
|
99
|
+
if (!trimmed || trimmed.startsWith('<'))
|
|
100
|
+
continue;
|
|
101
|
+
return trimmed.length > 1500 ? `${trimmed.slice(0, 1500)}…` : trimmed;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
/* unreadable transcript — the gate still works without it */
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* `ratifia-mcp gate` — a Claude Code **PreToolUse hook**. Opens a Ratifia
|
|
111
|
+
* decision for the tool call (rendered as a `tool_call` context panel), blocks
|
|
112
|
+
* until a human answers, and returns allow/deny to the harness. The "list of
|
|
113
|
+
* tools" that trigger this is the hook's matcher in settings.json; narrow it
|
|
114
|
+
* further with RATIFIA_GATED_TOOLS. Fail-CLOSED (deny) on error/timeout unless
|
|
115
|
+
* RATIFIA_GATE_FAIL_OPEN=1 — a gate that fails open is no gate.
|
|
116
|
+
*/
|
|
117
|
+
export async function runGate(cfg) {
|
|
118
|
+
const { api, waitForDecision } = createClient(cfg);
|
|
119
|
+
const failOpen = process.env.RATIFIA_GATE_FAIL_OPEN === '1';
|
|
120
|
+
let payload = {};
|
|
121
|
+
try {
|
|
122
|
+
payload = JSON.parse((await readStdin()) || '{}');
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
/* proceed with an empty payload */
|
|
126
|
+
}
|
|
127
|
+
const toolName = String(payload.tool_name ?? 'unknown_tool');
|
|
128
|
+
const toolInput = payload.tool_input ?? {};
|
|
129
|
+
// FN: the reviewer sees WHAT would run (the tool call) — give them WHERE and
|
|
130
|
+
// WHO too. All of it comes from the harness payload; none of it is guessed.
|
|
131
|
+
const cwd = typeof payload.cwd === 'string' ? payload.cwd : '';
|
|
132
|
+
const { repo, branch } = cwd ? repoContext(cwd) : {};
|
|
133
|
+
const where = [repo, branch && `(${branch})`].filter(Boolean).join(' ');
|
|
134
|
+
const askedFor = lastUserRequest(payload.transcript_path);
|
|
135
|
+
const fields = [
|
|
136
|
+
repo ? { label: 'Repo', value: branch ? `${repo} (${branch})` : repo } : null,
|
|
137
|
+
cwd ? { label: 'Directory', value: cwd } : null,
|
|
138
|
+
typeof payload.agent_type === 'string' && payload.agent_type ? { label: 'Agent', value: payload.agent_type } : null,
|
|
139
|
+
typeof payload.permission_mode === 'string' && payload.permission_mode
|
|
140
|
+
? { label: 'Permission mode', value: payload.permission_mode }
|
|
141
|
+
: null,
|
|
142
|
+
typeof payload.session_id === 'string' && payload.session_id
|
|
143
|
+
? { label: 'Session', value: payload.session_id.slice(0, 12) }
|
|
144
|
+
: null,
|
|
145
|
+
].filter((f) => f !== null);
|
|
146
|
+
// Optional allow-list: if set, only gate these tools; pass the rest through.
|
|
147
|
+
const gated = (process.env.RATIFIA_GATED_TOOLS ?? '')
|
|
148
|
+
.split(',')
|
|
149
|
+
.map((s) => s.trim())
|
|
150
|
+
.filter(Boolean);
|
|
151
|
+
if (gated.length > 0 && !gated.includes(toolName)) {
|
|
152
|
+
return gateDecision('allow', `not a gated tool: ${toolName}`);
|
|
153
|
+
}
|
|
154
|
+
// How long to block waiting for a human. Keep the settings.json hook `timeout`
|
|
155
|
+
// a little higher than this; on timeout we fail-closed (deny) by default.
|
|
156
|
+
const budgetMs = Math.max(10, Number(process.env.RATIFIA_GATE_TIMEOUT_SEC ?? 240)) * 1000;
|
|
157
|
+
let decisionId;
|
|
158
|
+
try {
|
|
159
|
+
// One id per hook invocation: retries below collapse into this decision,
|
|
160
|
+
// while a genuinely separate tool call mints its own and gates separately.
|
|
161
|
+
const externalRunId = `gate-${randomUUID()}`;
|
|
162
|
+
const res = await apiRetry(() => api('/v1/worker/decisions', {
|
|
163
|
+
method: 'POST',
|
|
164
|
+
idempotencyKey: externalRunId,
|
|
165
|
+
body: JSON.stringify({
|
|
166
|
+
engine: 'mcp',
|
|
167
|
+
externalRunId,
|
|
168
|
+
stepRef: `tool:${toolName}`.slice(0, 80),
|
|
169
|
+
workflowRef: label(cfg.workflow, 'ratifia-gate', 100),
|
|
170
|
+
...who(cfg.policy, cfg.approvers),
|
|
171
|
+
required: true,
|
|
172
|
+
context: {
|
|
173
|
+
prompt: `An agent wants to run the tool "${toolName}"${where ? ` in ${where}` : ''}. Approve?`,
|
|
174
|
+
blocks: [
|
|
175
|
+
{ kind: 'tool_call', title: 'Tool call', tool: toolName, args: toolInput },
|
|
176
|
+
...(fields.length ? [{ kind: 'fields', title: 'Where', fields }] : []),
|
|
177
|
+
...(askedFor ? [{ kind: 'text', title: 'What the user asked for', text: askedFor }] : []),
|
|
178
|
+
],
|
|
179
|
+
},
|
|
180
|
+
}),
|
|
181
|
+
}));
|
|
182
|
+
// Policy gate resolved "no approval needed" → let it run.
|
|
183
|
+
if (res.approvalNeeded === false || !res.decisionId) {
|
|
184
|
+
return gateDecision('allow', 'no approval required by policy');
|
|
185
|
+
}
|
|
186
|
+
decisionId = res.decisionId;
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
// A bad RATIFIA_POLICY denies even with fail-open: fail-open is for Ratifia
|
|
190
|
+
// being unreachable, and letting a misconfigured gate wave every tool call
|
|
191
|
+
// through is exactly the outage it exists to prevent.
|
|
192
|
+
if (isUnknownPolicy(err))
|
|
193
|
+
return gateDecision('deny', unknownPolicyMessage(cfg.policy));
|
|
194
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
195
|
+
return gateDecision(failOpen ? 'allow' : 'deny', `ratifia gate unreachable: ${msg}`);
|
|
196
|
+
}
|
|
197
|
+
// Block until the human answers (or the budget elapses → fail-closed).
|
|
198
|
+
try {
|
|
199
|
+
const d = await waitForDecision(decisionId, budgetMs);
|
|
200
|
+
if (d.status === 'APPROVED')
|
|
201
|
+
return gateDecision('allow', d.note ?? 'approved by reviewer');
|
|
202
|
+
if (d.status === 'REJECTED')
|
|
203
|
+
return gateDecision('deny', d.note ?? 'rejected by reviewer');
|
|
204
|
+
if (d.status !== 'PENDING')
|
|
205
|
+
return gateDecision('deny', `decision ${d.status.toLowerCase()}`);
|
|
206
|
+
return gateDecision(failOpen ? 'allow' : 'deny', 'timed out waiting for approval');
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
210
|
+
return gateDecision(failOpen ? 'allow' : 'deny', `ratifia gate error: ${msg}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
package/dist/index.d.ts
ADDED