@pinta-ai/pinta-gemini 0.1.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/dist/index.js ADDED
@@ -0,0 +1,700 @@
1
+ // src/env-file.ts
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ function envFilePath() {
6
+ const home = process.env.GEMINI_HOME || path.join(os.homedir(), ".gemini");
7
+ return path.join(home, "pinta-gemini.env");
8
+ }
9
+ function parseEnvFile(content) {
10
+ const out = {};
11
+ for (const raw of content.split("\n")) {
12
+ const line = raw.trim();
13
+ if (!line || line.startsWith("#")) continue;
14
+ const idx = line.indexOf("=");
15
+ if (idx < 0) continue;
16
+ const key = line.slice(0, idx).trim();
17
+ let value = line.slice(idx + 1).trim();
18
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
19
+ value = value.slice(1, -1);
20
+ }
21
+ if (key) out[key] = value;
22
+ }
23
+ return out;
24
+ }
25
+ function loadEnvFile(filePath = envFilePath()) {
26
+ let content;
27
+ try {
28
+ content = fs.readFileSync(filePath, "utf-8");
29
+ } catch {
30
+ return;
31
+ }
32
+ for (const [key, value] of Object.entries(parseEnvFile(content))) {
33
+ if (process.env[key] === void 0) process.env[key] = value;
34
+ }
35
+ }
36
+
37
+ // src/core/config.ts
38
+ import os2 from "os";
39
+ import path2 from "path";
40
+ function geminiHome() {
41
+ return process.env.GEMINI_HOME || path2.join(os2.homedir(), ".gemini");
42
+ }
43
+ function parseHeaders(raw) {
44
+ const out = {};
45
+ if (!raw) return out;
46
+ for (const pair of raw.split(",")) {
47
+ const [k, ...rest] = pair.split("=");
48
+ if (k && rest.length) out[k.trim()] = rest.join("=").trim();
49
+ }
50
+ return out;
51
+ }
52
+ function resolveEndpoint() {
53
+ const traces = process.env.GEMINI_PLUGIN_OPTION_ENDPOINT || process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT;
54
+ if (traces) return traces.replace(/\/+$/, "");
55
+ const base = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
56
+ if (base) return base.replace(/\/+$/, "") + "/v1/traces";
57
+ return void 0;
58
+ }
59
+ function resolveHeaders() {
60
+ const headers = parseHeaders(process.env.GEMINI_PLUGIN_OPTION_HEADERS || process.env.OTEL_EXPORTER_OTLP_HEADERS);
61
+ const apiKey = process.env.GEMINI_PLUGIN_OPTION_API_KEY;
62
+ if (apiKey && !headers["x-pinta-relay-token"]) headers["x-pinta-relay-token"] = apiKey;
63
+ return headers;
64
+ }
65
+ function loadConfig() {
66
+ const pluginData = process.env.GEMINI_PLUGIN_DATA || path2.join(geminiHome(), "pinta-gemini-data");
67
+ if (!process.env.PINTA_RELAY_TOKEN && process.env.GEMINI_PLUGIN_OPTION_API_KEY) {
68
+ process.env.PINTA_RELAY_TOKEN = process.env.GEMINI_PLUGIN_OPTION_API_KEY;
69
+ }
70
+ return {
71
+ pluginData,
72
+ tracePath: path2.join(pluginData, "trace.json"),
73
+ endpoint: resolveEndpoint(),
74
+ headers: resolveHeaders(),
75
+ guardEndpoint: process.env.PINTA_GUARD_ENDPOINT
76
+ };
77
+ }
78
+
79
+ // src/core/agent.ts
80
+ function parseInvocation(argv = process.argv) {
81
+ const get = (name) => {
82
+ const i = argv.indexOf(name);
83
+ return i >= 0 ? argv[i + 1] : void 0;
84
+ };
85
+ return { agent: get("--agent") || "gemini", event: get("--event") };
86
+ }
87
+ function antigravityProduct(ev) {
88
+ const tp = ev["transcriptPath"];
89
+ if (typeof tp !== "string") return void 0;
90
+ if (tp.includes("/antigravity-cli/brain/")) return "agy";
91
+ if (tp.includes("/antigravity/brain/")) return "antigravity2";
92
+ return void 0;
93
+ }
94
+
95
+ // src/core/types.ts
96
+ var isGemini = (agent) => agent === "gemini";
97
+ function identity(agent) {
98
+ return isGemini(agent) ? { prefix: "gemini", ingest: "gemini", service: "gemini-cli" } : { prefix: "antigravity", ingest: "antigravity", service: "antigravity-cli" };
99
+ }
100
+ function gateEvent(agent) {
101
+ return isGemini(agent) ? "BeforeTool" : "PreToolUse";
102
+ }
103
+ var SKIP_HOOKS = /* @__PURE__ */ new Set(["AfterModel"]);
104
+ var isSkippedHook = (hook) => SKIP_HOOKS.has(hook);
105
+
106
+ // src/core/normalize.ts
107
+ function normalize(agent, event, ev) {
108
+ if (isGemini(agent)) {
109
+ return {
110
+ hook: event || ev["hook_event_name"] || "unknown",
111
+ session_id: asString(ev["session_id"]),
112
+ cwd: asString(ev["cwd"]),
113
+ tool_name: asString(ev["tool_name"]),
114
+ tool_input: ev["tool_input"]
115
+ };
116
+ }
117
+ const workspacePaths = ev["workspacePaths"];
118
+ const toolCall = ev["toolCall"];
119
+ return {
120
+ hook: event || "unknown",
121
+ session_id: asString(ev["conversationId"]),
122
+ cwd: Array.isArray(workspacePaths) ? asString(workspacePaths[0]) : void 0,
123
+ tool_name: toolCall?.name,
124
+ tool_input: toolCall?.args
125
+ };
126
+ }
127
+ function asString(v) {
128
+ return typeof v === "string" ? v : void 0;
129
+ }
130
+
131
+ // src/core/guard.ts
132
+ var TIMEOUT_MS = 50;
133
+ function sleep(ms) {
134
+ return new Promise(
135
+ (_, reject) => setTimeout(() => {
136
+ const err = new Error("Guard request timed out");
137
+ err.name = "TimeoutError";
138
+ reject(err);
139
+ }, ms)
140
+ );
141
+ }
142
+ async function evaluateGuard(input, endpoint) {
143
+ if (!endpoint) return null;
144
+ if (process.env.PINTA_GUARD_DISABLED === "1") return null;
145
+ const start = Date.now();
146
+ try {
147
+ const res = await Promise.race([
148
+ fetch(endpoint, {
149
+ method: "POST",
150
+ headers: {
151
+ "content-type": "application/json",
152
+ "x-pinta-relay-token": process.env.PINTA_RELAY_TOKEN ?? ""
153
+ },
154
+ body: JSON.stringify({ input })
155
+ }),
156
+ sleep(TIMEOUT_MS)
157
+ ]);
158
+ if (res.status !== 200) {
159
+ return { decision: "ALLOW", reason: null, userMessage: null, durationMs: Date.now() - start, failOpenReason: "error" };
160
+ }
161
+ const body = await res.json();
162
+ return {
163
+ decision: body.decision,
164
+ reason: body.reason,
165
+ userMessage: body.userMessage ?? null,
166
+ durationMs: body.durationMs ?? Date.now() - start
167
+ };
168
+ } catch (err) {
169
+ const reason = err.name === "TimeoutError" ? "timeout" : "error";
170
+ return { decision: "ALLOW", reason: null, userMessage: null, durationMs: Date.now() - start, failOpenReason: reason };
171
+ }
172
+ }
173
+
174
+ // src/core/retry-queue.ts
175
+ import fs2 from "fs";
176
+ import path3 from "path";
177
+ var MAX_ENTRIES = 1e3;
178
+ var LOCK_TIMEOUT_MS = 50;
179
+ var LOCK_POLL_MS = 5;
180
+ var RetryQueue = class {
181
+ filePath;
182
+ lockPath;
183
+ constructor(pluginData) {
184
+ this.filePath = path3.join(pluginData, "failed-spans.jsonl");
185
+ this.lockPath = this.filePath + ".lock";
186
+ }
187
+ /** Append a single payload. Best-effort: any IO error is swallowed (logged to stderr). */
188
+ enqueue(payload) {
189
+ try {
190
+ fs2.mkdirSync(path3.dirname(this.filePath), { recursive: true });
191
+ const line = JSON.stringify({ savedAt: (/* @__PURE__ */ new Date()).toISOString(), payload }) + "\n";
192
+ fs2.appendFileSync(this.filePath, line);
193
+ this.trim();
194
+ } catch (err) {
195
+ process.stderr.write(`[pinta-gemini] retry-queue enqueue failed: ${err}
196
+ `);
197
+ }
198
+ }
199
+ /**
200
+ * Read all entries oldest-first. Returns [] if the file does not exist or is unreadable.
201
+ * Does NOT delete the file — callers handle persistence via `rewrite`.
202
+ */
203
+ readAll() {
204
+ try {
205
+ const raw = fs2.readFileSync(this.filePath, "utf-8");
206
+ const out = [];
207
+ for (const line of raw.split("\n")) {
208
+ if (!line.trim()) continue;
209
+ try {
210
+ out.push(JSON.parse(line));
211
+ } catch {
212
+ }
213
+ }
214
+ return out;
215
+ } catch {
216
+ return [];
217
+ }
218
+ }
219
+ /** Replace the queue with the given entries (or delete the file when empty). */
220
+ rewrite(entries) {
221
+ try {
222
+ if (entries.length === 0) {
223
+ if (fs2.existsSync(this.filePath)) fs2.unlinkSync(this.filePath);
224
+ return;
225
+ }
226
+ fs2.mkdirSync(path3.dirname(this.filePath), { recursive: true });
227
+ fs2.writeFileSync(this.filePath, entries.map((e) => JSON.stringify(e)).join("\n") + "\n");
228
+ } catch (err) {
229
+ process.stderr.write(`[pinta-gemini] retry-queue rewrite failed: ${err}
230
+ `);
231
+ }
232
+ }
233
+ /**
234
+ * Try to acquire the lock for ~LOCK_TIMEOUT_MS. Returns true on success.
235
+ * Caller MUST call `release()` if true is returned.
236
+ */
237
+ tryAcquireLock() {
238
+ const start = Date.now();
239
+ fs2.mkdirSync(path3.dirname(this.lockPath), { recursive: true });
240
+ while (Date.now() - start < LOCK_TIMEOUT_MS) {
241
+ try {
242
+ const fd = fs2.openSync(this.lockPath, "wx");
243
+ fs2.writeSync(fd, String(process.pid));
244
+ fs2.closeSync(fd);
245
+ return true;
246
+ } catch (err) {
247
+ if (err?.code !== "EEXIST") {
248
+ process.stderr.write(`[pinta-gemini] retry-queue lock open failed: ${err}
249
+ `);
250
+ return false;
251
+ }
252
+ try {
253
+ const st = fs2.statSync(this.lockPath);
254
+ if (Date.now() - st.mtimeMs > 3e4) {
255
+ fs2.unlinkSync(this.lockPath);
256
+ continue;
257
+ }
258
+ } catch {
259
+ }
260
+ const wait = LOCK_POLL_MS;
261
+ const end = Date.now() + wait;
262
+ while (Date.now() < end) {
263
+ }
264
+ }
265
+ }
266
+ return false;
267
+ }
268
+ release() {
269
+ try {
270
+ fs2.unlinkSync(this.lockPath);
271
+ } catch {
272
+ }
273
+ }
274
+ trim() {
275
+ const entries = this.readAll();
276
+ if (entries.length <= MAX_ENTRIES) return;
277
+ const drop = entries.length - MAX_ENTRIES;
278
+ process.stderr.write(`[pinta-gemini] retry-queue full, dropping ${drop} oldest entries
279
+ `);
280
+ this.rewrite(entries.slice(drop));
281
+ }
282
+ };
283
+
284
+ // src/core/otlp.ts
285
+ import crypto from "crypto";
286
+ import os3 from "os";
287
+
288
+ // src/core/redact.ts
289
+ var MAX_BYTES = 102400;
290
+ function truncate(input) {
291
+ const buf = Buffer.from(input, "utf-8");
292
+ if (buf.length <= MAX_BYTES) return input;
293
+ const head = buf.subarray(0, MAX_BYTES).toString("utf-8");
294
+ return `${head}\u2026[TRUNCATED:${buf.length}]`;
295
+ }
296
+ var PATTERNS = [
297
+ { type: "aws_access_key", regex: /AKIA[0-9A-Z]{16}/g },
298
+ {
299
+ type: "aws_secret_key",
300
+ // Context word `aws_secret`/`AWS_SECRET` (with optional separator) followed
301
+ // by an assignment-ish character then a 40-char base64-ish blob.
302
+ regex: /(?:aws[_-]?secret(?:[_-]?(?:access)?[_-]?key)?)\s*[:=]\s*["']?([A-Za-z0-9/+=]{40})(?![A-Za-z0-9/+=])/gi,
303
+ captureGroup: 1
304
+ },
305
+ {
306
+ type: "gcp_service_account",
307
+ // Whole JSON blob starting with the service-account discriminator.
308
+ regex: /\{[\s\S]{0,200}?"type"\s*:\s*"service_account"[\s\S]*?\}/g
309
+ },
310
+ { type: "github_token", regex: /gh[pousr]_[A-Za-z0-9]{36,}/g },
311
+ { type: "gitlab_token", regex: /glpat-[A-Za-z0-9_-]{20}/g },
312
+ { type: "slack_token", regex: /xox[abrsp]-[0-9A-Za-z-]{10,}/g },
313
+ { type: "openai_key", regex: /sk-(?:proj-)?[A-Za-z0-9_-]{40,}/g },
314
+ { type: "anthropic_key", regex: /sk-ant-[A-Za-z0-9_-]{50,}/g },
315
+ { type: "stripe_key", regex: /(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{20,}/g },
316
+ {
317
+ type: "jwt",
318
+ regex: /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g
319
+ },
320
+ {
321
+ type: "private_key_block",
322
+ regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g
323
+ },
324
+ { type: "bearer_token", regex: /bearer\s+([A-Za-z0-9._~+/=-]{12,})/gi, captureGroup: 1 },
325
+ { type: "basic_auth", regex: /basic\s+([A-Za-z0-9+/=]{12,})/gi, captureGroup: 1 },
326
+ {
327
+ type: "db_url_password",
328
+ regex: /\b(?:postgres|postgresql|mysql|mariadb|mongodb(?:\+srv)?|redis):\/\/[^:\s/]+:([^@\s]+)@/gi,
329
+ captureGroup: 1
330
+ },
331
+ {
332
+ type: "cli_password_flag",
333
+ regex: /(?:--password|--pass|--pwd)[=\s]([^\s'"]+)/g,
334
+ captureGroup: 1
335
+ },
336
+ {
337
+ type: "cli_password_short",
338
+ // mysql -p<pass>; only on bash context.
339
+ regex: /\s-p([^\s'"]+)/g,
340
+ captureGroup: 1,
341
+ requireContext: "bash"
342
+ },
343
+ {
344
+ type: "env_var_secret",
345
+ // Known false positive: trailing `[A-Z0-9_]*` is greedy, so names like
346
+ // `OPENAI_API_KEY_DESCRIPTION=Used` still match. Acceptable for Bronze.
347
+ regex: /^(?:export\s+)?([A-Z][A-Z0-9_]*(?:KEY|SECRET|TOKEN|PASSWORD|PASSWD|PWD|API_KEY)[A-Z0-9_]*)\s*=\s*["']?([^\s"'\n]+)/gm,
348
+ captureGroup: 2
349
+ }
350
+ ];
351
+ function collectMatches(input, opts) {
352
+ const out = [];
353
+ for (const pattern of PATTERNS) {
354
+ if (pattern.requireContext && pattern.requireContext !== opts.context) continue;
355
+ const re = new RegExp(pattern.regex.source, pattern.regex.flags);
356
+ let m;
357
+ while ((m = re.exec(input)) !== null) {
358
+ const cg = pattern.captureGroup ?? 0;
359
+ const captured = m[cg];
360
+ if (captured === void 0) {
361
+ if (m.index === re.lastIndex) re.lastIndex++;
362
+ continue;
363
+ }
364
+ const start = m.index;
365
+ const end = m.index + m[0].length;
366
+ const replaceStart = start + m[0].indexOf(captured);
367
+ const replaceEnd = replaceStart + captured.length;
368
+ out.push({ start, end, replaceStart, replaceEnd, type: pattern.type });
369
+ if (m.index === re.lastIndex) re.lastIndex++;
370
+ }
371
+ }
372
+ return out;
373
+ }
374
+ function resolveOverlaps(matches) {
375
+ const sorted = [...matches].sort((a, b) => {
376
+ if (a.start !== b.start) return a.start - b.start;
377
+ return b.end - b.start - (a.end - a.start);
378
+ });
379
+ const kept = [];
380
+ let lastEnd = -1;
381
+ for (const m of sorted) {
382
+ if (m.start < lastEnd) continue;
383
+ kept.push(m);
384
+ lastEnd = m.end;
385
+ }
386
+ return kept;
387
+ }
388
+ function applyMatches(input, matches) {
389
+ const sorted = [...matches].sort((a, b) => b.replaceStart - a.replaceStart);
390
+ let out = input;
391
+ for (const m of sorted) {
392
+ out = out.slice(0, m.replaceStart) + `[REDACTED:${m.type}]` + out.slice(m.replaceEnd);
393
+ }
394
+ return out;
395
+ }
396
+ function redact(input, opts = {}) {
397
+ if (input.length === 0) return input;
398
+ const all = collectMatches(input, opts);
399
+ if (all.length === 0) return input;
400
+ const kept = resolveOverlaps(all);
401
+ return applyMatches(input, kept);
402
+ }
403
+
404
+ // src/core/otlp.ts
405
+ var PLUGIN_VERSION = "0.1.0";
406
+ var CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
407
+ function ulidToTraceId(ulid) {
408
+ if (ulid.length !== 26) throw new Error(`ulidToTraceId: expected 26 chars, got ${ulid.length}`);
409
+ let n = 0n;
410
+ for (const ch of ulid) {
411
+ const idx = CROCKFORD.indexOf(ch);
412
+ if (idx < 0) throw new Error(`ulidToTraceId: invalid Crockford char "${ch}"`);
413
+ n = n << 5n | BigInt(idx);
414
+ }
415
+ n &= (1n << 128n) - 1n;
416
+ return n.toString(16).padStart(32, "0");
417
+ }
418
+ function newSpanId() {
419
+ return crypto.randomBytes(8).toString("hex");
420
+ }
421
+ function snakeCase(s) {
422
+ return s.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/([A-Z])([A-Z][a-z])/g, "$1_$2").toLowerCase();
423
+ }
424
+ function maybeRedactString(prefix, key, raw) {
425
+ const truncated = truncate(raw);
426
+ const skip = /* @__PURE__ */ new Set([`${prefix}.hook`, `${prefix}.agent`, `${prefix}.tool_name`, `${prefix}.session_id`, `${prefix}.transcript_path`, `${prefix}.transcriptPath`, `${prefix}.cwd`]);
427
+ if (skip.has(key)) return truncated;
428
+ const context = key === `${prefix}.tool_input` || key === `${prefix}.tool_response` || key === `${prefix}.toolCall` ? "bash" : void 0;
429
+ return redact(truncated, { context });
430
+ }
431
+ function toOtlpValue(prefix, key, v) {
432
+ if (v === null || v === void 0) return null;
433
+ switch (typeof v) {
434
+ case "string":
435
+ return { stringValue: maybeRedactString(prefix, key, v) };
436
+ case "boolean":
437
+ return { boolValue: v };
438
+ case "number":
439
+ return Number.isInteger(v) ? { intValue: v } : { doubleValue: v };
440
+ case "object":
441
+ try {
442
+ return { stringValue: maybeRedactString(prefix, key, JSON.stringify(v)) };
443
+ } catch {
444
+ return { stringValue: maybeRedactString(prefix, key, String(v)) };
445
+ }
446
+ default:
447
+ return { stringValue: maybeRedactString(prefix, key, String(v)) };
448
+ }
449
+ }
450
+ function resourceAttrs(serviceName) {
451
+ return [
452
+ { key: "service.name", value: { stringValue: serviceName } },
453
+ { key: "telemetry.sdk.name", value: { stringValue: "pinta-gemini" } },
454
+ { key: "telemetry.sdk.language", value: { stringValue: "nodejs" } },
455
+ { key: "telemetry.sdk.version", value: { stringValue: PLUGIN_VERSION } },
456
+ { key: "process.pid", value: { intValue: process.pid } },
457
+ { key: "process.owner", value: { stringValue: os3.userInfo().username } },
458
+ { key: "host.name", value: { stringValue: os3.hostname() } },
459
+ { key: "host.arch", value: { stringValue: os3.arch() } }
460
+ ];
461
+ }
462
+ function buildOtlpPayload(args) {
463
+ const id = identity(args.agent);
464
+ const ts = args.now ?? Date.now();
465
+ const tsNano = (BigInt(ts) * 1000000n).toString();
466
+ const attrs = [
467
+ { key: "ingest.type", value: { stringValue: id.ingest } },
468
+ { key: `${id.prefix}.hook`, value: { stringValue: args.canonical.hook } },
469
+ { key: `${id.prefix}.agent`, value: { stringValue: args.agent } }
470
+ ];
471
+ if (args.product) attrs.push({ key: `${id.prefix}.product`, value: { stringValue: args.product } });
472
+ for (const [k, v] of Object.entries(args.event)) {
473
+ const key = `${id.prefix}.${k}`;
474
+ const value = toOtlpValue(id.prefix, key, v);
475
+ if (value !== null) attrs.push({ key, value });
476
+ }
477
+ const have = new Set(attrs.map((a) => a.key));
478
+ for (const [field, val] of [
479
+ ["session_id", args.canonical.session_id],
480
+ ["cwd", args.canonical.cwd],
481
+ ["tool_name", args.canonical.tool_name]
482
+ ]) {
483
+ const key = `${id.prefix}.${field}`;
484
+ if (val != null && !have.has(key)) attrs.push({ key, value: { stringValue: maybeRedactString(id.prefix, key, String(val)) } });
485
+ }
486
+ if (args.guard) {
487
+ attrs.push(
488
+ { key: "pinta.guard.decision", value: { stringValue: args.guard.decision.toLowerCase() } },
489
+ { key: "pinta.guard.duration_ms", value: { intValue: args.guard.durationMs } }
490
+ );
491
+ if (args.guard.reason) attrs.push({ key: "pinta.guard.matched_rule", value: { stringValue: args.guard.reason } });
492
+ if (args.guard.failOpenReason) attrs.push({ key: "pinta.guard.fail_open_reason", value: { stringValue: args.guard.failOpenReason } });
493
+ }
494
+ const span = {
495
+ traceId: ulidToTraceId(args.traceId),
496
+ spanId: newSpanId(),
497
+ name: `${id.ingest}.${snakeCase(args.canonical.hook)}`,
498
+ kind: 1,
499
+ startTimeUnixNano: tsNano,
500
+ endTimeUnixNano: tsNano,
501
+ attributes: attrs
502
+ };
503
+ return {
504
+ resourceSpans: [{ resource: { attributes: resourceAttrs(id.service) }, scopeSpans: [{ scope: { name: "pinta-gemini", version: PLUGIN_VERSION }, spans: [span] }] }]
505
+ };
506
+ }
507
+ function mergeBatch(payloads) {
508
+ const out = [];
509
+ for (const p of payloads) out.push(...p.resourceSpans);
510
+ return { resourceSpans: out };
511
+ }
512
+
513
+ // src/core/transport.ts
514
+ var TIMEOUT_MS2 = 5e3;
515
+ var Transport = class {
516
+ constructor(config) {
517
+ this.config = config;
518
+ this.queue = new RetryQueue(config.pluginData);
519
+ }
520
+ queue;
521
+ async send(payload) {
522
+ if (!this.config.endpoint) return;
523
+ const ok = await this.post(payload);
524
+ if (!ok) this.queue.enqueue(payload);
525
+ }
526
+ async flush() {
527
+ if (!this.config.endpoint) return;
528
+ if (!this.queue.tryAcquireLock()) return;
529
+ try {
530
+ const entries = this.queue.readAll();
531
+ if (entries.length === 0) return;
532
+ const ok = await this.post(mergeBatch(entries.map((e) => e.payload)));
533
+ if (ok) this.queue.rewrite([]);
534
+ } finally {
535
+ this.queue.release();
536
+ }
537
+ }
538
+ async post(payload) {
539
+ const endpoint = this.config.endpoint;
540
+ const ctrl = new AbortController();
541
+ const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS2);
542
+ try {
543
+ const res = await fetch(endpoint, {
544
+ method: "POST",
545
+ headers: { "Content-Type": "application/json", ...this.config.headers },
546
+ body: JSON.stringify(payload),
547
+ signal: ctrl.signal
548
+ });
549
+ if (!res.ok) {
550
+ process.stderr.write(`[pinta-gemini] OTLP POST ${res.status} ${endpoint}
551
+ `);
552
+ return false;
553
+ }
554
+ return true;
555
+ } catch (err) {
556
+ process.stderr.write(`[pinta-gemini] OTLP POST failed: ${err.message ?? String(err)}
557
+ `);
558
+ return false;
559
+ } finally {
560
+ clearTimeout(timer);
561
+ }
562
+ }
563
+ };
564
+
565
+ // src/core/trace.ts
566
+ import fs3 from "fs";
567
+ import path4 from "path";
568
+ import crypto2 from "crypto";
569
+ var CROCKFORD2 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
570
+ function generateUlid() {
571
+ const now = Date.now();
572
+ let ts = "";
573
+ let t = now;
574
+ for (let i = 0; i < 10; i++) {
575
+ ts = CROCKFORD2[t & 31] + ts;
576
+ t = Math.floor(t / 32);
577
+ }
578
+ const rand = crypto2.randomBytes(10);
579
+ let r = "";
580
+ for (let i = 0; i < 10; i++) r += CROCKFORD2[rand[i] & 31];
581
+ while (r.length < 16) r += CROCKFORD2[0];
582
+ return ts + r;
583
+ }
584
+ var TraceManager = class {
585
+ tracePath;
586
+ constructor(config) {
587
+ this.tracePath = config.tracePath;
588
+ }
589
+ read() {
590
+ try {
591
+ const data = JSON.parse(fs3.readFileSync(this.tracePath, "utf-8"));
592
+ return data && typeof data === "object" ? data : {};
593
+ } catch {
594
+ return {};
595
+ }
596
+ }
597
+ write(map) {
598
+ try {
599
+ fs3.mkdirSync(path4.dirname(this.tracePath), { recursive: true });
600
+ fs3.writeFileSync(this.tracePath, JSON.stringify(map));
601
+ } catch {
602
+ }
603
+ }
604
+ /** Start a fresh trace for this session (turn boundary). */
605
+ newTrace(sessionId) {
606
+ const map = this.read();
607
+ const traceId = generateUlid();
608
+ map[sessionId] = traceId;
609
+ this.write(map);
610
+ return traceId;
611
+ }
612
+ /** Current trace for this session; creates one if absent. */
613
+ currentTrace(sessionId) {
614
+ const map = this.read();
615
+ if (map[sessionId]) return map[sessionId];
616
+ return this.newTrace(sessionId);
617
+ }
618
+ };
619
+
620
+ // src/core/decision.ts
621
+ function formatDecision(agent, event, guard) {
622
+ if (guard && guard.decision === "DENY") {
623
+ const reason = guard.userMessage ?? guard.reason ?? "guard_deny";
624
+ if (isGemini(agent)) return { decision: "deny", reason, systemMessage: guard.userMessage ?? void 0 };
625
+ return { decision: "deny", reason };
626
+ }
627
+ if (!isGemini(agent) && event === "PreToolUse") return { decision: "allow" };
628
+ return {};
629
+ }
630
+
631
+ // src/core/invocation-log.ts
632
+ import fs4 from "fs";
633
+ import path5 from "path";
634
+ function logInvocation(config, rec) {
635
+ if (process.env.PINTA_GEMINI_DEBUG !== "1") return;
636
+ try {
637
+ fs4.mkdirSync(config.pluginData, { recursive: true });
638
+ fs4.appendFileSync(path5.join(config.pluginData, "invocations.jsonl"), JSON.stringify(rec) + "\n");
639
+ } catch {
640
+ }
641
+ }
642
+
643
+ // src/index.ts
644
+ loadEnvFile();
645
+ async function readStdin() {
646
+ const chunks = [];
647
+ for await (const chunk of process.stdin) chunks.push(chunk);
648
+ return Buffer.concat(chunks).toString("utf-8");
649
+ }
650
+ function isTurnStart(agent, c, ev) {
651
+ if (isGemini(agent)) return c.hook === "BeforeAgent";
652
+ return c.hook === "PreInvocation" && ev["invocationNum"] === 1;
653
+ }
654
+ async function main() {
655
+ const { agent, event } = parseInvocation();
656
+ let out = {};
657
+ let ev = {};
658
+ let c;
659
+ let guard = null;
660
+ const config = loadConfig();
661
+ try {
662
+ ev = JSON.parse(await readStdin() || "{}");
663
+ c = normalize(agent, event, ev);
664
+ if (!isSkippedHook(c.hook)) {
665
+ const transport = new Transport(config);
666
+ await transport.flush();
667
+ const sessionId = c.session_id ?? "unknown";
668
+ const trace = new TraceManager(config);
669
+ const traceId = isTurnStart(agent, c, ev) ? trace.newTrace(sessionId) : trace.currentTrace(sessionId);
670
+ if (c.hook === gateEvent(agent)) {
671
+ const rawToolInput = typeof c.tool_input === "string" ? c.tool_input : JSON.stringify(c.tool_input ?? null);
672
+ guard = await evaluateGuard(
673
+ { spanId: sessionId, toolName: c.tool_name, toolInput: c.tool_input, rawTextFields: { toolInput: rawToolInput } },
674
+ config.guardEndpoint
675
+ );
676
+ }
677
+ const product = isGemini(agent) ? void 0 : antigravityProduct(ev);
678
+ await transport.send(buildOtlpPayload({ agent, canonical: c, event: ev, traceId, guard, product }));
679
+ out = formatDecision(agent, event, guard);
680
+ }
681
+ } catch (e) {
682
+ process.stderr.write(`[pinta-gemini] error: ${e}
683
+ `);
684
+ out = {};
685
+ }
686
+ logInvocation(config, {
687
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
688
+ pid: process.pid,
689
+ agent,
690
+ event,
691
+ argv: process.argv.slice(2),
692
+ received_payload: ev,
693
+ normalized: c ?? null,
694
+ guard,
695
+ decision_returned: out
696
+ });
697
+ process.stdout.write(JSON.stringify(out) + "\n");
698
+ process.exit(0);
699
+ }
700
+ main();