multicorn-shield 0.11.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/CHANGELOG.md +39 -0
- package/dist/badge.js +4 -4
- package/dist/index.cjs +38 -19
- package/dist/index.d.cts +11 -2
- package/dist/index.d.ts +11 -2
- package/dist/index.js +37 -20
- package/dist/multicorn-proxy.js +578 -15
- package/dist/openclaw-hook/handler.js +0 -1
- package/dist/openclaw-plugin/multicorn-shield.js +11 -17
- package/dist/openclaw-plugin/openclaw.plugin.json +3 -1
- package/dist/proxy.cjs +174 -0
- package/dist/proxy.d.cts +228 -1
- package/dist/proxy.d.ts +228 -1
- package/dist/proxy.js +174 -1
- package/dist/shield-extension.js +1 -4
- package/package.json +4 -2
- package/plugins/cline/README.md +61 -0
- package/plugins/cline/hooks/scripts/post-tool-use.cjs +116 -0
- package/plugins/cline/hooks/scripts/pre-tool-use.cjs +271 -0
- package/plugins/cline/hooks/scripts/shared.cjs +303 -0
- package/plugins/gemini-cli/hooks/scripts/after-tool.cjs +110 -0
- package/plugins/gemini-cli/hooks/scripts/before-tool.cjs +197 -0
- package/plugins/gemini-cli/hooks/scripts/shared.cjs +319 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Copyright (c) Multicorn AI Pty Ltd. MIT License. See LICENSE file.
|
|
3
|
+
/**
|
|
4
|
+
* Cline PreToolUse hook: asks Shield whether a tool call is allowed.
|
|
5
|
+
* Reads JSON from stdin (Cline Hooks API), checks permissions via Shield API.
|
|
6
|
+
* Returns {"cancel": false} to allow, {"cancel": true, "errorMessage": "..."} to block.
|
|
7
|
+
* Fail-open if Shield is not configured or API is unreachable.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
"use strict";
|
|
11
|
+
|
|
12
|
+
const { execFileSync } = require("node:child_process");
|
|
13
|
+
|
|
14
|
+
const {
|
|
15
|
+
buildScrubbedParametersJson,
|
|
16
|
+
loadConfig,
|
|
17
|
+
logPrefix,
|
|
18
|
+
mapToolName,
|
|
19
|
+
postJson,
|
|
20
|
+
readStdin,
|
|
21
|
+
safeJsonParse,
|
|
22
|
+
unwrapData,
|
|
23
|
+
} = require("./shared.cjs");
|
|
24
|
+
|
|
25
|
+
const HOOK_PREFIX = logPrefix("pre-hook");
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} apiBaseUrl
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
function dashboardOrigin(apiBaseUrl) {
|
|
32
|
+
try {
|
|
33
|
+
const raw = String(apiBaseUrl).replace(/\/+$/, "");
|
|
34
|
+
const lower = raw.toLowerCase();
|
|
35
|
+
if (lower.includes("localhost:8080") || lower.includes("127.0.0.1:8080")) {
|
|
36
|
+
return "http://localhost:5173";
|
|
37
|
+
}
|
|
38
|
+
const u = new URL(raw);
|
|
39
|
+
if (u.hostname.startsWith("api.")) {
|
|
40
|
+
u.hostname = "app." + u.hostname.slice(4);
|
|
41
|
+
}
|
|
42
|
+
return u.origin;
|
|
43
|
+
} catch {
|
|
44
|
+
return "https://app.multicorn.ai";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {string} apiBaseUrl
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
function dashboardHintUrl(apiBaseUrl) {
|
|
53
|
+
return `${dashboardOrigin(apiBaseUrl)}/approvals`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {string} apiBaseUrl
|
|
58
|
+
* @param {string} agentName
|
|
59
|
+
* @param {string} service
|
|
60
|
+
* @param {string} actionType
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
function consentUrl(apiBaseUrl, agentName, service, actionType) {
|
|
64
|
+
const origin = dashboardOrigin(apiBaseUrl);
|
|
65
|
+
const params = new URLSearchParams();
|
|
66
|
+
params.set("agent", agentName);
|
|
67
|
+
params.set("scopes", `${service}:${actionType}`);
|
|
68
|
+
params.set("platform", "cline");
|
|
69
|
+
return `${origin}/consent?${params.toString()}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {unknown} data
|
|
74
|
+
* @param {string} service
|
|
75
|
+
* @param {string} actionType
|
|
76
|
+
* @param {string} approvalsUrl
|
|
77
|
+
* @returns {string}
|
|
78
|
+
*/
|
|
79
|
+
function blockedMessage(data, service, actionType, approvalsUrl) {
|
|
80
|
+
if (data !== null && typeof data === "object") {
|
|
81
|
+
const d = /** @type {Record<string, unknown>} */ (data);
|
|
82
|
+
const meta = d.metadata;
|
|
83
|
+
if (typeof meta === "string" && meta.length > 0) {
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(meta);
|
|
86
|
+
if (parsed !== null && typeof parsed === "object" && "block_reason" in parsed) {
|
|
87
|
+
const br = /** @type {Record<string, unknown>} */ (parsed).block_reason;
|
|
88
|
+
if (typeof br === "string" && br.length > 0) {
|
|
89
|
+
return `Shield: Action blocked - ${br}. Grant access at ${approvalsUrl}`;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
/* ignore */
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return `Shield: Action blocked. Required permission: ${service} (${actionType}). Grant access at ${approvalsUrl}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @param {string} url
|
|
102
|
+
*/
|
|
103
|
+
function openBrowser(url) {
|
|
104
|
+
try {
|
|
105
|
+
if (process.platform === "darwin") {
|
|
106
|
+
execFileSync("open", [url], { stdio: "ignore" });
|
|
107
|
+
} else if (process.platform === "win32") {
|
|
108
|
+
execFileSync("cmd.exe", ["/c", "start", "", url], {
|
|
109
|
+
stdio: "ignore",
|
|
110
|
+
windowsHide: true,
|
|
111
|
+
});
|
|
112
|
+
} else {
|
|
113
|
+
execFileSync("xdg-open", [url], { stdio: "ignore" });
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
/* ignore */
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Outputs JSON response to stdout and exits.
|
|
122
|
+
* @param {boolean} cancel
|
|
123
|
+
* @param {string} [errorMessage]
|
|
124
|
+
*/
|
|
125
|
+
function respond(cancel, errorMessage) {
|
|
126
|
+
const response = cancel ? { cancel: true, errorMessage } : { cancel: false };
|
|
127
|
+
process.stdout.write(JSON.stringify(response) + "\n");
|
|
128
|
+
process.exit(0);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function main() {
|
|
132
|
+
let raw;
|
|
133
|
+
try {
|
|
134
|
+
raw = await readStdin();
|
|
135
|
+
} catch (e) {
|
|
136
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
137
|
+
process.stderr.write(`${HOOK_PREFIX} could not read stdin (${msg}). Allowing action.\n`);
|
|
138
|
+
respond(false);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const config = loadConfig();
|
|
143
|
+
if (config === null) {
|
|
144
|
+
respond(false);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (config.apiKey.length === 0 || config.agentName.length === 0) {
|
|
148
|
+
respond(false);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** @type {Record<string, unknown>} */
|
|
153
|
+
let hookPayload;
|
|
154
|
+
try {
|
|
155
|
+
hookPayload = JSON.parse(raw.length > 0 ? raw : "{}");
|
|
156
|
+
} catch (e) {
|
|
157
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
158
|
+
process.stderr.write(`${HOOK_PREFIX} invalid JSON (${msg}). Allowing action.\n`);
|
|
159
|
+
respond(false);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const preToolUse = hookPayload.preToolUse;
|
|
164
|
+
if (preToolUse === null || typeof preToolUse !== "object") {
|
|
165
|
+
respond(false);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const toolUse = /** @type {Record<string, unknown>} */ (preToolUse);
|
|
170
|
+
const toolName =
|
|
171
|
+
typeof toolUse.toolName === "string"
|
|
172
|
+
? toolUse.toolName
|
|
173
|
+
: typeof toolUse.tool === "string"
|
|
174
|
+
? toolUse.tool
|
|
175
|
+
: "";
|
|
176
|
+
const parameters =
|
|
177
|
+
typeof toolUse.parameters === "object" && toolUse.parameters !== null
|
|
178
|
+
? /** @type {Record<string, unknown>} */ (toolUse.parameters)
|
|
179
|
+
: {};
|
|
180
|
+
|
|
181
|
+
if (toolName.length === 0) {
|
|
182
|
+
respond(false);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const { service, actionType } = mapToolName(toolName);
|
|
187
|
+
|
|
188
|
+
const paramsSerialized = buildScrubbedParametersJson(parameters);
|
|
189
|
+
|
|
190
|
+
const approvalsUrl = dashboardHintUrl(config.baseUrl);
|
|
191
|
+
|
|
192
|
+
/** @type {Record<string, unknown>} */
|
|
193
|
+
const metadata = {
|
|
194
|
+
tool_name: toolName,
|
|
195
|
+
task_id: typeof hookPayload.taskId === "string" ? hookPayload.taskId : "",
|
|
196
|
+
cline_version: typeof hookPayload.clineVersion === "string" ? hookPayload.clineVersion : "",
|
|
197
|
+
parameters: paramsSerialized,
|
|
198
|
+
source: "cline",
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/** @type {Record<string, unknown>} */
|
|
202
|
+
const payload = {
|
|
203
|
+
agent: config.agentName,
|
|
204
|
+
service,
|
|
205
|
+
actionType,
|
|
206
|
+
status: "pending",
|
|
207
|
+
metadata,
|
|
208
|
+
platform: "cline",
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
let statusCode;
|
|
212
|
+
let bodyText;
|
|
213
|
+
try {
|
|
214
|
+
const res = await postJson(config.baseUrl, config.apiKey, payload);
|
|
215
|
+
statusCode = res.statusCode;
|
|
216
|
+
bodyText = res.bodyText;
|
|
217
|
+
} catch (e) {
|
|
218
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
219
|
+
process.stderr.write(`${HOOK_PREFIX} Shield API unreachable (${msg}). Allowing action.\n`);
|
|
220
|
+
respond(false);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const parsed = safeJsonParse(bodyText);
|
|
225
|
+
const data = unwrapData(parsed);
|
|
226
|
+
|
|
227
|
+
if (statusCode === 202) {
|
|
228
|
+
const url = consentUrl(config.baseUrl, config.agentName, service, actionType);
|
|
229
|
+
openBrowser(url);
|
|
230
|
+
respond(
|
|
231
|
+
true,
|
|
232
|
+
`Shield: ${config.agentName} needs ${service}:${actionType} permission. Authorize at ${url} then retry this action.`,
|
|
233
|
+
);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (statusCode === 201) {
|
|
238
|
+
if (data === null || typeof data !== "object") {
|
|
239
|
+
respond(
|
|
240
|
+
true,
|
|
241
|
+
`Shield: ${config.agentName} needs ${service}:${actionType} permission. Approve at ${consentUrl(config.baseUrl, config.agentName, service, actionType)} or review at ${approvalsUrl}`,
|
|
242
|
+
);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const st = String(/** @type {Record<string, unknown>} */ (data).status || "").toLowerCase();
|
|
246
|
+
if (st === "approved") {
|
|
247
|
+
respond(false);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (st === "blocked") {
|
|
251
|
+
respond(true, blockedMessage(data, service, actionType, approvalsUrl));
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
respond(
|
|
255
|
+
true,
|
|
256
|
+
`Shield: ${config.agentName} needs ${service}:${actionType} permission. Approve at ${consentUrl(config.baseUrl, config.agentName, service, actionType)} or review at ${approvalsUrl}`,
|
|
257
|
+
);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
respond(
|
|
262
|
+
true,
|
|
263
|
+
`Shield: ${config.agentName} needs ${service}:${actionType} permission. Approve at ${consentUrl(config.baseUrl, config.agentName, service, actionType)} or review at ${approvalsUrl}`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
main().catch((e) => {
|
|
268
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
269
|
+
process.stderr.write(`${HOOK_PREFIX} unexpected error (${msg}). Allowing action.\n`);
|
|
270
|
+
respond(false);
|
|
271
|
+
});
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// Copyright (c) Multicorn AI Pty Ltd. MIT License. See LICENSE file.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Shared helpers for Cline PreToolUse / PostToolUse Shield hooks.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
"use strict";
|
|
8
|
+
|
|
9
|
+
const fs = require("node:fs");
|
|
10
|
+
const http = require("node:http");
|
|
11
|
+
const https = require("node:https");
|
|
12
|
+
const os = require("node:os");
|
|
13
|
+
const path = require("node:path");
|
|
14
|
+
|
|
15
|
+
const AUTH_HEADER = "X-Multicorn-Key";
|
|
16
|
+
const HTTP_REQUEST_TIMEOUT_MS = 10000;
|
|
17
|
+
|
|
18
|
+
/** @type {Readonly<Record<string, { service: string; actionType: string }>>} */
|
|
19
|
+
const TOOL_MAP = {
|
|
20
|
+
read_file: { service: "filesystem", actionType: "read" },
|
|
21
|
+
write_to_file: { service: "filesystem", actionType: "write" },
|
|
22
|
+
replace_in_file: { service: "filesystem", actionType: "write" },
|
|
23
|
+
execute_command: { service: "terminal", actionType: "execute" },
|
|
24
|
+
browser_action: { service: "browser", actionType: "execute" },
|
|
25
|
+
list_files: { service: "filesystem", actionType: "read" },
|
|
26
|
+
search_files: { service: "filesystem", actionType: "read" },
|
|
27
|
+
list_code_definition_names: { service: "filesystem", actionType: "read" },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Builds the stderr log line prefix for a hook segment.
|
|
32
|
+
* @param {string} label e.g. "pre-hook", "post-hook", "config"
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
function logPrefix(label) {
|
|
36
|
+
return `[multicorn-shield] Cline ${label}:`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @returns {Promise<string>}
|
|
41
|
+
*/
|
|
42
|
+
function readStdin() {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const chunks = [];
|
|
45
|
+
process.stdin.setEncoding("utf8");
|
|
46
|
+
process.stdin.on("data", (c) => chunks.push(c));
|
|
47
|
+
process.stdin.on("end", () => resolve(chunks.join("")));
|
|
48
|
+
process.stdin.on("error", reject);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {Record<string, unknown>} obj
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
function resolveClineAgentName(obj) {
|
|
57
|
+
const agents = obj.agents;
|
|
58
|
+
if (Array.isArray(agents)) {
|
|
59
|
+
for (const entry of agents) {
|
|
60
|
+
if (
|
|
61
|
+
entry &&
|
|
62
|
+
typeof entry === "object" &&
|
|
63
|
+
/** @type {{ platform?: string; name?: string }} */ (entry).platform === "cline" &&
|
|
64
|
+
typeof (/** @type {{ platform?: string; name?: string }} */ (entry).name) === "string"
|
|
65
|
+
) {
|
|
66
|
+
return /** @type {{ name: string }} */ (entry).name;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return typeof obj.agentName === "string" ? obj.agentName : "";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Reads ~/.multicorn/config.json. Rejects non-HTTPS remote baseUrl (fail-open).
|
|
75
|
+
* @returns {{ apiKey: string; baseUrl: string; agentName: string } | null}
|
|
76
|
+
* @example
|
|
77
|
+
* const cfg = loadConfig();
|
|
78
|
+
* if (cfg && cfg.apiKey) { await postJson(cfg.baseUrl, cfg.apiKey, body); }
|
|
79
|
+
*/
|
|
80
|
+
function loadConfig() {
|
|
81
|
+
try {
|
|
82
|
+
const configPath = path.join(os.homedir(), ".multicorn", "config.json");
|
|
83
|
+
const raw = fs.readFileSync(configPath, "utf8");
|
|
84
|
+
const obj = JSON.parse(raw);
|
|
85
|
+
const apiKey = typeof obj.apiKey === "string" ? obj.apiKey : "";
|
|
86
|
+
const baseUrl =
|
|
87
|
+
typeof obj.baseUrl === "string" && obj.baseUrl.length > 0
|
|
88
|
+
? obj.baseUrl.replace(/\/+$/, "")
|
|
89
|
+
: "https://api.multicorn.ai";
|
|
90
|
+
const baseLower = baseUrl.toLowerCase();
|
|
91
|
+
const isHttps = baseLower.startsWith("https://");
|
|
92
|
+
const isLocal = baseLower.includes("localhost") || baseLower.includes("127.0.0.1");
|
|
93
|
+
if (!isHttps && !isLocal) {
|
|
94
|
+
process.stderr.write(
|
|
95
|
+
`${logPrefix("config")} baseUrl must use HTTPS for non-local servers. Fail-open: Shield disabled.\n`,
|
|
96
|
+
);
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
const agentName = resolveClineAgentName(obj);
|
|
100
|
+
return { apiKey, baseUrl, agentName };
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Maps a Cline tool name to a Shield service/actionType pair.
|
|
108
|
+
* MCP tools (prefixed mcp_ or containing __) map to `mcp:server.tool`.
|
|
109
|
+
* @param {string} toolName
|
|
110
|
+
* @returns {{ service: string; actionType: string }}
|
|
111
|
+
* @example
|
|
112
|
+
* mapToolName("read_file"); // { service: "filesystem", actionType: "read" }
|
|
113
|
+
* mapToolName("mcp_foo__bar"); // { service: "mcp:foo.bar", actionType: "execute" }
|
|
114
|
+
*/
|
|
115
|
+
function mapToolName(toolName) {
|
|
116
|
+
const name = String(toolName || "").trim();
|
|
117
|
+
|
|
118
|
+
if (name.startsWith("mcp_") || name.includes("__")) {
|
|
119
|
+
const parts = name.startsWith("mcp_") ? name.slice(4) : name;
|
|
120
|
+
const sepIdx = parts.indexOf("__");
|
|
121
|
+
if (sepIdx > 0) {
|
|
122
|
+
const server = parts.slice(0, sepIdx).replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
123
|
+
const tool = parts.slice(sepIdx + 2).replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
124
|
+
return { service: `mcp:${server}.${tool}`, actionType: "execute" };
|
|
125
|
+
}
|
|
126
|
+
const safe = parts.replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
127
|
+
return { service: `mcp:${safe}`, actionType: "execute" };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const mapped = TOOL_MAP[name];
|
|
131
|
+
if (mapped !== undefined) {
|
|
132
|
+
return mapped;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { service: "unknown", actionType: "execute" };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* POST JSON to /api/v1/actions; returns status and raw body text.
|
|
140
|
+
* @param {string} baseUrl
|
|
141
|
+
* @param {string} apiKey
|
|
142
|
+
* @param {Record<string, unknown>} bodyObj
|
|
143
|
+
* @returns {Promise<{ statusCode: number; bodyText: string }>}
|
|
144
|
+
* @example
|
|
145
|
+
* const { statusCode, bodyText } = await postJson(
|
|
146
|
+
* "https://api.multicorn.ai",
|
|
147
|
+
* apiKey,
|
|
148
|
+
* { agent: "my-agent", service: "filesystem", actionType: "read", status: "pending", metadata: {}, platform: "cline" },
|
|
149
|
+
* );
|
|
150
|
+
*/
|
|
151
|
+
function postJson(baseUrl, apiKey, bodyObj) {
|
|
152
|
+
return new Promise((resolve, reject) => {
|
|
153
|
+
let u;
|
|
154
|
+
try {
|
|
155
|
+
const root = String(baseUrl).replace(/\/+$/, "");
|
|
156
|
+
u = new URL(`${root}/api/v1/actions`);
|
|
157
|
+
} catch (e) {
|
|
158
|
+
reject(e);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const payload = JSON.stringify(bodyObj);
|
|
162
|
+
const isHttps = u.protocol === "https:";
|
|
163
|
+
const lib = isHttps ? https : http;
|
|
164
|
+
const port = u.port || (isHttps ? 443 : 80);
|
|
165
|
+
const options = {
|
|
166
|
+
hostname: u.hostname,
|
|
167
|
+
port,
|
|
168
|
+
path: u.pathname + u.search,
|
|
169
|
+
method: "POST",
|
|
170
|
+
headers: {
|
|
171
|
+
Connection: "close",
|
|
172
|
+
"Content-Type": "application/json",
|
|
173
|
+
"Content-Length": Buffer.byteLength(payload, "utf8"),
|
|
174
|
+
[AUTH_HEADER]: apiKey,
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
const req = lib.request(options, (res) => {
|
|
178
|
+
const chunks = [];
|
|
179
|
+
res.on("data", (c) => chunks.push(c));
|
|
180
|
+
res.on("end", () => {
|
|
181
|
+
resolve({
|
|
182
|
+
statusCode: res.statusCode ?? 0,
|
|
183
|
+
bodyText: Buffer.concat(chunks).toString("utf8"),
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
req.setTimeout(HTTP_REQUEST_TIMEOUT_MS, () => {
|
|
188
|
+
req.destroy(new Error("request timeout"));
|
|
189
|
+
});
|
|
190
|
+
req.on("error", reject);
|
|
191
|
+
req.write(payload);
|
|
192
|
+
req.end();
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* @param {string} text
|
|
198
|
+
* @returns {unknown}
|
|
199
|
+
*/
|
|
200
|
+
function safeJsonParse(text) {
|
|
201
|
+
try {
|
|
202
|
+
return JSON.parse(text);
|
|
203
|
+
} catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* @param {unknown} body
|
|
210
|
+
* @returns {unknown}
|
|
211
|
+
*/
|
|
212
|
+
function unwrapData(body) {
|
|
213
|
+
if (typeof body !== "object" || body === null) return null;
|
|
214
|
+
const o = /** @type {Record<string, unknown>} */ (body);
|
|
215
|
+
return o.success === true ? o.data : null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Redact oversized or sensitive-ish command text for metadata.
|
|
220
|
+
* @param {Record<string, unknown>} parameters
|
|
221
|
+
*/
|
|
222
|
+
function applyParameterScrub(parameters) {
|
|
223
|
+
const scrubbedParams = { ...parameters };
|
|
224
|
+
if (typeof scrubbedParams.content === "string") {
|
|
225
|
+
scrubbedParams.content = `[${scrubbedParams.content.length} chars redacted]`;
|
|
226
|
+
}
|
|
227
|
+
if (typeof scrubbedParams.command === "string" && scrubbedParams.command.length > 200) {
|
|
228
|
+
scrubbedParams.command = scrubbedParams.command.slice(0, 200) + "... [truncated]";
|
|
229
|
+
}
|
|
230
|
+
return scrubbedParams;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Normalizes hook parameters (object or JSON string) and returns scrubbed JSON string.
|
|
235
|
+
* @param {unknown} parameters
|
|
236
|
+
* @param {number} [maxLen]
|
|
237
|
+
* @returns {string}
|
|
238
|
+
*/
|
|
239
|
+
function buildScrubbedParametersJson(parameters, maxLen = 4096) {
|
|
240
|
+
/** @type {Record<string, unknown>} */
|
|
241
|
+
let base = {};
|
|
242
|
+
if (typeof parameters === "object" && parameters !== null) {
|
|
243
|
+
base = { .../** @type {Record<string, unknown>} */ (parameters) };
|
|
244
|
+
} else if (typeof parameters === "string") {
|
|
245
|
+
try {
|
|
246
|
+
const p = JSON.parse(parameters);
|
|
247
|
+
if (p !== null && typeof p === "object") {
|
|
248
|
+
base = { .../** @type {Record<string, unknown>} */ (p) };
|
|
249
|
+
} else {
|
|
250
|
+
base = { raw: parameters };
|
|
251
|
+
}
|
|
252
|
+
} catch {
|
|
253
|
+
base = { raw: parameters };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const scrubbed = applyParameterScrub(base);
|
|
258
|
+
let paramsSerialized;
|
|
259
|
+
try {
|
|
260
|
+
paramsSerialized = JSON.stringify(scrubbed);
|
|
261
|
+
} catch {
|
|
262
|
+
paramsSerialized = "{}";
|
|
263
|
+
}
|
|
264
|
+
if (paramsSerialized.length > maxLen) {
|
|
265
|
+
paramsSerialized = paramsSerialized.slice(0, maxLen);
|
|
266
|
+
}
|
|
267
|
+
return paramsSerialized;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Strip likely secrets from tool result strings for audit logging.
|
|
272
|
+
* @param {unknown} result
|
|
273
|
+
* @returns {string}
|
|
274
|
+
*/
|
|
275
|
+
function scrubResultForMetadata(result) {
|
|
276
|
+
if (typeof result !== "string") return "";
|
|
277
|
+
let s = result;
|
|
278
|
+
s = s.replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, "[REDACTED]");
|
|
279
|
+
s = s.replace(/\bmcs_[A-Za-z0-9_-]+\b/g, "[REDACTED]");
|
|
280
|
+
s = s.replace(/\bghp_[A-Za-z0-9]{20,}\b/g, "[REDACTED]");
|
|
281
|
+
s = s.replace(/Bearer\s+[^\s]+/gi, "[REDACTED]");
|
|
282
|
+
s = s.replace(/\b(password|token)\s*[:=]\s*[^\s]+\b/gi, "[REDACTED]");
|
|
283
|
+
if (s.length > 500) {
|
|
284
|
+
s = s.slice(0, 500) + "[truncated]";
|
|
285
|
+
}
|
|
286
|
+
return s;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
module.exports = {
|
|
290
|
+
AUTH_HEADER,
|
|
291
|
+
logPrefix,
|
|
292
|
+
HTTP_REQUEST_TIMEOUT_MS,
|
|
293
|
+
TOOL_MAP,
|
|
294
|
+
readStdin,
|
|
295
|
+
loadConfig,
|
|
296
|
+
resolveClineAgentName,
|
|
297
|
+
mapToolName,
|
|
298
|
+
postJson,
|
|
299
|
+
safeJsonParse,
|
|
300
|
+
unwrapData,
|
|
301
|
+
buildScrubbedParametersJson,
|
|
302
|
+
scrubResultForMetadata,
|
|
303
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Copyright (c) Multicorn AI Pty Ltd. MIT License. See LICENSE file.
|
|
3
|
+
/**
|
|
4
|
+
* Gemini CLI AfterTool hook: audit logging to Shield.
|
|
5
|
+
* Always returns { "decision": "allow" } on stdout.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
"use strict";
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
loadConfig,
|
|
12
|
+
logPrefix,
|
|
13
|
+
mapToolName,
|
|
14
|
+
postJson,
|
|
15
|
+
readStdin,
|
|
16
|
+
scrubParameters,
|
|
17
|
+
scrubResultForMetadata,
|
|
18
|
+
} = require("./shared.cjs");
|
|
19
|
+
|
|
20
|
+
const HOOK_PREFIX = logPrefix("after-tool");
|
|
21
|
+
|
|
22
|
+
function respond() {
|
|
23
|
+
process.stdout.write(JSON.stringify({ decision: "allow" }) + "\n");
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function main() {
|
|
28
|
+
let raw;
|
|
29
|
+
try {
|
|
30
|
+
raw = await readStdin();
|
|
31
|
+
} catch {
|
|
32
|
+
respond();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** @type {Record<string, unknown>} */
|
|
37
|
+
let hookPayload;
|
|
38
|
+
try {
|
|
39
|
+
hookPayload = JSON.parse(raw.length > 0 ? raw : "{}");
|
|
40
|
+
} catch {
|
|
41
|
+
respond();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const toolName = typeof hookPayload.tool_name === "string" ? hookPayload.tool_name : "";
|
|
46
|
+
const mapped = mapToolName(toolName);
|
|
47
|
+
|
|
48
|
+
if (mapped === null) {
|
|
49
|
+
respond();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const { service, actionType } = mapped;
|
|
53
|
+
|
|
54
|
+
const config = loadConfig();
|
|
55
|
+
if (config === null || config.apiKey.length === 0 || config.agentName.length === 0) {
|
|
56
|
+
respond();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const toolInput =
|
|
61
|
+
typeof hookPayload.tool_input === "object" && hookPayload.tool_input !== null
|
|
62
|
+
? /** @type {Record<string, unknown>} */ (hookPayload.tool_input)
|
|
63
|
+
: {};
|
|
64
|
+
|
|
65
|
+
const paramsSerialized = scrubParameters(toolInput);
|
|
66
|
+
const toolResponse = hookPayload.tool_response;
|
|
67
|
+
|
|
68
|
+
/** @type {Record<string, unknown>} */
|
|
69
|
+
const metadata = {
|
|
70
|
+
tool_name: toolName,
|
|
71
|
+
session_id: typeof hookPayload.session_id === "string" ? hookPayload.session_id : "",
|
|
72
|
+
cwd: typeof hookPayload.cwd === "string" ? hookPayload.cwd : "",
|
|
73
|
+
parameters: paramsSerialized,
|
|
74
|
+
result: scrubResultForMetadata(toolResponse),
|
|
75
|
+
source: "gemini-cli",
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** @type {Record<string, unknown>} */
|
|
79
|
+
const payload = {
|
|
80
|
+
agent: config.agentName,
|
|
81
|
+
service,
|
|
82
|
+
actionType,
|
|
83
|
+
status: "approved",
|
|
84
|
+
metadata,
|
|
85
|
+
platform: "gemini-cli",
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const res = await postJson(config.baseUrl, config.apiKey, payload);
|
|
90
|
+
const code = res.statusCode ?? 0;
|
|
91
|
+
if (code < 200 || code >= 300) {
|
|
92
|
+
throw new Error(`HTTP ${String(code)}`);
|
|
93
|
+
}
|
|
94
|
+
} catch (e) {
|
|
95
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
96
|
+
process.stderr.write(
|
|
97
|
+
`${HOOK_PREFIX} Warning: failed to log action to Shield audit trail. Detail: ${msg}\n`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
respond();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
main().catch((e) => {
|
|
105
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
106
|
+
process.stderr.write(
|
|
107
|
+
`${HOOK_PREFIX} Warning: failed to log action to Shield audit trail. Detail: ${msg}\n`,
|
|
108
|
+
);
|
|
109
|
+
respond();
|
|
110
|
+
});
|