@tuent/sentinel 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/LICENSE +201 -0
- package/README.md +96 -0
- package/dist/Sentinel-B_sv8Kiy.d.ts +1785 -0
- package/dist/Sentinel-JLQL3YRD.js +10 -0
- package/dist/auditTrailKeys-GKCW5KUD.js +23 -0
- package/dist/chunk-2FFMYSVC.js +428 -0
- package/dist/chunk-3U3PKD4N.js +539 -0
- package/dist/chunk-6MHWJATS.js +1221 -0
- package/dist/chunk-CUJKNIKT.js +62 -0
- package/dist/chunk-FMZWHT4M.js +20 -0
- package/dist/chunk-NUXSUSYY.js +95 -0
- package/dist/chunk-PDWWRZXF.js +238 -0
- package/dist/chunk-QFRDEISP.js +7429 -0
- package/dist/chunk-Z3PWIJKT.js +2268 -0
- package/dist/cli.js +80 -0
- package/dist/gateway/index.d.ts +241 -0
- package/dist/gateway/index.js +10 -0
- package/dist/gatewayDaemon.js +25 -0
- package/dist/index.d.ts +141 -0
- package/dist/index.js +28 -0
- package/dist/logAdapter-IB6ZDEV2.js +7 -0
- package/dist/mcpAdapter-R47GX2P3.js +178 -0
- package/dist/pidManager-ZYC7SICM.js +15 -0
- package/dist/policyLoader-6KR5VFVV.js +15 -0
- package/dist/webhookReceiver-NAVMQ6N5.js +203 -0
- package/package.json +61 -0
|
@@ -0,0 +1,2268 @@
|
|
|
1
|
+
import {
|
|
2
|
+
discoverPolicy
|
|
3
|
+
} from "./chunk-FMZWHT4M.js";
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_FORBIDDEN_PATTERNS,
|
|
6
|
+
matchGlobInsensitive,
|
|
7
|
+
normalizeForbiddenPattern
|
|
8
|
+
} from "./chunk-6MHWJATS.js";
|
|
9
|
+
import {
|
|
10
|
+
loadPolicy,
|
|
11
|
+
policyToConfig,
|
|
12
|
+
policyToRole
|
|
13
|
+
} from "./chunk-2FFMYSVC.js";
|
|
14
|
+
|
|
15
|
+
// src/gateway/workspaceRouter.ts
|
|
16
|
+
import { resolve, dirname } from "path";
|
|
17
|
+
|
|
18
|
+
// src/workspaceIdentity.ts
|
|
19
|
+
var AGENT_PREFIX = "claude-code";
|
|
20
|
+
function fnv1a32Hex(s) {
|
|
21
|
+
let h = 2166136261;
|
|
22
|
+
for (let i = 0; i < s.length; i++) {
|
|
23
|
+
h ^= s.charCodeAt(i);
|
|
24
|
+
h = Math.imul(h, 16777619);
|
|
25
|
+
}
|
|
26
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
27
|
+
}
|
|
28
|
+
function lastSegment(path) {
|
|
29
|
+
const parts = path.split("/").filter(Boolean);
|
|
30
|
+
return parts.length > 0 ? parts[parts.length - 1] : "";
|
|
31
|
+
}
|
|
32
|
+
function slugify(s) {
|
|
33
|
+
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
|
34
|
+
}
|
|
35
|
+
function normalizeRoot(root) {
|
|
36
|
+
if (root === "" || root === "/") return root;
|
|
37
|
+
return root.replace(/\/+$/, "") || "/";
|
|
38
|
+
}
|
|
39
|
+
function deriveAgentId(workspaceRoot) {
|
|
40
|
+
const root = normalizeRoot(workspaceRoot);
|
|
41
|
+
const slug = slugify(lastSegment(root)) || "root";
|
|
42
|
+
const hash = fnv1a32Hex(root);
|
|
43
|
+
return `${AGENT_PREFIX}@${slug}-${hash}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// src/mergeRoles.ts
|
|
47
|
+
function isWithinActiveHours(hour, range) {
|
|
48
|
+
const [startHour, endHour] = range;
|
|
49
|
+
const outsideHours = startHour <= endHour ? hour < startHour || hour > endHour : hour < startHour && hour > endHour;
|
|
50
|
+
return !outsideHours;
|
|
51
|
+
}
|
|
52
|
+
function toContiguousArc(activeHours) {
|
|
53
|
+
if (activeHours.size === 0) return null;
|
|
54
|
+
if (activeHours.size === 24) return [0, 23];
|
|
55
|
+
const starts = [];
|
|
56
|
+
for (let h = 0; h < 24; h++) {
|
|
57
|
+
const prev = (h + 23) % 24;
|
|
58
|
+
if (activeHours.has(h) && !activeHours.has(prev)) starts.push(h);
|
|
59
|
+
}
|
|
60
|
+
if (starts.length !== 1) return null;
|
|
61
|
+
const start = starts[0];
|
|
62
|
+
let end = start;
|
|
63
|
+
while (activeHours.has((end + 1) % 24)) end = (end + 1) % 24;
|
|
64
|
+
return [start, end];
|
|
65
|
+
}
|
|
66
|
+
function mergeActiveHours(ceilingHours, workspaceHours, warnings) {
|
|
67
|
+
if (workspaceHours === void 0) return ceilingHours;
|
|
68
|
+
if (ceilingHours === void 0) return workspaceHours;
|
|
69
|
+
const active = /* @__PURE__ */ new Set();
|
|
70
|
+
let workspaceWidens = false;
|
|
71
|
+
for (let h = 0; h < 24; h++) {
|
|
72
|
+
const inCeiling = isWithinActiveHours(h, ceilingHours);
|
|
73
|
+
const inWorkspace = isWithinActiveHours(h, workspaceHours);
|
|
74
|
+
if (inCeiling && inWorkspace) active.add(h);
|
|
75
|
+
if (inWorkspace && !inCeiling) workspaceWidens = true;
|
|
76
|
+
}
|
|
77
|
+
if (workspaceWidens) {
|
|
78
|
+
warnings.push(
|
|
79
|
+
`workspace activeHours [${workspaceHours.join(",")}] includes hours outside operator ceiling [${ceilingHours.join(
|
|
80
|
+
","
|
|
81
|
+
)}] \u2014 clamped to the overlap.`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
if (active.size === 0) {
|
|
85
|
+
warnings.push(
|
|
86
|
+
`workspace activeHours [${workspaceHours.join(",")}] is disjoint from operator ceiling [${ceilingHours.join(
|
|
87
|
+
","
|
|
88
|
+
)}] \u2014 no overlapping active hours; using ceiling.`
|
|
89
|
+
);
|
|
90
|
+
return ceilingHours;
|
|
91
|
+
}
|
|
92
|
+
const arc = toContiguousArc(active);
|
|
93
|
+
if (arc === null) {
|
|
94
|
+
warnings.push(
|
|
95
|
+
`workspace activeHours [${workspaceHours.join(",")}] \u2229 ceiling [${ceilingHours.join(
|
|
96
|
+
","
|
|
97
|
+
)}] is non-contiguous ({${[...active].sort((a, b) => a - b).join(",")}}) and cannot be a single range; using ceiling.`
|
|
98
|
+
);
|
|
99
|
+
return ceilingHours;
|
|
100
|
+
}
|
|
101
|
+
return arc;
|
|
102
|
+
}
|
|
103
|
+
function mergeLimit(dim, ceilingVal, workspaceVal, warnings) {
|
|
104
|
+
if (workspaceVal === void 0) return ceilingVal;
|
|
105
|
+
if (ceilingVal === void 0) return workspaceVal;
|
|
106
|
+
if (workspaceVal > ceilingVal) {
|
|
107
|
+
warnings.push(
|
|
108
|
+
`workspace ${dim} (${workspaceVal}) is looser than operator ceiling (${ceilingVal}) \u2014 clamped to ceiling.`
|
|
109
|
+
);
|
|
110
|
+
return ceilingVal;
|
|
111
|
+
}
|
|
112
|
+
return workspaceVal;
|
|
113
|
+
}
|
|
114
|
+
function intersectAllowlist(dim, ceilingArr, workspaceArr, warnings, caseInsensitive) {
|
|
115
|
+
const norm = (s) => caseInsensitive ? s.toLowerCase() : s;
|
|
116
|
+
const ceilingSet = new Set(ceilingArr.map(norm));
|
|
117
|
+
const widened = workspaceArr.filter((x) => !ceilingSet.has(norm(x)));
|
|
118
|
+
if (widened.length > 0) {
|
|
119
|
+
warnings.push(
|
|
120
|
+
`workspace ${dim} lists ${JSON.stringify(widened)} not in operator ceiling \u2014 excluded (cannot widen past ceiling).`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
const result = workspaceArr.filter((x) => ceilingSet.has(norm(x)));
|
|
124
|
+
if (result.length === 0) {
|
|
125
|
+
warnings.push(
|
|
126
|
+
`workspace narrowed ${dim} to empty \u2014 this workspace can perform nothing on that dimension; was this intended?`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
function mergeRoles(ceiling, workspace) {
|
|
132
|
+
const warnings = [];
|
|
133
|
+
if (workspace === null || workspace === void 0) {
|
|
134
|
+
return { role: cloneRole(ceiling), warnings };
|
|
135
|
+
}
|
|
136
|
+
if (typeof workspace !== "object" || Array.isArray(workspace)) {
|
|
137
|
+
warnings.push("workspace policy is not an object \u2014 using operator ceiling.");
|
|
138
|
+
return { role: cloneRole(ceiling), warnings };
|
|
139
|
+
}
|
|
140
|
+
const allowedActions = workspace.allowedActions === void 0 ? [...ceiling.allowedActions] : intersectAllowlist(
|
|
141
|
+
"allowedActions",
|
|
142
|
+
ceiling.allowedActions,
|
|
143
|
+
workspace.allowedActions,
|
|
144
|
+
warnings,
|
|
145
|
+
false
|
|
146
|
+
);
|
|
147
|
+
let forbiddenTargetPatterns;
|
|
148
|
+
if (workspace.forbiddenTargetPatterns === void 0) {
|
|
149
|
+
forbiddenTargetPatterns = [...ceiling.forbiddenTargetPatterns];
|
|
150
|
+
} else {
|
|
151
|
+
const wsNormalized = workspace.forbiddenTargetPatterns.map(normalizeForbiddenPattern);
|
|
152
|
+
forbiddenTargetPatterns = [.../* @__PURE__ */ new Set([...ceiling.forbiddenTargetPatterns, ...wsNormalized])];
|
|
153
|
+
}
|
|
154
|
+
const allowedTargetPatterns = [...ceiling.allowedTargetPatterns];
|
|
155
|
+
if (workspace.allowedTargetPatterns !== void 0) {
|
|
156
|
+
warnings.push(
|
|
157
|
+
"workspace allowedTargetPatterns narrowing is not supported in v1 \u2014 ignored (narrow via forbiddenTargetPatterns instead); effective allowed = operator ceiling."
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
let networkHosts;
|
|
161
|
+
if (workspace.networkHosts === void 0) {
|
|
162
|
+
networkHosts = ceiling.networkHosts ? [...ceiling.networkHosts] : void 0;
|
|
163
|
+
} else if (ceiling.networkHosts === void 0) {
|
|
164
|
+
networkHosts = void 0;
|
|
165
|
+
warnings.push(
|
|
166
|
+
"workspace defines networkHosts but operator ceiling has none \u2014 a workspace cannot create a host allowlist; ignored."
|
|
167
|
+
);
|
|
168
|
+
} else {
|
|
169
|
+
networkHosts = intersectAllowlist(
|
|
170
|
+
"networkHosts",
|
|
171
|
+
ceiling.networkHosts,
|
|
172
|
+
workspace.networkHosts,
|
|
173
|
+
warnings,
|
|
174
|
+
true
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
let expectedSchedule = ceiling.expectedSchedule ? { ...ceiling.expectedSchedule } : void 0;
|
|
178
|
+
if (workspace.expectedSchedule !== void 0) {
|
|
179
|
+
const cSched = ceiling.expectedSchedule;
|
|
180
|
+
const wSched = workspace.expectedSchedule;
|
|
181
|
+
let activeDays;
|
|
182
|
+
if (wSched.activeDays === void 0) {
|
|
183
|
+
activeDays = cSched?.activeDays ? [...cSched.activeDays] : void 0;
|
|
184
|
+
} else if (cSched?.activeDays === void 0) {
|
|
185
|
+
activeDays = [...wSched.activeDays];
|
|
186
|
+
} else {
|
|
187
|
+
activeDays = intersectAllowlist(
|
|
188
|
+
"activeDays",
|
|
189
|
+
cSched.activeDays,
|
|
190
|
+
wSched.activeDays,
|
|
191
|
+
warnings,
|
|
192
|
+
true
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
const activeHours = mergeActiveHours(cSched?.activeHours, wSched.activeHours, warnings);
|
|
196
|
+
expectedSchedule = {};
|
|
197
|
+
if (activeDays !== void 0) expectedSchedule.activeDays = activeDays;
|
|
198
|
+
if (activeHours !== void 0) expectedSchedule.activeHours = activeHours;
|
|
199
|
+
if (activeDays === void 0 && activeHours === void 0) expectedSchedule = void 0;
|
|
200
|
+
}
|
|
201
|
+
const maxEventsPerHour = mergeLimit(
|
|
202
|
+
"maxEventsPerHour",
|
|
203
|
+
ceiling.maxEventsPerHour,
|
|
204
|
+
workspace.maxEventsPerHour,
|
|
205
|
+
warnings
|
|
206
|
+
);
|
|
207
|
+
const maxSessionDuration = mergeLimit(
|
|
208
|
+
"maxSessionDuration",
|
|
209
|
+
ceiling.maxSessionDuration,
|
|
210
|
+
workspace.maxSessionDuration,
|
|
211
|
+
warnings
|
|
212
|
+
);
|
|
213
|
+
if (workspace.exceptions !== void 0 && workspace.exceptions.length > 0) {
|
|
214
|
+
warnings.push(
|
|
215
|
+
`workspace defines ${workspace.exceptions.length} exception(s) \u2014 dropped (exceptions can only widen; only operator exceptions apply).`
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
const exceptions = ceiling.exceptions ? [...ceiling.exceptions] : void 0;
|
|
219
|
+
const role = {
|
|
220
|
+
// identity fields preserved from ceiling; B5 overrides agentId with the per-workspace id.
|
|
221
|
+
agentId: ceiling.agentId,
|
|
222
|
+
name: ceiling.name,
|
|
223
|
+
description: ceiling.description,
|
|
224
|
+
allowedActions,
|
|
225
|
+
allowedTargetPatterns,
|
|
226
|
+
forbiddenTargetPatterns,
|
|
227
|
+
...expectedSchedule !== void 0 && { expectedSchedule },
|
|
228
|
+
...maxEventsPerHour !== void 0 && { maxEventsPerHour },
|
|
229
|
+
...maxSessionDuration !== void 0 && { maxSessionDuration },
|
|
230
|
+
...exceptions !== void 0 && { exceptions },
|
|
231
|
+
...networkHosts !== void 0 && { networkHosts }
|
|
232
|
+
};
|
|
233
|
+
return { role, warnings };
|
|
234
|
+
}
|
|
235
|
+
function cloneRole(role) {
|
|
236
|
+
return {
|
|
237
|
+
agentId: role.agentId,
|
|
238
|
+
name: role.name,
|
|
239
|
+
description: role.description,
|
|
240
|
+
allowedActions: [...role.allowedActions],
|
|
241
|
+
allowedTargetPatterns: [...role.allowedTargetPatterns],
|
|
242
|
+
forbiddenTargetPatterns: [...role.forbiddenTargetPatterns],
|
|
243
|
+
...role.expectedSchedule !== void 0 && {
|
|
244
|
+
expectedSchedule: {
|
|
245
|
+
...role.expectedSchedule.activeDays !== void 0 && {
|
|
246
|
+
activeDays: [...role.expectedSchedule.activeDays]
|
|
247
|
+
},
|
|
248
|
+
...role.expectedSchedule.activeHours !== void 0 && {
|
|
249
|
+
activeHours: [...role.expectedSchedule.activeHours]
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
...role.maxEventsPerHour !== void 0 && { maxEventsPerHour: role.maxEventsPerHour },
|
|
254
|
+
...role.maxSessionDuration !== void 0 && { maxSessionDuration: role.maxSessionDuration },
|
|
255
|
+
...role.exceptions !== void 0 && { exceptions: [...role.exceptions] },
|
|
256
|
+
...role.networkHosts !== void 0 && { networkHosts: [...role.networkHosts] }
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// src/gateway/workspaceRouter.ts
|
|
261
|
+
async function resolveWorkspace(cwd, home) {
|
|
262
|
+
if (!cwd || cwd.trim() === "") {
|
|
263
|
+
return {
|
|
264
|
+
ok: false,
|
|
265
|
+
reason: "request carries no cwd; the originating workspace cannot be resolved"
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
const start = resolve(cwd);
|
|
269
|
+
const policyPath = discoverPolicy(start, home);
|
|
270
|
+
let root = start;
|
|
271
|
+
let workspaceRole = null;
|
|
272
|
+
if (policyPath) {
|
|
273
|
+
const policy = await loadPolicy(policyPath);
|
|
274
|
+
const repoRoot = policyToConfig(policy).repo?.root;
|
|
275
|
+
root = repoRoot ? resolve(repoRoot) : dirname(resolve(policyPath));
|
|
276
|
+
workspaceRole = policyToRole(policy);
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
ok: true,
|
|
280
|
+
resolution: { root, agentId: deriveAgentId(root), policyPath, workspaceRole }
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
function effectiveRole(ceiling, workspaceRole) {
|
|
284
|
+
if (!workspaceRole) return { role: ceiling, warnings: [] };
|
|
285
|
+
return mergeRoles(ceiling, workspaceRole);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// src/gateway/telemetry.ts
|
|
289
|
+
var Telemetry = class {
|
|
290
|
+
startedAt = Date.now();
|
|
291
|
+
total = 0;
|
|
292
|
+
allowed = 0;
|
|
293
|
+
blocked = 0;
|
|
294
|
+
byAction = {};
|
|
295
|
+
byPhase = {};
|
|
296
|
+
latencies = [];
|
|
297
|
+
recordToolCall(action, phase, decision, durationMs) {
|
|
298
|
+
this.total++;
|
|
299
|
+
if (decision === "allowed") this.allowed++;
|
|
300
|
+
else this.blocked++;
|
|
301
|
+
this.byAction[action] = (this.byAction[action] ?? 0) + 1;
|
|
302
|
+
this.byPhase[phase] = (this.byPhase[phase] ?? 0) + 1;
|
|
303
|
+
const idx = this.bisect(durationMs);
|
|
304
|
+
this.latencies.splice(idx, 0, durationMs);
|
|
305
|
+
}
|
|
306
|
+
getSnapshot() {
|
|
307
|
+
return {
|
|
308
|
+
uptime_seconds: Math.floor((Date.now() - this.startedAt) / 1e3),
|
|
309
|
+
tool_calls: {
|
|
310
|
+
total: this.total,
|
|
311
|
+
allowed: this.allowed,
|
|
312
|
+
blocked: this.blocked
|
|
313
|
+
},
|
|
314
|
+
latency_ms: {
|
|
315
|
+
p50: this.percentile(50),
|
|
316
|
+
p95: this.percentile(95),
|
|
317
|
+
p99: this.percentile(99)
|
|
318
|
+
},
|
|
319
|
+
by_action: { ...this.byAction },
|
|
320
|
+
by_phase: { ...this.byPhase }
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
percentile(p) {
|
|
324
|
+
if (this.latencies.length === 0) return 0;
|
|
325
|
+
const idx = Math.ceil(p / 100 * this.latencies.length) - 1;
|
|
326
|
+
return this.latencies[Math.max(0, idx)];
|
|
327
|
+
}
|
|
328
|
+
bisect(val) {
|
|
329
|
+
let lo = 0;
|
|
330
|
+
let hi = this.latencies.length;
|
|
331
|
+
while (lo < hi) {
|
|
332
|
+
const mid = lo + hi >>> 1;
|
|
333
|
+
if (this.latencies[mid] < val) lo = mid + 1;
|
|
334
|
+
else hi = mid;
|
|
335
|
+
}
|
|
336
|
+
return lo;
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
// src/gateway/translatorRegistry.ts
|
|
341
|
+
var TranslatorRegistry = class {
|
|
342
|
+
translators = /* @__PURE__ */ new Map();
|
|
343
|
+
insertionOrder = [];
|
|
344
|
+
/**
|
|
345
|
+
* Register a translator for an agent type.
|
|
346
|
+
* Throws if a translator is already registered for the same agentType.
|
|
347
|
+
*/
|
|
348
|
+
register(translator) {
|
|
349
|
+
const { agentType } = translator;
|
|
350
|
+
if (this.translators.has(agentType)) {
|
|
351
|
+
throw new Error(`Translator already registered for agentType '${agentType}'`);
|
|
352
|
+
}
|
|
353
|
+
this.translators.set(agentType, translator);
|
|
354
|
+
this.insertionOrder.push(agentType);
|
|
355
|
+
}
|
|
356
|
+
/** Returns the translator for the given agent type, or null if none is registered. */
|
|
357
|
+
get(agentType) {
|
|
358
|
+
return this.translators.get(agentType) ?? null;
|
|
359
|
+
}
|
|
360
|
+
/** Returns registered agent types in registration order. */
|
|
361
|
+
listRegistered() {
|
|
362
|
+
return [...this.insertionOrder];
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
// src/gateway/bashScanner.ts
|
|
367
|
+
import { parse as shellParse } from "shell-quote";
|
|
368
|
+
import { realpathSync } from "fs";
|
|
369
|
+
import { dirname as dirname2, join, basename, normalize } from "path";
|
|
370
|
+
var BRACE_PATTERN_RE = /\{[^}]*,[^}]*\}/;
|
|
371
|
+
var MAX_BRACE_EXPANSION = 64;
|
|
372
|
+
function fnmatchBasename(pattern, candidate) {
|
|
373
|
+
if (pattern.length !== candidate.length) return false;
|
|
374
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
375
|
+
const p = pattern[i].toLowerCase();
|
|
376
|
+
const c = candidate[i].toLowerCase();
|
|
377
|
+
if (p === "?") continue;
|
|
378
|
+
if (p !== c) return false;
|
|
379
|
+
}
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
function bracketTokenMatchesForbidden(token, forbiddenBasenames) {
|
|
383
|
+
const literals = [];
|
|
384
|
+
let current = "";
|
|
385
|
+
let inBracket = false;
|
|
386
|
+
for (let i = 0; i < token.length; i++) {
|
|
387
|
+
if (token[i] === "[" && !inBracket) {
|
|
388
|
+
if (current) literals.push(current);
|
|
389
|
+
current = "";
|
|
390
|
+
inBracket = true;
|
|
391
|
+
} else if (token[i] === "]" && inBracket) {
|
|
392
|
+
inBracket = false;
|
|
393
|
+
} else if (!inBracket) {
|
|
394
|
+
current += token[i];
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (current) literals.push(current);
|
|
398
|
+
for (const forbidden of forbiddenBasenames) {
|
|
399
|
+
const fl = forbidden.toLowerCase();
|
|
400
|
+
for (const lit of literals) {
|
|
401
|
+
if (lit.length === 0) continue;
|
|
402
|
+
if (fl.includes(lit.toLowerCase())) return forbidden;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
function resolveBraceExpansion(token) {
|
|
408
|
+
const match = token.match(/^(.*?)\{([^}]*,[^}]*)\}(.*)$/);
|
|
409
|
+
if (!match) return null;
|
|
410
|
+
const [, prefix, alternatives, suffix] = match;
|
|
411
|
+
const parts = alternatives.split(",");
|
|
412
|
+
if (parts.length > MAX_BRACE_EXPANSION) return null;
|
|
413
|
+
return parts.map((p) => prefix + p + suffix);
|
|
414
|
+
}
|
|
415
|
+
function wildcardDispatch(token, forbiddenBasenames, metadataField) {
|
|
416
|
+
const result = {
|
|
417
|
+
resolvedBasenames: [],
|
|
418
|
+
unparseable: false,
|
|
419
|
+
metadata: {}
|
|
420
|
+
};
|
|
421
|
+
if (token === "*" || token === "**" || token === "?") {
|
|
422
|
+
return result;
|
|
423
|
+
}
|
|
424
|
+
if (BRACE_PATTERN_RE.test(token)) {
|
|
425
|
+
const expanded = resolveBraceExpansion(token);
|
|
426
|
+
if (expanded === null) {
|
|
427
|
+
result.unparseable = true;
|
|
428
|
+
return result;
|
|
429
|
+
}
|
|
430
|
+
for (const alt of expanded) {
|
|
431
|
+
const hasWildcard = /[?*[]/.test(alt);
|
|
432
|
+
if (hasWildcard) {
|
|
433
|
+
const sub = wildcardDispatch(alt, forbiddenBasenames, metadataField);
|
|
434
|
+
if (sub.resolvedBasenames.length > 0) {
|
|
435
|
+
result.resolvedBasenames.push(...sub.resolvedBasenames);
|
|
436
|
+
result.metadata["resolvedFromBrace"] = token;
|
|
437
|
+
Object.assign(result.metadata, sub.metadata);
|
|
438
|
+
}
|
|
439
|
+
if (sub.unparseable) result.unparseable = true;
|
|
440
|
+
} else {
|
|
441
|
+
const altLower = alt.toLowerCase();
|
|
442
|
+
for (const forbidden of forbiddenBasenames) {
|
|
443
|
+
if (altLower === forbidden.toLowerCase()) {
|
|
444
|
+
result.resolvedBasenames.push(forbidden);
|
|
445
|
+
result.metadata["resolvedFromBrace"] = token;
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return result;
|
|
452
|
+
}
|
|
453
|
+
const hasStar = token.includes("*");
|
|
454
|
+
const hasQuestion = token.includes("?");
|
|
455
|
+
const hasBracket = token.includes("[");
|
|
456
|
+
if (hasBracket) {
|
|
457
|
+
const matched = bracketTokenMatchesForbidden(token, forbiddenBasenames);
|
|
458
|
+
if (matched) {
|
|
459
|
+
result.resolvedBasenames.push(matched);
|
|
460
|
+
result.metadata["resolvedFromBracket"] = token;
|
|
461
|
+
} else {
|
|
462
|
+
result.unparseable = true;
|
|
463
|
+
}
|
|
464
|
+
return result;
|
|
465
|
+
}
|
|
466
|
+
if (hasStar && !hasQuestion) {
|
|
467
|
+
const matched = starLiteralSubstringCheck(token, forbiddenBasenames);
|
|
468
|
+
if (matched) {
|
|
469
|
+
result.resolvedBasenames.push(matched);
|
|
470
|
+
result.metadata[metadataField] = token;
|
|
471
|
+
}
|
|
472
|
+
return result;
|
|
473
|
+
}
|
|
474
|
+
if (hasQuestion && !hasStar) {
|
|
475
|
+
for (const forbidden of forbiddenBasenames) {
|
|
476
|
+
if (fnmatchBasename(token, forbidden)) {
|
|
477
|
+
result.resolvedBasenames.push(forbidden);
|
|
478
|
+
result.metadata[metadataField] = token;
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return result;
|
|
483
|
+
}
|
|
484
|
+
if (hasStar && hasQuestion) {
|
|
485
|
+
const starMatch = starLiteralSubstringCheck(token, forbiddenBasenames);
|
|
486
|
+
if (starMatch) {
|
|
487
|
+
result.resolvedBasenames.push(starMatch);
|
|
488
|
+
result.metadata[metadataField] = token;
|
|
489
|
+
return result;
|
|
490
|
+
}
|
|
491
|
+
const segments = token.split("*").filter((s) => s.includes("?"));
|
|
492
|
+
for (const seg of segments) {
|
|
493
|
+
for (const forbidden of forbiddenBasenames) {
|
|
494
|
+
if (fnmatchBasename(seg, forbidden)) {
|
|
495
|
+
result.resolvedBasenames.push(forbidden);
|
|
496
|
+
result.metadata[metadataField] = token;
|
|
497
|
+
return result;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return result;
|
|
502
|
+
}
|
|
503
|
+
return result;
|
|
504
|
+
}
|
|
505
|
+
function starLiteralSubstringCheck(token, forbiddenBasenames) {
|
|
506
|
+
const literals = token.split("*").filter((s) => s.length > 0);
|
|
507
|
+
for (const forbidden of forbiddenBasenames) {
|
|
508
|
+
const fl = forbidden.toLowerCase();
|
|
509
|
+
for (const lit of literals) {
|
|
510
|
+
if (fl.includes(lit.toLowerCase())) return forbidden;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
function shouldDispatchWildcard(token) {
|
|
516
|
+
const hasMetachar = /[?*[{]/.test(token);
|
|
517
|
+
if (!hasMetachar) return false;
|
|
518
|
+
if (isPathShaped(token)) return true;
|
|
519
|
+
if (token.includes("[")) return true;
|
|
520
|
+
if (BRACE_PATTERN_RE.test(token)) return true;
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
var SENSITIVE_BASENAME_RE = /(?:\.env|\.ssh|secrets|credentials|id_rsa|id_dsa|id_ecdsa|id_ed25519|\.pem|\.key)/i;
|
|
524
|
+
var DANGEROUS_COMMAND_TOKENS = /* @__PURE__ */ new Set(["eval"]);
|
|
525
|
+
var COMMAND_SUBSTITUTION_RE = /\$\(|`/;
|
|
526
|
+
var DANGEROUS_RAW_RE = /<<<|<\(|>\(/;
|
|
527
|
+
function isVarMarker(token) {
|
|
528
|
+
return typeof token === "object" && token !== null && "__sentinel_var" in token && typeof token.__sentinel_var === "string";
|
|
529
|
+
}
|
|
530
|
+
function tokenizePaths(command) {
|
|
531
|
+
const result = {
|
|
532
|
+
paths: [],
|
|
533
|
+
unparseable: false,
|
|
534
|
+
hasDangerousConstruct: false
|
|
535
|
+
};
|
|
536
|
+
if (DANGEROUS_RAW_RE.test(command)) {
|
|
537
|
+
result.hasDangerousConstruct = true;
|
|
538
|
+
}
|
|
539
|
+
if (COMMAND_SUBSTITUTION_RE.test(command)) {
|
|
540
|
+
result.hasDangerousConstruct = true;
|
|
541
|
+
}
|
|
542
|
+
let tokens;
|
|
543
|
+
try {
|
|
544
|
+
tokens = shellParse(command, (key) => ({ __sentinel_var: key }));
|
|
545
|
+
} catch {
|
|
546
|
+
result.unparseable = true;
|
|
547
|
+
return result;
|
|
548
|
+
}
|
|
549
|
+
if (!Array.isArray(tokens)) {
|
|
550
|
+
result.unparseable = true;
|
|
551
|
+
return result;
|
|
552
|
+
}
|
|
553
|
+
let prevToken = null;
|
|
554
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
555
|
+
const token = tokens[i];
|
|
556
|
+
if (isVarMarker(token)) {
|
|
557
|
+
const nextToken = tokens[i + 1];
|
|
558
|
+
const nextIsPathRelevant = nextToken === void 0 || // end of tokens — var is complete argument
|
|
559
|
+
typeof nextToken === "object" && nextToken !== null && "op" in nextToken || // followed by operator — var is complete argument
|
|
560
|
+
typeof nextToken === "string" && isPathShaped(nextToken);
|
|
561
|
+
const prevIsPathRelevant = prevToken !== null && isPathShaped(prevToken);
|
|
562
|
+
if (nextIsPathRelevant || prevIsPathRelevant) {
|
|
563
|
+
result.unparseable = true;
|
|
564
|
+
}
|
|
565
|
+
prevToken = null;
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
if (typeof token === "object" && token !== null) {
|
|
569
|
+
if ("pattern" in token) {
|
|
570
|
+
const globPattern = token.pattern;
|
|
571
|
+
const lastSlash = globPattern.lastIndexOf("/");
|
|
572
|
+
const dispatchTarget = lastSlash >= 0 ? globPattern.slice(lastSlash + 1) : globPattern;
|
|
573
|
+
const dispatch = wildcardDispatch(dispatchTarget, FORBIDDEN_BASENAMES, "resolvedFromGlob");
|
|
574
|
+
if (dispatch.resolvedBasenames.length > 0) {
|
|
575
|
+
for (const resolved of dispatch.resolvedBasenames) {
|
|
576
|
+
result.paths.push(resolved);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (dispatch.unparseable) {
|
|
580
|
+
result.unparseable = true;
|
|
581
|
+
}
|
|
582
|
+
if (SENSITIVE_BASENAME_RE.test(globPattern)) {
|
|
583
|
+
result.unparseable = true;
|
|
584
|
+
}
|
|
585
|
+
prevToken = null;
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
if ("op" in token) {
|
|
589
|
+
if (token.op === "<(") {
|
|
590
|
+
result.hasDangerousConstruct = true;
|
|
591
|
+
}
|
|
592
|
+
prevToken = null;
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
prevToken = null;
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
if (typeof token !== "string") {
|
|
599
|
+
prevToken = null;
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
if (DANGEROUS_COMMAND_TOKENS.has(token.toLowerCase())) {
|
|
603
|
+
result.hasDangerousConstruct = true;
|
|
604
|
+
}
|
|
605
|
+
if ((prevToken === "sh" || prevToken === "bash" || prevToken === "/bin/sh" || prevToken === "/bin/bash") && token === "-c") {
|
|
606
|
+
result.hasDangerousConstruct = true;
|
|
607
|
+
}
|
|
608
|
+
if (shouldDispatchWildcard(token)) {
|
|
609
|
+
const metaField = "resolvedFromQuotedGlob";
|
|
610
|
+
const dispatch = wildcardDispatch(token, FORBIDDEN_BASENAMES, metaField);
|
|
611
|
+
if (dispatch.resolvedBasenames.length > 0) {
|
|
612
|
+
for (const resolved of dispatch.resolvedBasenames) {
|
|
613
|
+
result.paths.push(resolved);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
if (dispatch.unparseable) {
|
|
617
|
+
result.unparseable = true;
|
|
618
|
+
}
|
|
619
|
+
} else if (isPathShaped(token)) {
|
|
620
|
+
const resolved = resolvePathToken(token);
|
|
621
|
+
result.paths.push(resolved);
|
|
622
|
+
}
|
|
623
|
+
prevToken = token;
|
|
624
|
+
}
|
|
625
|
+
return result;
|
|
626
|
+
}
|
|
627
|
+
function isPathShaped(token) {
|
|
628
|
+
if (token.includes("/")) return true;
|
|
629
|
+
if (token.startsWith(".")) return true;
|
|
630
|
+
if (SENSITIVE_BASENAME_RE.test(token)) return true;
|
|
631
|
+
return false;
|
|
632
|
+
}
|
|
633
|
+
function resolvePathToken(token) {
|
|
634
|
+
const normalized = normalize(token);
|
|
635
|
+
try {
|
|
636
|
+
return realpathSync(normalized);
|
|
637
|
+
} catch (err) {
|
|
638
|
+
const code = err.code;
|
|
639
|
+
if (code === "ENOENT") {
|
|
640
|
+
return resolveNonexistentPathToken(normalized);
|
|
641
|
+
}
|
|
642
|
+
return normalized;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
function resolveNonexistentPathToken(normalizedPath) {
|
|
646
|
+
let current = normalizedPath;
|
|
647
|
+
let suffix = "";
|
|
648
|
+
for (let i = 0; i < 50; i++) {
|
|
649
|
+
const parent = dirname2(current);
|
|
650
|
+
if (parent === current) {
|
|
651
|
+
return normalizedPath;
|
|
652
|
+
}
|
|
653
|
+
if (parent === ".") {
|
|
654
|
+
return normalizedPath;
|
|
655
|
+
}
|
|
656
|
+
suffix = suffix ? join(basename(current), suffix) : basename(current);
|
|
657
|
+
current = parent;
|
|
658
|
+
try {
|
|
659
|
+
const resolved = realpathSync(current);
|
|
660
|
+
if (resolved !== current) {
|
|
661
|
+
return join(resolved, suffix);
|
|
662
|
+
}
|
|
663
|
+
return join(resolved, suffix);
|
|
664
|
+
} catch {
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return normalizedPath;
|
|
669
|
+
}
|
|
670
|
+
var FORBIDDEN_BASENAMES = [
|
|
671
|
+
".env",
|
|
672
|
+
".ssh",
|
|
673
|
+
".aws",
|
|
674
|
+
"secrets",
|
|
675
|
+
"credentials",
|
|
676
|
+
"id_rsa",
|
|
677
|
+
"id_dsa",
|
|
678
|
+
"id_ecdsa",
|
|
679
|
+
"id_ed25519",
|
|
680
|
+
".pem",
|
|
681
|
+
".key"
|
|
682
|
+
];
|
|
683
|
+
function scanBashCommand(command, forbiddenBasenames) {
|
|
684
|
+
const basenames = forbiddenBasenames ?? FORBIDDEN_BASENAMES;
|
|
685
|
+
const hits = [];
|
|
686
|
+
for (const basename2 of basenames) {
|
|
687
|
+
const pattern = buildPattern(basename2);
|
|
688
|
+
if (pattern.test(command)) {
|
|
689
|
+
hits.push(basename2);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return { matched: hits.length > 0, hits };
|
|
693
|
+
}
|
|
694
|
+
function buildPattern(basename2) {
|
|
695
|
+
const escaped = escapeRegex(basename2);
|
|
696
|
+
if (basename2.startsWith(".") && basename2.length <= 4 && !isAlphaAfterDot(basename2)) {
|
|
697
|
+
return new RegExp(`\\w${escaped}(?=$|[\\s;&|<>()'"=\\/])`, "i");
|
|
698
|
+
}
|
|
699
|
+
if (basename2.startsWith(".")) {
|
|
700
|
+
return new RegExp(`(?:^|[\\s;&|<>()\\/'"=])${escaped}(?=$|[\\s;&|<>()\\/'"=.])`, "i");
|
|
701
|
+
}
|
|
702
|
+
return new RegExp(`\\b${escaped}\\b`, "i");
|
|
703
|
+
}
|
|
704
|
+
function scanContentForForbiddenBasenames(content, forbiddenBasenames) {
|
|
705
|
+
const hits = [];
|
|
706
|
+
for (const basename2 of forbiddenBasenames) {
|
|
707
|
+
const pattern = buildContentPattern(basename2);
|
|
708
|
+
if (pattern.test(content)) {
|
|
709
|
+
hits.push(basename2);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
return { matched: hits.length > 0, hits };
|
|
713
|
+
}
|
|
714
|
+
function buildContentPattern(basename2) {
|
|
715
|
+
const escaped = escapeRegex(basename2);
|
|
716
|
+
if (basename2.startsWith(".") && basename2.length <= 4 && !isAlphaAfterDot(basename2)) {
|
|
717
|
+
return new RegExp(`\\w${escaped}(?=$|[\\s;&|<>()'"=\\/])`, "i");
|
|
718
|
+
}
|
|
719
|
+
if (basename2.startsWith(".")) {
|
|
720
|
+
return new RegExp(`(?:^|[\\s;&|<>()\\/'"=])${escaped}(?=$|[\\s;&|<>()\\/'"=.])`, "i");
|
|
721
|
+
}
|
|
722
|
+
return new RegExp(`(?<=[/\\\\]\\.?)${escaped}\\b`, "i");
|
|
723
|
+
}
|
|
724
|
+
function isAlphaAfterDot(s) {
|
|
725
|
+
return /^\.[a-zA-Z]+$/.test(s);
|
|
726
|
+
}
|
|
727
|
+
function escapeRegex(s) {
|
|
728
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
729
|
+
}
|
|
730
|
+
function scanGlobPattern(pattern, forbiddenBasenames) {
|
|
731
|
+
const basenames = forbiddenBasenames ?? FORBIDDEN_BASENAMES;
|
|
732
|
+
const hits = [];
|
|
733
|
+
for (const basename2 of basenames) {
|
|
734
|
+
const re = buildGlobContextPattern(basename2);
|
|
735
|
+
if (re.test(pattern)) {
|
|
736
|
+
hits.push(basename2);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
return { matched: hits.length > 0, hits };
|
|
740
|
+
}
|
|
741
|
+
function buildGlobContextPattern(basename2) {
|
|
742
|
+
const escaped = escapeRegex(basename2);
|
|
743
|
+
const GLOB_DELIM = String.raw`\s;&|<>()\\/'"=.*?{}\[\]`;
|
|
744
|
+
if (basename2.startsWith(".") && basename2.length <= 4 && !isAlphaAfterDot(basename2)) {
|
|
745
|
+
return new RegExp(`[\\w*]${escaped}(?=$|[${GLOB_DELIM}])`, "i");
|
|
746
|
+
}
|
|
747
|
+
if (basename2.startsWith(".")) {
|
|
748
|
+
return new RegExp(`(?:^|[${GLOB_DELIM}])${escaped}(?=$|[${GLOB_DELIM}])`, "i");
|
|
749
|
+
}
|
|
750
|
+
return new RegExp(`\\b${escaped}\\b`, "i");
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// src/gateway/runtimeConstructionResolvers.ts
|
|
754
|
+
var MAX_RECURSION_DEPTH = 3;
|
|
755
|
+
var INTERPRETER_RE = /\b(?:python[23]?|node|ruby|perl|php)\s+(?:-[cer])\s+(.+)/s;
|
|
756
|
+
var NESTED_ENCODING_RE = /chr\(\d+\)|String\.fromCharCode|\\x[0-9a-fA-F]{2}|\\[0-7]{1,3}|printf\s/;
|
|
757
|
+
var PRINTF_HEX_RE = /printf\s+['"]?[^'"]*\\x[0-9a-fA-F]{2}/;
|
|
758
|
+
var PRINTF_OCT_RE = /printf\s+['"]?[^'"]*\\[0-7]{1,3}/;
|
|
759
|
+
var B1_CONTEXT_RE = /(?:\bbase64\s+(?:-d|--decode)\b|\bopenssl\s+(?:enc\s+)?(?:-?base64\s+(?:-\w+\s+)*-d|-d\s+(?:-\w+\s+)*-?base64)\b)/;
|
|
760
|
+
var ECHO_E_HEX_RE = /\becho\s+(?:-\w+\s+)*-\w*e\w*\b[^|;&\n]*\\x[0-9a-fA-F]{2}/;
|
|
761
|
+
var ANSI_C_QUOTE_HEX_RE = /\$'[^']*\\x[0-9a-fA-F]{2}/;
|
|
762
|
+
var ANSI_C_QUOTE_OCT_RE = /\$'[^']*\\[0-7]{1,3}/;
|
|
763
|
+
var ANSI_C_QUOTE_UNICODE_RE = /\$'[^']*\\u[0-9a-fA-F]{4}/;
|
|
764
|
+
var ANSI_C_BLOCK_RE = /\$'((?:\\.|[^'\\])*)'/g;
|
|
765
|
+
var XXD_CONTEXT_RE = /\bxxd\s+(?:-r\s+-p|-p\s+-r)\b/;
|
|
766
|
+
var AWK_RE = /\b[gmn]?awk\s/;
|
|
767
|
+
var RESOLVERS = [
|
|
768
|
+
resolveCharCodeEncoding,
|
|
769
|
+
resolveInterpreterStringConcat,
|
|
770
|
+
resolveBase64Encoding,
|
|
771
|
+
resolveHexEscape,
|
|
772
|
+
resolveOctalEscape,
|
|
773
|
+
resolveUnicodeEscape,
|
|
774
|
+
resolveAnsiCBlock,
|
|
775
|
+
resolveXxdHexDecode
|
|
776
|
+
];
|
|
777
|
+
function runResolverPipeline(input, depth = 0) {
|
|
778
|
+
if (depth >= MAX_RECURSION_DEPTH) return [];
|
|
779
|
+
const results = [];
|
|
780
|
+
for (const resolver of RESOLVERS) {
|
|
781
|
+
for (const resolved of resolver(input)) {
|
|
782
|
+
if (resolved === input) continue;
|
|
783
|
+
results.push(resolved);
|
|
784
|
+
results.push(...runResolverPipeline(resolved, depth + 1));
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
return [...new Set(results)];
|
|
788
|
+
}
|
|
789
|
+
function resolveCharCodeEncoding(input) {
|
|
790
|
+
const results = [];
|
|
791
|
+
const TERM = String.raw`(?:chr\(\d+\)|'[^']*'|"(?:[^"\\]|\\.)*"|\\\"(?:[^"\\]|\\.)*\\\")`;
|
|
792
|
+
const CHAIN_RE = new RegExp(`${TERM}(?:\\s*\\+\\s*${TERM})+`, "g");
|
|
793
|
+
const SEG_RE = /chr\((\d+)\)|'([^']*)'|\\"([^"]*)\\"|"((?:[^"\\]|\\.)*)"/g;
|
|
794
|
+
const chains = [...input.matchAll(CHAIN_RE)];
|
|
795
|
+
for (const chain of chains) {
|
|
796
|
+
const segments = [...chain[0].matchAll(SEG_RE)];
|
|
797
|
+
const hasChr = segments.some((s) => s[1] !== void 0);
|
|
798
|
+
if (!hasChr) continue;
|
|
799
|
+
const decoded = segments.map((s) => {
|
|
800
|
+
if (s[1] !== void 0) return String.fromCharCode(parseInt(s[1], 10));
|
|
801
|
+
if (s[2] !== void 0) return s[2];
|
|
802
|
+
if (s[3] !== void 0) return s[3];
|
|
803
|
+
if (s[4] !== void 0) return s[4];
|
|
804
|
+
return "";
|
|
805
|
+
}).join("");
|
|
806
|
+
if (decoded.length > 0) results.push(decoded);
|
|
807
|
+
}
|
|
808
|
+
const JS_RE = /String\.fromCharCode\(\s*(\d+(?:\s*,\s*\d+)*)\s*\)/g;
|
|
809
|
+
let jsMatch;
|
|
810
|
+
while ((jsMatch = JS_RE.exec(input)) !== null) {
|
|
811
|
+
const argList = jsMatch[1];
|
|
812
|
+
const codePoints = argList.split(/\s*,\s*/).map((s) => parseInt(s, 10));
|
|
813
|
+
if (codePoints.some(isNaN)) continue;
|
|
814
|
+
const decoded = String.fromCharCode(...codePoints);
|
|
815
|
+
if (decoded.length > 0) results.push(decoded);
|
|
816
|
+
}
|
|
817
|
+
return [...new Set(results)];
|
|
818
|
+
}
|
|
819
|
+
function resolveBase64Encoding(input) {
|
|
820
|
+
if (!INTERPRETER_RE.test(input) && !B1_CONTEXT_RE.test(input)) return [];
|
|
821
|
+
const results = [];
|
|
822
|
+
const BASE64_RE = /[A-Za-z0-9+/]{4,}={0,2}/g;
|
|
823
|
+
let match;
|
|
824
|
+
while ((match = BASE64_RE.exec(input)) !== null) {
|
|
825
|
+
const candidate = match[0];
|
|
826
|
+
if (!isValidBase64(candidate)) continue;
|
|
827
|
+
let decoded;
|
|
828
|
+
try {
|
|
829
|
+
decoded = Buffer.from(candidate, "base64").toString("utf-8");
|
|
830
|
+
} catch {
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
if (decoded.length === 0) continue;
|
|
834
|
+
const scan = scanBashCommand(decoded, FORBIDDEN_BASENAMES);
|
|
835
|
+
if (scan.matched) {
|
|
836
|
+
results.push(decoded);
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
if (NESTED_ENCODING_RE.test(decoded)) {
|
|
840
|
+
results.push(decoded);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return [...new Set(results)];
|
|
844
|
+
}
|
|
845
|
+
function isValidBase64(s) {
|
|
846
|
+
if (s.length < 8) return false;
|
|
847
|
+
if (s.length % 4 !== 0) return false;
|
|
848
|
+
return /^[A-Za-z0-9+/]+={0,2}$/.test(s);
|
|
849
|
+
}
|
|
850
|
+
function resolveHexEscape(input) {
|
|
851
|
+
if (!INTERPRETER_RE.test(input) && !PRINTF_HEX_RE.test(input) && !ECHO_E_HEX_RE.test(input) && !ANSI_C_QUOTE_HEX_RE.test(input))
|
|
852
|
+
return [];
|
|
853
|
+
const results = [];
|
|
854
|
+
const HEX_RUN_RE = /(?:\\x[0-9a-fA-F]{2})+/g;
|
|
855
|
+
let match;
|
|
856
|
+
while ((match = HEX_RUN_RE.exec(input)) !== null) {
|
|
857
|
+
const hexRun = match[0];
|
|
858
|
+
const bytePairs = [...hexRun.matchAll(/\\x([0-9a-fA-F]{2})/g)];
|
|
859
|
+
const decoded = bytePairs.map((m) => String.fromCharCode(parseInt(m[1], 16))).join("");
|
|
860
|
+
if (decoded.length === 0) continue;
|
|
861
|
+
const afterIndex = match.index + hexRun.length;
|
|
862
|
+
const trailingMatch = /^'?([a-zA-Z0-9._-]*)/.exec(input.slice(afterIndex));
|
|
863
|
+
const trailing = trailingMatch ? trailingMatch[1] : "";
|
|
864
|
+
const fullCandidate = decoded + trailing;
|
|
865
|
+
const scan = scanBashCommand(fullCandidate, FORBIDDEN_BASENAMES);
|
|
866
|
+
if (!scan.matched) continue;
|
|
867
|
+
results.push(fullCandidate);
|
|
868
|
+
}
|
|
869
|
+
return [...new Set(results)];
|
|
870
|
+
}
|
|
871
|
+
function resolveOctalEscape(input) {
|
|
872
|
+
if (!INTERPRETER_RE.test(input) && !PRINTF_OCT_RE.test(input) && !ANSI_C_QUOTE_OCT_RE.test(input))
|
|
873
|
+
return [];
|
|
874
|
+
const results = [];
|
|
875
|
+
const OCTAL_RUN_RE = /(?:\\[0-7]{1,3})+/g;
|
|
876
|
+
let match;
|
|
877
|
+
while ((match = OCTAL_RUN_RE.exec(input)) !== null) {
|
|
878
|
+
const octalRun = match[0];
|
|
879
|
+
const octalDigits = [...octalRun.matchAll(/\\([0-7]{1,3})/g)];
|
|
880
|
+
const decoded = octalDigits.map((m) => {
|
|
881
|
+
const val = parseInt(m[1], 8);
|
|
882
|
+
if (val > 255) return "";
|
|
883
|
+
return String.fromCharCode(val);
|
|
884
|
+
}).join("");
|
|
885
|
+
if (decoded.length === 0) continue;
|
|
886
|
+
const afterIndex = match.index + octalRun.length;
|
|
887
|
+
const trailingMatch = /^'?([a-zA-Z0-9._-]*)/.exec(input.slice(afterIndex));
|
|
888
|
+
const trailing = trailingMatch ? trailingMatch[1] : "";
|
|
889
|
+
const fullCandidate = decoded + trailing;
|
|
890
|
+
const scan = scanBashCommand(fullCandidate, FORBIDDEN_BASENAMES);
|
|
891
|
+
if (!scan.matched) continue;
|
|
892
|
+
results.push(fullCandidate);
|
|
893
|
+
}
|
|
894
|
+
return [...new Set(results)];
|
|
895
|
+
}
|
|
896
|
+
function resolveUnicodeEscape(input) {
|
|
897
|
+
if (!INTERPRETER_RE.test(input) && !ANSI_C_QUOTE_UNICODE_RE.test(input)) return [];
|
|
898
|
+
const results = [];
|
|
899
|
+
const UNICODE_RUN_RE = /(?:\\u[0-9a-fA-F]{4})+/g;
|
|
900
|
+
let match;
|
|
901
|
+
while ((match = UNICODE_RUN_RE.exec(input)) !== null) {
|
|
902
|
+
const unicodeRun = match[0];
|
|
903
|
+
const codePoints = [...unicodeRun.matchAll(/\\u([0-9a-fA-F]{4})/g)];
|
|
904
|
+
const decoded = codePoints.map((m) => String.fromCharCode(parseInt(m[1], 16))).join("");
|
|
905
|
+
if (decoded.length === 0) continue;
|
|
906
|
+
const afterIndex = match.index + unicodeRun.length;
|
|
907
|
+
const trailingMatch = /^'?([a-zA-Z0-9._-]*)/.exec(input.slice(afterIndex));
|
|
908
|
+
const trailing = trailingMatch ? trailingMatch[1] : "";
|
|
909
|
+
const fullCandidate = decoded + trailing;
|
|
910
|
+
const scan = scanBashCommand(fullCandidate, FORBIDDEN_BASENAMES);
|
|
911
|
+
if (!scan.matched) continue;
|
|
912
|
+
results.push(fullCandidate);
|
|
913
|
+
}
|
|
914
|
+
return [...new Set(results)];
|
|
915
|
+
}
|
|
916
|
+
function resolveInterpreterStringConcat(input) {
|
|
917
|
+
if (!INTERPRETER_RE.test(input) && !AWK_RE.test(input)) return [];
|
|
918
|
+
const results = [];
|
|
919
|
+
const LIT_TERM = String.raw`(?:'[^']*'|"(?:[^"\\]|\\.)*"|\\\"(?:[^"\\]|\\.)*\\\")`;
|
|
920
|
+
const CONCAT_OP = String.raw`(?:\s*[+.]\s*|\s*(?=["']))`;
|
|
921
|
+
const CONCAT_CHAIN_RE = new RegExp(`${LIT_TERM}(?:${CONCAT_OP}${LIT_TERM})+`, "g");
|
|
922
|
+
const LIT_SEG_RE = /'([^']*)'|\\"([^"]*)\\"|"((?:[^"\\]|\\.)*)"/g;
|
|
923
|
+
for (const chain of input.matchAll(CONCAT_CHAIN_RE)) {
|
|
924
|
+
const segments = [...chain[0].matchAll(LIT_SEG_RE)];
|
|
925
|
+
const decoded = segments.map((s) => s[1] ?? s[2] ?? s[3] ?? "").join("");
|
|
926
|
+
if (decoded.length === 0) continue;
|
|
927
|
+
const scan = scanBashCommand(decoded, FORBIDDEN_BASENAMES);
|
|
928
|
+
if (!scan.matched) continue;
|
|
929
|
+
results.push(decoded);
|
|
930
|
+
}
|
|
931
|
+
return [...new Set(results)];
|
|
932
|
+
}
|
|
933
|
+
function decodeAnsiCBlock(raw) {
|
|
934
|
+
let result = "";
|
|
935
|
+
let i = 0;
|
|
936
|
+
while (i < raw.length) {
|
|
937
|
+
if (raw[i] === "\\" && i + 1 < raw.length) {
|
|
938
|
+
const next = raw[i + 1];
|
|
939
|
+
if (next === "x" && i + 3 < raw.length) {
|
|
940
|
+
const hex = raw.slice(i + 2, i + 4);
|
|
941
|
+
if (/^[0-9a-fA-F]{2}$/.test(hex)) {
|
|
942
|
+
result += String.fromCharCode(parseInt(hex, 16));
|
|
943
|
+
i += 4;
|
|
944
|
+
continue;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
if (next === "u") {
|
|
948
|
+
const rest = raw.slice(i + 2, i + 6);
|
|
949
|
+
const m = /^[0-9a-fA-F]{1,4}/.exec(rest);
|
|
950
|
+
if (m) {
|
|
951
|
+
result += String.fromCodePoint(parseInt(m[0], 16));
|
|
952
|
+
i += 2 + m[0].length;
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
if (next === "U") {
|
|
957
|
+
const rest = raw.slice(i + 2, i + 10);
|
|
958
|
+
const m = /^[0-9a-fA-F]{1,8}/.exec(rest);
|
|
959
|
+
if (m) {
|
|
960
|
+
const cp = parseInt(m[0], 16);
|
|
961
|
+
if (cp <= 1114111) {
|
|
962
|
+
result += String.fromCodePoint(cp);
|
|
963
|
+
i += 2 + m[0].length;
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
if (next >= "0" && next <= "7") {
|
|
969
|
+
const rest = raw.slice(i + 1, i + 4);
|
|
970
|
+
const m = /^[0-7]{1,3}/.exec(rest);
|
|
971
|
+
if (m) {
|
|
972
|
+
const val = parseInt(m[0], 8);
|
|
973
|
+
if (val <= 255) {
|
|
974
|
+
result += String.fromCharCode(val);
|
|
975
|
+
i += 1 + m[0].length;
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
const NAMED = {
|
|
981
|
+
"\\": "\\",
|
|
982
|
+
"'": "'",
|
|
983
|
+
n: "\n",
|
|
984
|
+
t: " ",
|
|
985
|
+
r: "\r",
|
|
986
|
+
a: "\x07",
|
|
987
|
+
b: "\b",
|
|
988
|
+
f: "\f",
|
|
989
|
+
v: "\v",
|
|
990
|
+
e: "\x1B",
|
|
991
|
+
"?": "?"
|
|
992
|
+
};
|
|
993
|
+
if (next in NAMED) {
|
|
994
|
+
result += NAMED[next];
|
|
995
|
+
i += 2;
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
result += next;
|
|
999
|
+
i += 2;
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
result += raw[i];
|
|
1003
|
+
i += 1;
|
|
1004
|
+
}
|
|
1005
|
+
return result;
|
|
1006
|
+
}
|
|
1007
|
+
function resolveAnsiCBlock(input) {
|
|
1008
|
+
const results = [];
|
|
1009
|
+
ANSI_C_BLOCK_RE.lastIndex = 0;
|
|
1010
|
+
let match;
|
|
1011
|
+
while ((match = ANSI_C_BLOCK_RE.exec(input)) !== null) {
|
|
1012
|
+
const rawInner = match[1];
|
|
1013
|
+
const decoded = decodeAnsiCBlock(rawInner);
|
|
1014
|
+
if (decoded.length === 0) continue;
|
|
1015
|
+
const afterIndex = match.index + match[0].length;
|
|
1016
|
+
const trailingMatch = /^[a-zA-Z0-9._-]*/.exec(input.slice(afterIndex));
|
|
1017
|
+
const trailing = trailingMatch ? trailingMatch[0] : "";
|
|
1018
|
+
const fullCandidate = decoded + trailing;
|
|
1019
|
+
const scan = scanBashCommand(fullCandidate, FORBIDDEN_BASENAMES);
|
|
1020
|
+
if (!scan.matched) continue;
|
|
1021
|
+
results.push(fullCandidate);
|
|
1022
|
+
}
|
|
1023
|
+
return [...new Set(results)];
|
|
1024
|
+
}
|
|
1025
|
+
function resolveXxdHexDecode(input) {
|
|
1026
|
+
if (!XXD_CONTEXT_RE.test(input)) return [];
|
|
1027
|
+
const results = [];
|
|
1028
|
+
const RAW_HEX_RUN_RE = /\b(?:[0-9a-fA-F]{2}){4,}\b/g;
|
|
1029
|
+
let match;
|
|
1030
|
+
while ((match = RAW_HEX_RUN_RE.exec(input)) !== null) {
|
|
1031
|
+
const hexStr = match[0];
|
|
1032
|
+
const pairs = hexStr.match(/.{2}/g);
|
|
1033
|
+
if (!pairs) continue;
|
|
1034
|
+
const decoded = pairs.map((p) => String.fromCharCode(parseInt(p, 16))).join("");
|
|
1035
|
+
if (decoded.length === 0) continue;
|
|
1036
|
+
const scan = scanBashCommand(decoded, FORBIDDEN_BASENAMES);
|
|
1037
|
+
if (!scan.matched) continue;
|
|
1038
|
+
results.push(decoded);
|
|
1039
|
+
}
|
|
1040
|
+
return [...new Set(results)];
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// src/gateway/claudeCodeTranslator.ts
|
|
1044
|
+
var TOOL_MAP = {
|
|
1045
|
+
Bash: { action: "command_exec", targetKey: "command" },
|
|
1046
|
+
Read: { action: "file_read", targetKey: "file_path" },
|
|
1047
|
+
Write: { action: "file_write", targetKey: "file_path" },
|
|
1048
|
+
Edit: { action: "file_write", targetKey: "file_path" },
|
|
1049
|
+
Glob: { action: "file_read", targetKey: "pattern" },
|
|
1050
|
+
Grep: { action: "file_read", targetKey: "path" },
|
|
1051
|
+
WebFetch: { action: "network_request", targetKey: "url" },
|
|
1052
|
+
WebSearch: { action: "network_request", targetKey: "query" },
|
|
1053
|
+
Task: { action: "tool_invocation", targetKey: "description" },
|
|
1054
|
+
Skill: { action: "tool_invocation", targetKey: "skill" },
|
|
1055
|
+
NotebookEdit: { action: "file_write", targetKey: "notebook_path" }
|
|
1056
|
+
};
|
|
1057
|
+
var AGENT_ID = "claude-code";
|
|
1058
|
+
var AGENT_NAME = "Claude Code";
|
|
1059
|
+
var AGENT_ROLE = "coding-assistant";
|
|
1060
|
+
var TOOL_RESPONSE_MAX_CHARS = 500;
|
|
1061
|
+
function extractBashTargets(toolInput) {
|
|
1062
|
+
const command = toolInput.command;
|
|
1063
|
+
if (typeof command !== "string" || command.length === 0) return ["Bash"];
|
|
1064
|
+
const targets = [command];
|
|
1065
|
+
const resolved = runResolverPipeline(command);
|
|
1066
|
+
for (const r of resolved) {
|
|
1067
|
+
targets.push(r);
|
|
1068
|
+
}
|
|
1069
|
+
return targets;
|
|
1070
|
+
}
|
|
1071
|
+
function extractTaskTargets(toolInput) {
|
|
1072
|
+
const targets = [];
|
|
1073
|
+
const description = toolInput.description;
|
|
1074
|
+
if (typeof description === "string" && description.length > 0) {
|
|
1075
|
+
targets.push(description);
|
|
1076
|
+
}
|
|
1077
|
+
const prompt = toolInput.prompt;
|
|
1078
|
+
if (typeof prompt === "string" && prompt.length > 0) {
|
|
1079
|
+
targets.push(prompt);
|
|
1080
|
+
}
|
|
1081
|
+
const subagentType = toolInput.subagent_type;
|
|
1082
|
+
if (typeof subagentType === "string" && subagentType.length > 0) {
|
|
1083
|
+
targets.push(subagentType);
|
|
1084
|
+
}
|
|
1085
|
+
return targets.length > 0 ? targets : ["Task"];
|
|
1086
|
+
}
|
|
1087
|
+
function extractWebFetchTargets(toolInput) {
|
|
1088
|
+
const url = toolInput.url;
|
|
1089
|
+
if (typeof url !== "string" || url.length === 0) return ["WebFetch"];
|
|
1090
|
+
const targets = [url];
|
|
1091
|
+
try {
|
|
1092
|
+
const parsed = new URL(url);
|
|
1093
|
+
const hostTarget = `${parsed.protocol}//${parsed.host}`;
|
|
1094
|
+
if (hostTarget !== url) targets.push(hostTarget);
|
|
1095
|
+
if (parsed.pathname && parsed.pathname !== "/") targets.push(parsed.pathname);
|
|
1096
|
+
} catch {
|
|
1097
|
+
}
|
|
1098
|
+
return targets;
|
|
1099
|
+
}
|
|
1100
|
+
function extractNotebookEditTargets(toolInput) {
|
|
1101
|
+
const targets = [];
|
|
1102
|
+
const notebookPath = toolInput.notebook_path;
|
|
1103
|
+
if (typeof notebookPath === "string" && notebookPath.length > 0) {
|
|
1104
|
+
targets.push(notebookPath);
|
|
1105
|
+
}
|
|
1106
|
+
const newSource = toolInput.new_source;
|
|
1107
|
+
if (typeof newSource === "string" && newSource.length > 0) {
|
|
1108
|
+
targets.push(newSource);
|
|
1109
|
+
const resolved = runResolverPipeline(newSource);
|
|
1110
|
+
for (const r of resolved) {
|
|
1111
|
+
targets.push(r);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
const cellId = toolInput.cell_id;
|
|
1115
|
+
if (typeof cellId === "string" && cellId.length > 0) {
|
|
1116
|
+
targets.push(cellId);
|
|
1117
|
+
}
|
|
1118
|
+
return targets.length > 0 ? targets : ["NotebookEdit"];
|
|
1119
|
+
}
|
|
1120
|
+
function extractGrepTargets(toolInput, cwd) {
|
|
1121
|
+
const targets = [];
|
|
1122
|
+
const path = toolInput.path;
|
|
1123
|
+
if (typeof path === "string" && path.length > 0) {
|
|
1124
|
+
targets.push(path);
|
|
1125
|
+
} else if (cwd) {
|
|
1126
|
+
targets.push(cwd);
|
|
1127
|
+
}
|
|
1128
|
+
const pattern = toolInput.pattern;
|
|
1129
|
+
if (typeof pattern === "string" && pattern.length > 0) {
|
|
1130
|
+
targets.push(pattern);
|
|
1131
|
+
}
|
|
1132
|
+
return targets.length > 0 ? targets : ["Grep"];
|
|
1133
|
+
}
|
|
1134
|
+
function extractTargets(toolName, toolInput, cwd) {
|
|
1135
|
+
switch (toolName) {
|
|
1136
|
+
case "Bash":
|
|
1137
|
+
return extractBashTargets(toolInput);
|
|
1138
|
+
case "Task":
|
|
1139
|
+
return extractTaskTargets(toolInput);
|
|
1140
|
+
case "WebFetch":
|
|
1141
|
+
return extractWebFetchTargets(toolInput);
|
|
1142
|
+
case "NotebookEdit":
|
|
1143
|
+
return extractNotebookEditTargets(toolInput);
|
|
1144
|
+
case "Grep":
|
|
1145
|
+
return extractGrepTargets(toolInput, cwd);
|
|
1146
|
+
default: {
|
|
1147
|
+
const mapping = TOOL_MAP[toolName];
|
|
1148
|
+
if (!mapping) return [toolName];
|
|
1149
|
+
const val = toolInput[mapping.targetKey];
|
|
1150
|
+
if (typeof val === "string" && val.length > 0) return [val];
|
|
1151
|
+
return [toolName];
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
function isMcpTool(toolName) {
|
|
1156
|
+
return toolName.startsWith("mcp__");
|
|
1157
|
+
}
|
|
1158
|
+
var MCP_MUTATING_VERBS = ["write", "delete", "exec", "create", "send", "put", "update", "remove"];
|
|
1159
|
+
function mcpVerbIsMutating(toolName) {
|
|
1160
|
+
const seg = (toolName.split("__").pop() ?? "").toLowerCase();
|
|
1161
|
+
const tokens = seg.split(/[_-]/).filter((t) => t.length > 0);
|
|
1162
|
+
return tokens.some((t) => MCP_MUTATING_VERBS.some((v) => t.startsWith(v)));
|
|
1163
|
+
}
|
|
1164
|
+
function extractMcpTargets(toolName, toolInput) {
|
|
1165
|
+
const targets = [toolName];
|
|
1166
|
+
const keys = Object.keys(toolInput);
|
|
1167
|
+
let extractedStrings = 0;
|
|
1168
|
+
for (const key of keys) {
|
|
1169
|
+
const value = toolInput[key];
|
|
1170
|
+
if (typeof value !== "string" || value.length === 0) continue;
|
|
1171
|
+
extractedStrings++;
|
|
1172
|
+
targets.push(value);
|
|
1173
|
+
const resolved = runResolverPipeline(value);
|
|
1174
|
+
for (const r of resolved) {
|
|
1175
|
+
targets.push(r);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
return {
|
|
1179
|
+
targets,
|
|
1180
|
+
unextractable: keys.length > 0 && extractedStrings === 0,
|
|
1181
|
+
mutating: mcpVerbIsMutating(toolName)
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
var UNKNOWN_TOOL_REASON = "tool schema unknown \u2014 sensitivity scoring and forbidden target patterns cannot evaluate this event";
|
|
1185
|
+
var ClaudeCodeTranslator = class {
|
|
1186
|
+
agentType = "claude-code";
|
|
1187
|
+
translatePreToolUse(payload) {
|
|
1188
|
+
const p = payload;
|
|
1189
|
+
if (!p || typeof p !== "object" || !p.tool_name) return null;
|
|
1190
|
+
const toolName = p.tool_name;
|
|
1191
|
+
const toolInput = p.tool_input ?? {};
|
|
1192
|
+
const cwd = p.cwd ?? "";
|
|
1193
|
+
const { action, targets, isUnknown, isMcp, mcpUnscanned, mcpMutating } = this.resolveToolMapping(toolName, toolInput, cwd);
|
|
1194
|
+
const metadata = {
|
|
1195
|
+
executionPhase: "pre",
|
|
1196
|
+
ccToolName: toolName
|
|
1197
|
+
};
|
|
1198
|
+
if (cwd) metadata.cwd = cwd;
|
|
1199
|
+
if (p.session_id) metadata.ccSessionId = p.session_id;
|
|
1200
|
+
if (p.agent_id) metadata.agent_id = p.agent_id;
|
|
1201
|
+
if (p.agent_type) metadata.agent_type = p.agent_type;
|
|
1202
|
+
if (isUnknown) {
|
|
1203
|
+
metadata._unknownTool = "true";
|
|
1204
|
+
metadata._policyEnforcementBypassed = "true";
|
|
1205
|
+
metadata._policyBypassReason = UNKNOWN_TOOL_REASON;
|
|
1206
|
+
console.warn(
|
|
1207
|
+
`[SENTINEL] Unknown Claude Code tool "${toolName}" \u2014 allowing with WARN. ${UNKNOWN_TOOL_REASON}`
|
|
1208
|
+
);
|
|
1209
|
+
}
|
|
1210
|
+
if (isMcp) {
|
|
1211
|
+
metadata._mcpTool = "true";
|
|
1212
|
+
if (mcpUnscanned) {
|
|
1213
|
+
metadata._mcpUnscanned = "true";
|
|
1214
|
+
if (mcpMutating) {
|
|
1215
|
+
metadata._mcpUnscannedMutating = "true";
|
|
1216
|
+
console.warn(
|
|
1217
|
+
`[SENTINEL] MCP tool "${toolName}" (mutating) called with unscannable arguments \u2014 escalating. Arguments contained no extractable string operand; forbidden-path and sensitivity checks could not evaluate them.`
|
|
1218
|
+
);
|
|
1219
|
+
} else {
|
|
1220
|
+
console.warn(
|
|
1221
|
+
`[SENTINEL] MCP tool "${toolName}" called with unscannable arguments \u2014 allowing with WARN. Arguments contained no extractable string operand.`
|
|
1222
|
+
);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
return {
|
|
1227
|
+
agentId: AGENT_ID,
|
|
1228
|
+
agentName: AGENT_NAME,
|
|
1229
|
+
agentRole: AGENT_ROLE,
|
|
1230
|
+
action,
|
|
1231
|
+
targets,
|
|
1232
|
+
primaryTarget: targets[0],
|
|
1233
|
+
schemaVersion: 2,
|
|
1234
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1235
|
+
sessionId: p.session_id,
|
|
1236
|
+
metadata
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
translatePostToolUse(payload) {
|
|
1240
|
+
const p = payload;
|
|
1241
|
+
if (!p || typeof p !== "object" || !p.tool_name) return null;
|
|
1242
|
+
const toolName = p.tool_name;
|
|
1243
|
+
const toolInput = p.tool_input ?? {};
|
|
1244
|
+
const cwd = p.cwd ?? "";
|
|
1245
|
+
const { action, targets, isUnknown, isMcp, mcpUnscanned, mcpMutating } = this.resolveToolMapping(toolName, toolInput, cwd);
|
|
1246
|
+
const metadata = {
|
|
1247
|
+
executionPhase: "post",
|
|
1248
|
+
ccToolName: toolName
|
|
1249
|
+
};
|
|
1250
|
+
if (cwd) metadata.cwd = cwd;
|
|
1251
|
+
if (p.session_id) metadata.ccSessionId = p.session_id;
|
|
1252
|
+
if (p.agent_id) metadata.agent_id = p.agent_id;
|
|
1253
|
+
if (p.agent_type) metadata.agent_type = p.agent_type;
|
|
1254
|
+
if (p.tool_response !== void 0) {
|
|
1255
|
+
const summary = String(p.tool_response);
|
|
1256
|
+
metadata.toolResponseSummary = summary.length > TOOL_RESPONSE_MAX_CHARS ? summary.slice(0, TOOL_RESPONSE_MAX_CHARS) : summary;
|
|
1257
|
+
}
|
|
1258
|
+
if (isUnknown) {
|
|
1259
|
+
metadata._unknownTool = "true";
|
|
1260
|
+
metadata._policyEnforcementBypassed = "true";
|
|
1261
|
+
metadata._policyBypassReason = UNKNOWN_TOOL_REASON;
|
|
1262
|
+
}
|
|
1263
|
+
if (isMcp) {
|
|
1264
|
+
metadata._mcpTool = "true";
|
|
1265
|
+
if (mcpUnscanned) {
|
|
1266
|
+
metadata._mcpUnscanned = "true";
|
|
1267
|
+
if (mcpMutating) metadata._mcpUnscannedMutating = "true";
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
return {
|
|
1271
|
+
agentId: AGENT_ID,
|
|
1272
|
+
agentName: AGENT_NAME,
|
|
1273
|
+
agentRole: AGENT_ROLE,
|
|
1274
|
+
action,
|
|
1275
|
+
targets,
|
|
1276
|
+
primaryTarget: targets[0],
|
|
1277
|
+
schemaVersion: 2,
|
|
1278
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1279
|
+
sessionId: p.session_id,
|
|
1280
|
+
metadata
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
translateSessionEnd(payload) {
|
|
1284
|
+
const p = payload;
|
|
1285
|
+
if (!p || typeof p !== "object") return null;
|
|
1286
|
+
return {
|
|
1287
|
+
sessionId: p.session_id,
|
|
1288
|
+
cwd: p.cwd
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
translateUserPromptSubmit(payload) {
|
|
1292
|
+
const p = payload;
|
|
1293
|
+
if (!p || typeof p !== "object" || typeof p.prompt !== "string") return null;
|
|
1294
|
+
return {
|
|
1295
|
+
sessionId: p.session_id,
|
|
1296
|
+
cwd: p.cwd,
|
|
1297
|
+
prompt: p.prompt
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
formatPreToolUseResponse(result) {
|
|
1301
|
+
if (result.blocked) {
|
|
1302
|
+
const reason = result.finding ? `Sentinel blocked: ${result.finding.description}` : "Sentinel blocked this action";
|
|
1303
|
+
return {
|
|
1304
|
+
hookSpecificOutput: {
|
|
1305
|
+
hookEventName: "PreToolUse",
|
|
1306
|
+
permissionDecision: "deny",
|
|
1307
|
+
permissionDecisionReason: reason
|
|
1308
|
+
}
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
return {
|
|
1312
|
+
hookSpecificOutput: {
|
|
1313
|
+
hookEventName: "PreToolUse",
|
|
1314
|
+
permissionDecision: "allow"
|
|
1315
|
+
}
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
/**
|
|
1319
|
+
* Format a MODIFY response — allows the tool call with rewritten arguments.
|
|
1320
|
+
* Uses cc's updatedInput field (shallow merge with original tool_input)
|
|
1321
|
+
* and additionalContext for transparency.
|
|
1322
|
+
*
|
|
1323
|
+
* Sprint 6a Prompt 8b: used by Grep handler to inject --glob exclusions.
|
|
1324
|
+
*/
|
|
1325
|
+
formatPreToolUseModifyResponse(args) {
|
|
1326
|
+
return {
|
|
1327
|
+
hookSpecificOutput: {
|
|
1328
|
+
hookEventName: "PreToolUse",
|
|
1329
|
+
permissionDecision: "allow",
|
|
1330
|
+
updatedInput: args.updatedInput,
|
|
1331
|
+
additionalContext: args.additionalContext
|
|
1332
|
+
}
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
formatPostToolUseResponse() {
|
|
1336
|
+
return { hookSpecificOutput: { hookEventName: "PostToolUse" } };
|
|
1337
|
+
}
|
|
1338
|
+
formatSessionEndResponse() {
|
|
1339
|
+
return { hookSpecificOutput: {} };
|
|
1340
|
+
}
|
|
1341
|
+
// -------------------------------------------------------------------------
|
|
1342
|
+
// Internal
|
|
1343
|
+
// -------------------------------------------------------------------------
|
|
1344
|
+
resolveToolMapping(toolName, toolInput, cwd) {
|
|
1345
|
+
const mapping = TOOL_MAP[toolName];
|
|
1346
|
+
if (mapping) {
|
|
1347
|
+
return {
|
|
1348
|
+
action: mapping.action,
|
|
1349
|
+
targets: extractTargets(toolName, toolInput, cwd),
|
|
1350
|
+
isUnknown: false,
|
|
1351
|
+
isMcp: false
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1354
|
+
if (isMcpTool(toolName)) {
|
|
1355
|
+
const mcp = extractMcpTargets(toolName, toolInput);
|
|
1356
|
+
return {
|
|
1357
|
+
action: "tool_invocation",
|
|
1358
|
+
targets: mcp.targets,
|
|
1359
|
+
isUnknown: false,
|
|
1360
|
+
isMcp: true,
|
|
1361
|
+
mcpUnscanned: mcp.unextractable,
|
|
1362
|
+
mcpMutating: mcp.mutating
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
return {
|
|
1366
|
+
action: "tool_invocation",
|
|
1367
|
+
targets: [toolName],
|
|
1368
|
+
isUnknown: true,
|
|
1369
|
+
isMcp: false
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
};
|
|
1373
|
+
|
|
1374
|
+
// src/gateway/grepRewriter.ts
|
|
1375
|
+
function buildGrepExclusions(forbiddenPatterns) {
|
|
1376
|
+
const flags = [];
|
|
1377
|
+
for (const pattern of forbiddenPatterns) {
|
|
1378
|
+
flags.push("--glob", `!${pattern}`);
|
|
1379
|
+
}
|
|
1380
|
+
return flags;
|
|
1381
|
+
}
|
|
1382
|
+
function isPathItselfForbidden(searchPath, forbiddenPatterns) {
|
|
1383
|
+
const matched = [];
|
|
1384
|
+
for (const pattern of forbiddenPatterns) {
|
|
1385
|
+
if (matchGlobInsensitive(pattern, searchPath)) {
|
|
1386
|
+
matched.push(pattern);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
return { forbidden: matched.length > 0, matchedPatterns: matched };
|
|
1390
|
+
}
|
|
1391
|
+
function buildModifiedGrepInput(originalInput, exclusions) {
|
|
1392
|
+
const updated = { ...originalInput };
|
|
1393
|
+
const exclusionPatterns = exclusions.filter((_, i) => i % 2 === 1).map((p) => p.slice(1));
|
|
1394
|
+
const exclusionGlob = exclusionPatterns.length === 1 ? `!${exclusionPatterns[0]}` : `!{${exclusionPatterns.join(",")}}`;
|
|
1395
|
+
updated.glob = exclusionGlob;
|
|
1396
|
+
return updated;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// src/gateway/server.ts
|
|
1400
|
+
var DEFAULT_PORT = 7847;
|
|
1401
|
+
var MAX_BODY_SIZE = 1024 * 1024;
|
|
1402
|
+
var GATEWAY_VERSION = "0.1.0";
|
|
1403
|
+
function parseIntentLine(prompt) {
|
|
1404
|
+
if (typeof prompt !== "string") return null;
|
|
1405
|
+
const firstNonEmpty = prompt.split("\n").find((line) => line.trim().length > 0);
|
|
1406
|
+
if (!firstNonEmpty) return null;
|
|
1407
|
+
const match = firstNonEmpty.match(/^\s*intent:\s*(.+?)\s*$/i);
|
|
1408
|
+
if (!match) return null;
|
|
1409
|
+
const description = match[1].trim();
|
|
1410
|
+
return description.length > 0 ? description : null;
|
|
1411
|
+
}
|
|
1412
|
+
function parseScopeLine(prompt) {
|
|
1413
|
+
if (typeof prompt !== "string") return null;
|
|
1414
|
+
const lines = prompt.split("\n");
|
|
1415
|
+
let seenNonBlank = false;
|
|
1416
|
+
for (const line of lines) {
|
|
1417
|
+
const trimmed = line.trim();
|
|
1418
|
+
if (trimmed.length === 0) {
|
|
1419
|
+
if (seenNonBlank) break;
|
|
1420
|
+
continue;
|
|
1421
|
+
}
|
|
1422
|
+
seenNonBlank = true;
|
|
1423
|
+
const scopeMatch = trimmed.match(/^scope:\s*(.+?)\s*$/i);
|
|
1424
|
+
if (scopeMatch) {
|
|
1425
|
+
const globs = scopeMatch[1].split(/[\s,]+/).map((g) => g.trim()).filter((g) => g.length > 0);
|
|
1426
|
+
return globs.length > 0 ? globs : null;
|
|
1427
|
+
}
|
|
1428
|
+
if (!/^intent:/i.test(trimmed)) break;
|
|
1429
|
+
}
|
|
1430
|
+
return null;
|
|
1431
|
+
}
|
|
1432
|
+
var SentinelGateway = class {
|
|
1433
|
+
configuredPort;
|
|
1434
|
+
sentinel;
|
|
1435
|
+
registry;
|
|
1436
|
+
agentId;
|
|
1437
|
+
telemetry = new Telemetry();
|
|
1438
|
+
forbiddenPatterns;
|
|
1439
|
+
/** B5a gate (default off). True → per-workspace B-path; false → today's behavior. */
|
|
1440
|
+
workspaceIsolation;
|
|
1441
|
+
operatorCeiling;
|
|
1442
|
+
home;
|
|
1443
|
+
server = null;
|
|
1444
|
+
running = false;
|
|
1445
|
+
signalHandlersInstalled = false;
|
|
1446
|
+
boundSigterm = null;
|
|
1447
|
+
boundSigint = null;
|
|
1448
|
+
constructor(options) {
|
|
1449
|
+
this.configuredPort = options.port ?? DEFAULT_PORT;
|
|
1450
|
+
this.sentinel = options.sentinel;
|
|
1451
|
+
this.agentId = options.agentId;
|
|
1452
|
+
this.forbiddenPatterns = options.forbiddenPatterns ?? DEFAULT_FORBIDDEN_PATTERNS;
|
|
1453
|
+
this.workspaceIsolation = options.workspaceIsolation ?? process.env.SENTINEL_WORKSPACE_ISOLATION === "1";
|
|
1454
|
+
this.operatorCeiling = options.operatorCeiling ?? null;
|
|
1455
|
+
this.home = options.home ?? "";
|
|
1456
|
+
const internal = options;
|
|
1457
|
+
if (internal.registry) {
|
|
1458
|
+
this.registry = internal.registry;
|
|
1459
|
+
} else if (internal.translator) {
|
|
1460
|
+
this.registry = new TranslatorRegistry();
|
|
1461
|
+
this.registry.register(internal.translator);
|
|
1462
|
+
} else {
|
|
1463
|
+
this.registry = new TranslatorRegistry();
|
|
1464
|
+
this.registry.register(new ClaudeCodeTranslator());
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
get port() {
|
|
1468
|
+
const addr = this.server?.address();
|
|
1469
|
+
if (addr && typeof addr === "object") return addr.port;
|
|
1470
|
+
return this.configuredPort;
|
|
1471
|
+
}
|
|
1472
|
+
isRunning() {
|
|
1473
|
+
return this.running;
|
|
1474
|
+
}
|
|
1475
|
+
async start() {
|
|
1476
|
+
if (this.running) return;
|
|
1477
|
+
this.running = true;
|
|
1478
|
+
const http = await import("http");
|
|
1479
|
+
this.server = http.createServer((req, res) => this.handleRequest(req, res));
|
|
1480
|
+
await new Promise((resolve2, reject) => {
|
|
1481
|
+
this.server.once("error", reject);
|
|
1482
|
+
this.server.listen(this.configuredPort, () => {
|
|
1483
|
+
this.server.removeListener("error", reject);
|
|
1484
|
+
resolve2();
|
|
1485
|
+
});
|
|
1486
|
+
});
|
|
1487
|
+
this.installSignalHandlers();
|
|
1488
|
+
if (this.workspaceIsolation) this.sentinel.startWorkspaceSweep();
|
|
1489
|
+
console.log(`[SENTINEL GATEWAY] Listening on port ${this.port}`);
|
|
1490
|
+
}
|
|
1491
|
+
async stop() {
|
|
1492
|
+
if (!this.server) return;
|
|
1493
|
+
this.removeSignalHandlers();
|
|
1494
|
+
if (this.workspaceIsolation) this.sentinel.stopWorkspaceSweep();
|
|
1495
|
+
await new Promise((resolve2, reject) => {
|
|
1496
|
+
this.server.close((err) => err ? reject(err) : resolve2());
|
|
1497
|
+
});
|
|
1498
|
+
this.server = null;
|
|
1499
|
+
this.running = false;
|
|
1500
|
+
console.log("[SENTINEL GATEWAY] Stopped");
|
|
1501
|
+
}
|
|
1502
|
+
/**
|
|
1503
|
+
* Session completion. Safe to call multiple times — idempotency is provided
|
|
1504
|
+
* by classifier.flush() returning null when no events have accumulated
|
|
1505
|
+
* (runner.ts completeSession L612-613). Supports multi-session gateway
|
|
1506
|
+
* lifetimes: each cc session's events flush independently.
|
|
1507
|
+
*/
|
|
1508
|
+
async completeSessionSafe(routingId = this.agentId) {
|
|
1509
|
+
try {
|
|
1510
|
+
await this.sentinel.completeSession(routingId);
|
|
1511
|
+
} catch (err) {
|
|
1512
|
+
console.error("[SENTINEL GATEWAY] completeSession error:", err);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
/**
|
|
1516
|
+
* B5b-Phase-2: complete EVERY active session on shutdown, not just the global
|
|
1517
|
+
* identity. Under workspace isolation each workspace runs its own runner with
|
|
1518
|
+
* its own session + audit trail; a clean SIGTERM/SIGINT must flush each one,
|
|
1519
|
+
* otherwise a workspace's tail events are lost on daemon shutdown. The global
|
|
1520
|
+
* agentId is always included (it is the only runner gate-off, so this reduces
|
|
1521
|
+
* to today's single completeSession in the escape-hatch case). Per-id failures
|
|
1522
|
+
* are swallowed by completeSessionSafe so one bad runner can't strand the rest;
|
|
1523
|
+
* completeSession is idempotent (flush() returns null with nothing queued).
|
|
1524
|
+
*/
|
|
1525
|
+
async completeAllSessionsSafe() {
|
|
1526
|
+
const ids = /* @__PURE__ */ new Set([this.agentId, ...this.sentinel.listActiveAgentIds()]);
|
|
1527
|
+
for (const id of ids) {
|
|
1528
|
+
await this.completeSessionSafe(id);
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
// -------------------------------------------------------------------------
|
|
1532
|
+
// Signal handlers (fallback for SIGTERM/SIGINT)
|
|
1533
|
+
// -------------------------------------------------------------------------
|
|
1534
|
+
installSignalHandlers() {
|
|
1535
|
+
if (this.signalHandlersInstalled) return;
|
|
1536
|
+
this.boundSigterm = () => {
|
|
1537
|
+
this.handleSignal("SIGTERM", 0);
|
|
1538
|
+
};
|
|
1539
|
+
this.boundSigint = () => {
|
|
1540
|
+
this.handleSignal("SIGINT", 130);
|
|
1541
|
+
};
|
|
1542
|
+
process.on("SIGTERM", this.boundSigterm);
|
|
1543
|
+
process.on("SIGINT", this.boundSigint);
|
|
1544
|
+
this.signalHandlersInstalled = true;
|
|
1545
|
+
}
|
|
1546
|
+
removeSignalHandlers() {
|
|
1547
|
+
if (this.boundSigterm) process.removeListener("SIGTERM", this.boundSigterm);
|
|
1548
|
+
if (this.boundSigint) process.removeListener("SIGINT", this.boundSigint);
|
|
1549
|
+
this.boundSigterm = null;
|
|
1550
|
+
this.boundSigint = null;
|
|
1551
|
+
this.signalHandlersInstalled = false;
|
|
1552
|
+
}
|
|
1553
|
+
handleSignal(signal, exitCode) {
|
|
1554
|
+
console.log(`[SENTINEL GATEWAY] Received ${signal}, shutting down...`);
|
|
1555
|
+
this.completeAllSessionsSafe().then(() => this.stop()).then(() => this.sentinel.stop()).then(() => {
|
|
1556
|
+
if (process.env.NODE_ENV !== "test") process.exit(exitCode);
|
|
1557
|
+
}).catch((err) => {
|
|
1558
|
+
console.error(`[SENTINEL GATEWAY] Shutdown error:`, err);
|
|
1559
|
+
if (process.env.NODE_ENV !== "test") process.exit(1);
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
// -------------------------------------------------------------------------
|
|
1563
|
+
// Request handling
|
|
1564
|
+
// -------------------------------------------------------------------------
|
|
1565
|
+
handleRequest(req, res) {
|
|
1566
|
+
const url = req.url ?? "";
|
|
1567
|
+
const method = req.method ?? "";
|
|
1568
|
+
if (method === "GET") {
|
|
1569
|
+
if (url === "/api/sentinel/health") {
|
|
1570
|
+
const snap = this.telemetry.getSnapshot();
|
|
1571
|
+
this.sendJson(res, 200, {
|
|
1572
|
+
status: "running",
|
|
1573
|
+
version: GATEWAY_VERSION,
|
|
1574
|
+
uptime: snap.uptime_seconds
|
|
1575
|
+
});
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
if (url === "/api/sentinel/telemetry") {
|
|
1579
|
+
this.sendJson(res, 200, this.telemetry.getSnapshot());
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
if (method === "POST") {
|
|
1584
|
+
const match = url.match(
|
|
1585
|
+
/^\/api\/sentinel\/(pre-tool-use|post-tool-use|session-end|user-prompt-submit)\/(.+)$/
|
|
1586
|
+
);
|
|
1587
|
+
if (match) {
|
|
1588
|
+
const [, endpoint, agentType] = match;
|
|
1589
|
+
const translator = this.registry.get(agentType);
|
|
1590
|
+
if (!translator) {
|
|
1591
|
+
this.sendJson(res, 404, {
|
|
1592
|
+
error: `Unknown agent type: "${agentType}". No translator registered.`,
|
|
1593
|
+
registered: this.registry.listRegistered()
|
|
1594
|
+
});
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
this.readBody(req, res, (body) => {
|
|
1598
|
+
if (endpoint === "pre-tool-use") this.handlePreToolUse(body, res, translator);
|
|
1599
|
+
else if (endpoint === "post-tool-use") this.handlePostToolUse(body, res, translator);
|
|
1600
|
+
else if (endpoint === "user-prompt-submit")
|
|
1601
|
+
this.handleUserPromptSubmit(body, res, translator);
|
|
1602
|
+
else this.handleSessionEnd(body, res, translator);
|
|
1603
|
+
});
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
this.sendJson(res, 404, { error: "not found" });
|
|
1608
|
+
}
|
|
1609
|
+
// -------------------------------------------------------------------------
|
|
1610
|
+
// Endpoint handlers
|
|
1611
|
+
// -------------------------------------------------------------------------
|
|
1612
|
+
/**
|
|
1613
|
+
* Emit a workspace-mismatch refusal as a finding + telemetry. Originally
|
|
1614
|
+
* Approach A's foreign-refuse emitter; after the B5b-Phase-2 cutover this is
|
|
1615
|
+
* reused by the B-path (resolveBPathRouting) for the no-cwd / unresolvable-
|
|
1616
|
+
* workspace FAIL-CLOSED case — a request whose originating workspace cannot be
|
|
1617
|
+
* resolved is still refused (correct fail-closed), even though foreign-but-
|
|
1618
|
+
* resolvable workspaces are now SERVED.
|
|
1619
|
+
*
|
|
1620
|
+
* The finding's type is `workspace_mismatch`, which is NOT in
|
|
1621
|
+
* `ESCALATION_ELIGIBLE_TYPES` — so `getEffectiveBlockCount` / `maybeEscalate`
|
|
1622
|
+
* never count it, by design. This is the honest mechanism: a workspace
|
|
1623
|
+
* mismatch is a ROUTING error (request from a different workspace than this
|
|
1624
|
+
* daemon serves), not the served agent's misbehavior, so it denies the action
|
|
1625
|
+
* but does not move the served agent's escalation ladder. Escalation-
|
|
1626
|
+
* ineligibility is a consequence of what the finding IS, not a borrowed
|
|
1627
|
+
* ineligible type. (An earlier version typed this `unauthorized_target`, which
|
|
1628
|
+
* IS escalation-eligible; the audit-log-derived count then pulled foreign-
|
|
1629
|
+
* workspace refusals into the shared ladder and collaterally quarantined the
|
|
1630
|
+
* legitimate session — the bug this fix closes.)
|
|
1631
|
+
*
|
|
1632
|
+
* Emitted via logFinding() (not handleGatewayDeny()), matching the gateway's
|
|
1633
|
+
* other non-eligible findings — e.g. the L3 case C/D `bash_analysis` records,
|
|
1634
|
+
* which also use logFinding(). handleGatewayDeny() is reserved for the
|
|
1635
|
+
* escalation-eligible deny paths (L1/L2/etc.).
|
|
1636
|
+
*/
|
|
1637
|
+
async logWorkspaceMismatch(check, action, agentName, phase, routingId = this.agentId) {
|
|
1638
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
1639
|
+
const finding = {
|
|
1640
|
+
severity: "HIGH",
|
|
1641
|
+
kind: "actionable",
|
|
1642
|
+
type: "workspace_mismatch",
|
|
1643
|
+
agentId: routingId,
|
|
1644
|
+
agentName,
|
|
1645
|
+
description: `Workspace mismatch \u2014 ${check.reason}`,
|
|
1646
|
+
evidence: {
|
|
1647
|
+
action,
|
|
1648
|
+
target: check.requestCwd ?? "(no cwd)",
|
|
1649
|
+
timestamp: ts,
|
|
1650
|
+
baselineComparison: "workspace_mismatch"
|
|
1651
|
+
},
|
|
1652
|
+
recommendation: "This gateway serves a single workspace and has not loaded the originating workspace's policy. Start a session-local Sentinel gateway for that workspace.",
|
|
1653
|
+
timestamp: ts,
|
|
1654
|
+
decision: "deny"
|
|
1655
|
+
};
|
|
1656
|
+
await this.sentinel.logFinding(routingId, finding);
|
|
1657
|
+
this.telemetry.recordToolCall(action, phase, "blocked", 0);
|
|
1658
|
+
return finding;
|
|
1659
|
+
}
|
|
1660
|
+
/**
|
|
1661
|
+
* Approach B / B5a — resolve a request to its per-workspace routing identity.
|
|
1662
|
+
* Returns the derived agentId (used for BOTH routing and the event label) after
|
|
1663
|
+
* lazily ensuring the workspace's runner exists with its merged role. Returns
|
|
1664
|
+
* null and sends a fail-closed refusal when the workspace is unresolvable
|
|
1665
|
+
* (no/empty cwd) — reusing the workspace_mismatch finding type. Only called
|
|
1666
|
+
* when the isolation gate is ON.
|
|
1667
|
+
*/
|
|
1668
|
+
async resolveBPathRouting(cwd, action, agentName, phase) {
|
|
1669
|
+
const home = this.home || (await import("os")).homedir();
|
|
1670
|
+
const resolved = await resolveWorkspace(cwd, home);
|
|
1671
|
+
if (!resolved.ok) {
|
|
1672
|
+
const check = { active: true, allowed: false, reason: resolved.reason };
|
|
1673
|
+
const finding = await this.logWorkspaceMismatch(
|
|
1674
|
+
check,
|
|
1675
|
+
action,
|
|
1676
|
+
agentName,
|
|
1677
|
+
phase,
|
|
1678
|
+
this.agentId
|
|
1679
|
+
);
|
|
1680
|
+
return { ok: false, finding };
|
|
1681
|
+
}
|
|
1682
|
+
const { root, agentId, workspaceRole } = resolved.resolution;
|
|
1683
|
+
const ceiling = this.operatorCeiling;
|
|
1684
|
+
if (!ceiling) {
|
|
1685
|
+
const check = {
|
|
1686
|
+
active: true,
|
|
1687
|
+
allowed: false,
|
|
1688
|
+
reason: "workspace isolation enabled but no operator ceiling configured"
|
|
1689
|
+
};
|
|
1690
|
+
const finding = await this.logWorkspaceMismatch(
|
|
1691
|
+
check,
|
|
1692
|
+
action,
|
|
1693
|
+
agentName,
|
|
1694
|
+
phase,
|
|
1695
|
+
this.agentId
|
|
1696
|
+
);
|
|
1697
|
+
return { ok: false, finding };
|
|
1698
|
+
}
|
|
1699
|
+
const merged = effectiveRole(ceiling, workspaceRole);
|
|
1700
|
+
for (const w of merged.warnings) {
|
|
1701
|
+
console.warn(`[SENTINEL GATEWAY] policy-merge warning (${agentId}): ${w}`);
|
|
1702
|
+
}
|
|
1703
|
+
await this.sentinel.getOrCreateWorkspaceAgent(agentId, {
|
|
1704
|
+
workspaceRoot: root,
|
|
1705
|
+
role: merged.role,
|
|
1706
|
+
name: agentId
|
|
1707
|
+
});
|
|
1708
|
+
this.sentinel.touchAgent(agentId);
|
|
1709
|
+
return { ok: true, agentId };
|
|
1710
|
+
}
|
|
1711
|
+
async handlePreToolUse(body, res, translator) {
|
|
1712
|
+
let payload;
|
|
1713
|
+
try {
|
|
1714
|
+
payload = JSON.parse(body);
|
|
1715
|
+
} catch {
|
|
1716
|
+
this.sendJson(res, 400, { error: "invalid JSON" });
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
const event = translator.translatePreToolUse(payload);
|
|
1720
|
+
if (!event) {
|
|
1721
|
+
this.sendJson(res, 400, { error: "unable to translate payload" });
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
let routingId = this.agentId;
|
|
1725
|
+
if (this.workspaceIsolation) {
|
|
1726
|
+
const routed = await this.resolveBPathRouting(
|
|
1727
|
+
event.metadata?.cwd,
|
|
1728
|
+
event.action,
|
|
1729
|
+
event.agentName,
|
|
1730
|
+
"pre"
|
|
1731
|
+
);
|
|
1732
|
+
if (!routed.ok) {
|
|
1733
|
+
const response = translator.formatPreToolUseResponse({
|
|
1734
|
+
blocked: true,
|
|
1735
|
+
finding: routed.finding
|
|
1736
|
+
});
|
|
1737
|
+
this.sendJson(res, 200, response);
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
routingId = routed.agentId;
|
|
1741
|
+
event.agentId = routingId;
|
|
1742
|
+
}
|
|
1743
|
+
if (event.action === "command_exec" && event.targets && event.targets.length > 0) {
|
|
1744
|
+
const allL2Hits = [];
|
|
1745
|
+
for (const scanTarget of event.targets) {
|
|
1746
|
+
const scan = scanBashCommand(scanTarget, FORBIDDEN_BASENAMES);
|
|
1747
|
+
if (scan.matched) allL2Hits.push(...scan.hits);
|
|
1748
|
+
}
|
|
1749
|
+
if (allL2Hits.length > 0) {
|
|
1750
|
+
const finding = {
|
|
1751
|
+
severity: "HIGH",
|
|
1752
|
+
kind: "actionable",
|
|
1753
|
+
type: "unauthorized_target",
|
|
1754
|
+
agentId: event.agentId,
|
|
1755
|
+
agentName: event.agentName,
|
|
1756
|
+
description: `Bash command references forbidden basename: ${allL2Hits.join(", ")}`,
|
|
1757
|
+
evidence: {
|
|
1758
|
+
action: event.action,
|
|
1759
|
+
target: allL2Hits[0],
|
|
1760
|
+
timestamp: event.timestamp,
|
|
1761
|
+
baselineComparison: "credentials_exfil_attempt"
|
|
1762
|
+
},
|
|
1763
|
+
recommendation: "Review the command for credential access or exfiltration. If legitimate, use existing policy exception mechanisms.",
|
|
1764
|
+
timestamp: event.timestamp,
|
|
1765
|
+
decision: "deny"
|
|
1766
|
+
};
|
|
1767
|
+
await this.sentinel.handleGatewayDeny(routingId, finding);
|
|
1768
|
+
this.telemetry.recordToolCall(event.action, "pre", "blocked", 0);
|
|
1769
|
+
const response = translator.formatPreToolUseResponse({ blocked: true, finding });
|
|
1770
|
+
this.sendJson(res, 200, response);
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
const allTokenPaths = [];
|
|
1774
|
+
let anyUnparseable = false;
|
|
1775
|
+
let anyDangerousConstruct = false;
|
|
1776
|
+
for (const tokenTarget of event.targets) {
|
|
1777
|
+
const tr = tokenizePaths(tokenTarget);
|
|
1778
|
+
allTokenPaths.push(...tr.paths);
|
|
1779
|
+
if (tr.unparseable) anyUnparseable = true;
|
|
1780
|
+
if (tr.hasDangerousConstruct) anyDangerousConstruct = true;
|
|
1781
|
+
}
|
|
1782
|
+
let matchedPath = null;
|
|
1783
|
+
let matchedPattern = null;
|
|
1784
|
+
for (const tokenPath of allTokenPaths) {
|
|
1785
|
+
for (const pattern of this.forbiddenPatterns) {
|
|
1786
|
+
if (matchGlobInsensitive(pattern, tokenPath)) {
|
|
1787
|
+
matchedPath = tokenPath;
|
|
1788
|
+
matchedPattern = pattern;
|
|
1789
|
+
break;
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
if (matchedPath) break;
|
|
1793
|
+
}
|
|
1794
|
+
if (matchedPath) {
|
|
1795
|
+
const finding = {
|
|
1796
|
+
severity: "HIGH",
|
|
1797
|
+
kind: "actionable",
|
|
1798
|
+
type: "unauthorized_target",
|
|
1799
|
+
agentId: event.agentId,
|
|
1800
|
+
agentName: event.agentName,
|
|
1801
|
+
description: `Bash command contains path '${matchedPath}' matching forbidden pattern '${matchedPattern}'`,
|
|
1802
|
+
evidence: {
|
|
1803
|
+
action: event.action,
|
|
1804
|
+
target: matchedPath,
|
|
1805
|
+
timestamp: event.timestamp,
|
|
1806
|
+
baselineComparison: "forbidden_path_in_bash_command"
|
|
1807
|
+
},
|
|
1808
|
+
recommendation: "Review the command for credential or sensitive file access. If legitimate, use existing policy exception mechanisms.",
|
|
1809
|
+
timestamp: event.timestamp,
|
|
1810
|
+
decision: "deny"
|
|
1811
|
+
};
|
|
1812
|
+
await this.sentinel.handleGatewayDeny(routingId, finding);
|
|
1813
|
+
this.telemetry.recordToolCall(event.action, "pre", "blocked", 0);
|
|
1814
|
+
const response = translator.formatPreToolUseResponse({ blocked: true, finding });
|
|
1815
|
+
this.sendJson(res, 200, response);
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
const hasForbiddenIndicators = allL2Hits.length > 0 || matchedPath !== null;
|
|
1819
|
+
if (anyDangerousConstruct && hasForbiddenIndicators) {
|
|
1820
|
+
const finding = {
|
|
1821
|
+
severity: "HIGH",
|
|
1822
|
+
kind: "actionable",
|
|
1823
|
+
type: "unauthorized_target",
|
|
1824
|
+
agentId: event.agentId,
|
|
1825
|
+
agentName: event.agentName,
|
|
1826
|
+
description: `Bash command contains dangerous construct with forbidden indicators (${allL2Hits.join(", ") || matchedPath})`,
|
|
1827
|
+
evidence: {
|
|
1828
|
+
action: event.action,
|
|
1829
|
+
target: event.primaryTarget,
|
|
1830
|
+
timestamp: event.timestamp,
|
|
1831
|
+
baselineComparison: "bash_dangerous_construct_with_forbidden_indicators"
|
|
1832
|
+
},
|
|
1833
|
+
recommendation: "Command uses eval/sh -c/command substitution and references forbidden targets. Review for injection or exfiltration.",
|
|
1834
|
+
timestamp: event.timestamp,
|
|
1835
|
+
decision: "deny"
|
|
1836
|
+
};
|
|
1837
|
+
await this.sentinel.handleGatewayDeny(routingId, finding);
|
|
1838
|
+
this.telemetry.recordToolCall(event.action, "pre", "blocked", 0);
|
|
1839
|
+
const response = translator.formatPreToolUseResponse({ blocked: true, finding });
|
|
1840
|
+
this.sendJson(res, 200, response);
|
|
1841
|
+
return;
|
|
1842
|
+
}
|
|
1843
|
+
if (anyUnparseable && hasForbiddenIndicators) {
|
|
1844
|
+
const finding = {
|
|
1845
|
+
severity: "HIGH",
|
|
1846
|
+
kind: "actionable",
|
|
1847
|
+
type: "unauthorized_target",
|
|
1848
|
+
agentId: event.agentId,
|
|
1849
|
+
agentName: event.agentName,
|
|
1850
|
+
description: `Bash command is unparseable and references forbidden indicators (${allL2Hits.join(", ") || matchedPath})`,
|
|
1851
|
+
evidence: {
|
|
1852
|
+
action: event.action,
|
|
1853
|
+
target: event.primaryTarget,
|
|
1854
|
+
timestamp: event.timestamp,
|
|
1855
|
+
baselineComparison: "bash_unparseable_with_forbidden_indicators"
|
|
1856
|
+
},
|
|
1857
|
+
recommendation: "Command cannot be fully parsed and references forbidden targets. Review for obfuscation or injection.",
|
|
1858
|
+
timestamp: event.timestamp,
|
|
1859
|
+
decision: "deny"
|
|
1860
|
+
};
|
|
1861
|
+
await this.sentinel.handleGatewayDeny(routingId, finding);
|
|
1862
|
+
this.telemetry.recordToolCall(event.action, "pre", "blocked", 0);
|
|
1863
|
+
const response = translator.formatPreToolUseResponse({ blocked: true, finding });
|
|
1864
|
+
this.sendJson(res, 200, response);
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1867
|
+
if (anyDangerousConstruct && !hasForbiddenIndicators) {
|
|
1868
|
+
const findingC = {
|
|
1869
|
+
severity: "MEDIUM",
|
|
1870
|
+
kind: "informational",
|
|
1871
|
+
type: "bash_analysis",
|
|
1872
|
+
agentId: event.agentId,
|
|
1873
|
+
agentName: event.agentName,
|
|
1874
|
+
description: "Bash command uses dangerous construct (eval/sh -c/$(...)) without forbidden indicators",
|
|
1875
|
+
evidence: {
|
|
1876
|
+
action: event.action,
|
|
1877
|
+
target: event.primaryTarget,
|
|
1878
|
+
timestamp: event.timestamp,
|
|
1879
|
+
baselineComparison: "bash_dangerous_construct_no_indicators"
|
|
1880
|
+
},
|
|
1881
|
+
recommendation: "Review command for potential obfuscation or staged exfiltration.",
|
|
1882
|
+
timestamp: event.timestamp,
|
|
1883
|
+
decision: "allow"
|
|
1884
|
+
};
|
|
1885
|
+
await this.sentinel.logFinding(routingId, findingC);
|
|
1886
|
+
event.metadata = {
|
|
1887
|
+
...event.metadata,
|
|
1888
|
+
bashTokenPaths: allTokenPaths.join(","),
|
|
1889
|
+
bashUnparseable: String(anyUnparseable),
|
|
1890
|
+
bashHasDangerousConstruct: String(anyDangerousConstruct),
|
|
1891
|
+
bashFindingSeverity: "MEDIUM",
|
|
1892
|
+
bashFindingCategory: "bash_dangerous_construct_no_indicators"
|
|
1893
|
+
};
|
|
1894
|
+
} else if (anyUnparseable && !hasForbiddenIndicators) {
|
|
1895
|
+
const findingD = {
|
|
1896
|
+
severity: "MEDIUM",
|
|
1897
|
+
kind: "informational",
|
|
1898
|
+
type: "bash_analysis",
|
|
1899
|
+
agentId: event.agentId,
|
|
1900
|
+
agentName: event.agentName,
|
|
1901
|
+
description: "Bash command is unparseable (variable in path position) without forbidden indicators",
|
|
1902
|
+
evidence: {
|
|
1903
|
+
action: event.action,
|
|
1904
|
+
target: event.primaryTarget,
|
|
1905
|
+
timestamp: event.timestamp,
|
|
1906
|
+
baselineComparison: "bash_unparseable_no_indicators"
|
|
1907
|
+
},
|
|
1908
|
+
recommendation: "Review command for potential obfuscation or staged exfiltration.",
|
|
1909
|
+
timestamp: event.timestamp,
|
|
1910
|
+
decision: "allow"
|
|
1911
|
+
};
|
|
1912
|
+
await this.sentinel.logFinding(routingId, findingD);
|
|
1913
|
+
event.metadata = {
|
|
1914
|
+
...event.metadata,
|
|
1915
|
+
bashTokenPaths: allTokenPaths.join(","),
|
|
1916
|
+
bashUnparseable: String(anyUnparseable),
|
|
1917
|
+
bashHasDangerousConstruct: String(anyDangerousConstruct),
|
|
1918
|
+
bashFindingSeverity: "MEDIUM",
|
|
1919
|
+
bashFindingCategory: "bash_unparseable_no_indicators"
|
|
1920
|
+
};
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
const parsedPayload = payload;
|
|
1924
|
+
const ccToolName = parsedPayload.tool_name;
|
|
1925
|
+
if (ccToolName === "Grep") {
|
|
1926
|
+
const toolInput = parsedPayload.tool_input ?? {};
|
|
1927
|
+
const searchPath = typeof toolInput.path === "string" && toolInput.path.length > 0 ? toolInput.path : parsedPayload.cwd ?? ".";
|
|
1928
|
+
const pathCheck = isPathItselfForbidden(searchPath, this.forbiddenPatterns);
|
|
1929
|
+
if (pathCheck.forbidden) {
|
|
1930
|
+
const finding2 = {
|
|
1931
|
+
severity: "HIGH",
|
|
1932
|
+
kind: "actionable",
|
|
1933
|
+
type: "unauthorized_target",
|
|
1934
|
+
agentId: event.agentId,
|
|
1935
|
+
agentName: event.agentName,
|
|
1936
|
+
description: `Grep blocked: search path '${searchPath}' matches forbidden pattern ${pathCheck.matchedPatterns.join(", ")}`,
|
|
1937
|
+
evidence: {
|
|
1938
|
+
action: event.action,
|
|
1939
|
+
target: searchPath,
|
|
1940
|
+
timestamp: event.timestamp,
|
|
1941
|
+
baselineComparison: "grep_path_itself_forbidden"
|
|
1942
|
+
},
|
|
1943
|
+
recommendation: "To read forbidden file contents, use Read with explicit approval. Other paths can be grepped normally.",
|
|
1944
|
+
timestamp: event.timestamp,
|
|
1945
|
+
decision: "deny"
|
|
1946
|
+
};
|
|
1947
|
+
await this.sentinel.handleGatewayDeny(routingId, finding2);
|
|
1948
|
+
this.telemetry.recordToolCall(event.action, "pre", "blocked", 0);
|
|
1949
|
+
const response = translator.formatPreToolUseResponse({ blocked: true, finding: finding2 });
|
|
1950
|
+
this.sendJson(res, 200, response);
|
|
1951
|
+
return;
|
|
1952
|
+
}
|
|
1953
|
+
const exclusions = buildGrepExclusions(this.forbiddenPatterns);
|
|
1954
|
+
const updatedInput = buildModifiedGrepInput(toolInput, exclusions);
|
|
1955
|
+
const finding = {
|
|
1956
|
+
severity: "MEDIUM",
|
|
1957
|
+
kind: "informational",
|
|
1958
|
+
type: "unauthorized_target",
|
|
1959
|
+
agentId: event.agentId,
|
|
1960
|
+
agentName: event.agentName,
|
|
1961
|
+
description: `Grep search modified: injected exclusions for forbidden patterns on path '${searchPath}'`,
|
|
1962
|
+
evidence: {
|
|
1963
|
+
action: event.action,
|
|
1964
|
+
target: searchPath,
|
|
1965
|
+
timestamp: event.timestamp,
|
|
1966
|
+
baselineComparison: "grep_rewritten_for_exclusion"
|
|
1967
|
+
},
|
|
1968
|
+
recommendation: "Search was narrowed to exclude forbidden files. To search specific forbidden files, use Read with explicit approval.",
|
|
1969
|
+
timestamp: event.timestamp,
|
|
1970
|
+
decision: "modify",
|
|
1971
|
+
modification: { type: "append_args", args: exclusions }
|
|
1972
|
+
};
|
|
1973
|
+
await this.sentinel.logFinding(routingId, finding);
|
|
1974
|
+
this.telemetry.recordToolCall(event.action, "pre", "allowed", 0);
|
|
1975
|
+
const ccTranslator = translator;
|
|
1976
|
+
const excludedPatterns = this.forbiddenPatterns.join(", ");
|
|
1977
|
+
const additionalContext = `Sentinel: this Grep search was modified to exclude forbidden paths. Excluded patterns: ${excludedPatterns}. To search specific forbidden files, use Read with explicit approval.`;
|
|
1978
|
+
if (typeof ccTranslator.formatPreToolUseModifyResponse === "function") {
|
|
1979
|
+
const response = ccTranslator.formatPreToolUseModifyResponse({
|
|
1980
|
+
updatedInput,
|
|
1981
|
+
additionalContext
|
|
1982
|
+
});
|
|
1983
|
+
this.sendJson(res, 200, response);
|
|
1984
|
+
} else {
|
|
1985
|
+
this.sendJson(res, 200, {
|
|
1986
|
+
hookSpecificOutput: {
|
|
1987
|
+
hookEventName: "PreToolUse",
|
|
1988
|
+
permissionDecision: "allow",
|
|
1989
|
+
updatedInput,
|
|
1990
|
+
additionalContext
|
|
1991
|
+
}
|
|
1992
|
+
});
|
|
1993
|
+
}
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
if (ccToolName === "NotebookEdit" && event.targets && event.targets.length > 0) {
|
|
1997
|
+
const allL2Hits = [];
|
|
1998
|
+
for (let i = 0; i < event.targets.length; i++) {
|
|
1999
|
+
const scanTarget = event.targets[i];
|
|
2000
|
+
const isContentTarget = i >= 1;
|
|
2001
|
+
const scan = isContentTarget ? scanContentForForbiddenBasenames(scanTarget, FORBIDDEN_BASENAMES) : scanBashCommand(scanTarget, FORBIDDEN_BASENAMES);
|
|
2002
|
+
if (scan.matched) allL2Hits.push(...scan.hits);
|
|
2003
|
+
}
|
|
2004
|
+
if (allL2Hits.length > 0) {
|
|
2005
|
+
const finding = {
|
|
2006
|
+
severity: "HIGH",
|
|
2007
|
+
kind: "actionable",
|
|
2008
|
+
type: "unauthorized_target",
|
|
2009
|
+
agentId: event.agentId,
|
|
2010
|
+
agentName: event.agentName,
|
|
2011
|
+
description: `NotebookEdit cell contains forbidden basename: ${allL2Hits.join(", ")}`,
|
|
2012
|
+
evidence: {
|
|
2013
|
+
action: event.action,
|
|
2014
|
+
target: allL2Hits[0],
|
|
2015
|
+
timestamp: event.timestamp,
|
|
2016
|
+
baselineComparison: "credentials_exfil_attempt"
|
|
2017
|
+
},
|
|
2018
|
+
recommendation: "Review notebook cell source for credential access or exfiltration. If legitimate, use existing policy exception mechanisms.",
|
|
2019
|
+
timestamp: event.timestamp,
|
|
2020
|
+
decision: "deny"
|
|
2021
|
+
};
|
|
2022
|
+
await this.sentinel.handleGatewayDeny(routingId, finding);
|
|
2023
|
+
this.telemetry.recordToolCall(event.action, "pre", "blocked", 0);
|
|
2024
|
+
const response = translator.formatPreToolUseResponse({ blocked: true, finding });
|
|
2025
|
+
this.sendJson(res, 200, response);
|
|
2026
|
+
return;
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
if (ccToolName === "Glob") {
|
|
2030
|
+
const toolInput = parsedPayload.tool_input ?? {};
|
|
2031
|
+
const globPattern = typeof toolInput.pattern === "string" ? toolInput.pattern : "";
|
|
2032
|
+
if (globPattern.length > 0) {
|
|
2033
|
+
const scanResult = scanGlobPattern(globPattern, FORBIDDEN_BASENAMES);
|
|
2034
|
+
if (scanResult.matched) {
|
|
2035
|
+
const finding = {
|
|
2036
|
+
severity: "HIGH",
|
|
2037
|
+
kind: "actionable",
|
|
2038
|
+
type: "unauthorized_target",
|
|
2039
|
+
agentId: event.agentId,
|
|
2040
|
+
agentName: event.agentName,
|
|
2041
|
+
description: `Glob pattern references forbidden basename: ${scanResult.hits.join(", ")}`,
|
|
2042
|
+
evidence: {
|
|
2043
|
+
action: event.action,
|
|
2044
|
+
target: globPattern,
|
|
2045
|
+
timestamp: event.timestamp,
|
|
2046
|
+
baselineComparison: "glob_forbidden_pattern"
|
|
2047
|
+
},
|
|
2048
|
+
recommendation: "Glob patterns must not target forbidden file types. Use a more specific pattern that excludes sensitive paths.",
|
|
2049
|
+
timestamp: event.timestamp,
|
|
2050
|
+
decision: "deny"
|
|
2051
|
+
};
|
|
2052
|
+
await this.sentinel.handleGatewayDeny(routingId, finding);
|
|
2053
|
+
this.telemetry.recordToolCall(event.action, "pre", "blocked", 0);
|
|
2054
|
+
const response = translator.formatPreToolUseResponse({ blocked: true, finding });
|
|
2055
|
+
this.sendJson(res, 200, response);
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
const start = Date.now();
|
|
2061
|
+
try {
|
|
2062
|
+
const result = await this.sentinel.wrap(routingId, event, async () => void 0);
|
|
2063
|
+
const duration = Date.now() - start;
|
|
2064
|
+
this.telemetry.recordToolCall(
|
|
2065
|
+
event.action,
|
|
2066
|
+
"pre",
|
|
2067
|
+
result.blocked ? "blocked" : "allowed",
|
|
2068
|
+
duration
|
|
2069
|
+
);
|
|
2070
|
+
const response = translator.formatPreToolUseResponse(result);
|
|
2071
|
+
this.sendJson(res, 200, response);
|
|
2072
|
+
} catch (err) {
|
|
2073
|
+
console.error("[SENTINEL GATEWAY] wrap() error:", err);
|
|
2074
|
+
const response = translator.formatPreToolUseResponse({ blocked: false });
|
|
2075
|
+
this.sendJson(res, 200, response);
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
async handlePostToolUse(body, res, translator) {
|
|
2079
|
+
let payload;
|
|
2080
|
+
try {
|
|
2081
|
+
payload = JSON.parse(body);
|
|
2082
|
+
} catch {
|
|
2083
|
+
this.sendJson(res, 400, { error: "invalid JSON" });
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
2086
|
+
const event = translator.translatePostToolUse(payload);
|
|
2087
|
+
if (!event) {
|
|
2088
|
+
this.sendJson(res, 400, { error: "unable to translate payload" });
|
|
2089
|
+
return;
|
|
2090
|
+
}
|
|
2091
|
+
if (this.workspaceIsolation) {
|
|
2092
|
+
const routed = await this.resolveBPathRouting(
|
|
2093
|
+
event.metadata?.cwd,
|
|
2094
|
+
event.action,
|
|
2095
|
+
event.agentName,
|
|
2096
|
+
"post"
|
|
2097
|
+
);
|
|
2098
|
+
if (!routed.ok) {
|
|
2099
|
+
const response2 = translator.formatPostToolUseResponse();
|
|
2100
|
+
this.sendJson(res, 200, response2);
|
|
2101
|
+
return;
|
|
2102
|
+
}
|
|
2103
|
+
event.agentId = routed.agentId;
|
|
2104
|
+
}
|
|
2105
|
+
const start = Date.now();
|
|
2106
|
+
try {
|
|
2107
|
+
await this.sentinel.record(event);
|
|
2108
|
+
const duration = Date.now() - start;
|
|
2109
|
+
this.telemetry.recordToolCall(event.action, "post", "allowed", duration);
|
|
2110
|
+
} catch (err) {
|
|
2111
|
+
console.error("[SENTINEL GATEWAY] record() error:", err);
|
|
2112
|
+
}
|
|
2113
|
+
const response = translator.formatPostToolUseResponse();
|
|
2114
|
+
this.sendJson(res, 200, response);
|
|
2115
|
+
}
|
|
2116
|
+
async handleSessionEnd(body, res, translator) {
|
|
2117
|
+
let payload;
|
|
2118
|
+
try {
|
|
2119
|
+
payload = JSON.parse(body);
|
|
2120
|
+
} catch {
|
|
2121
|
+
this.sendJson(res, 400, { error: "invalid JSON" });
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
const sessionInfo = translator.translateSessionEnd(payload);
|
|
2125
|
+
let routingId = this.agentId;
|
|
2126
|
+
if (this.workspaceIsolation) {
|
|
2127
|
+
const routed = await this.resolveBPathRouting(
|
|
2128
|
+
sessionInfo?.cwd,
|
|
2129
|
+
"session_end",
|
|
2130
|
+
this.agentId,
|
|
2131
|
+
"session-end"
|
|
2132
|
+
);
|
|
2133
|
+
if (!routed.ok) {
|
|
2134
|
+
const response2 = translator.formatSessionEndResponse();
|
|
2135
|
+
this.sendJson(res, 200, response2);
|
|
2136
|
+
return;
|
|
2137
|
+
}
|
|
2138
|
+
routingId = routed.agentId;
|
|
2139
|
+
}
|
|
2140
|
+
const start = Date.now();
|
|
2141
|
+
await this.completeSessionSafe(routingId);
|
|
2142
|
+
const duration = Date.now() - start;
|
|
2143
|
+
this.telemetry.recordToolCall("session_end", "session-end", "allowed", duration);
|
|
2144
|
+
const response = translator.formatSessionEndResponse();
|
|
2145
|
+
this.sendJson(res, 200, response);
|
|
2146
|
+
}
|
|
2147
|
+
/**
|
|
2148
|
+
* UserPromptSubmit handler (Sprint 23 P1 — automatic per-prompt intent
|
|
2149
|
+
* capture). Fires once per prompt, BEFORE the turn's first PreToolUse (cc
|
|
2150
|
+
* blocks on hook completion), and the hook AWAITS this response — so by the
|
|
2151
|
+
* time cc proceeds to the first tool call, any declared intent is already
|
|
2152
|
+
* stored and the first action IS checked against it.
|
|
2153
|
+
*
|
|
2154
|
+
* The intent is declared ONLY when the prompt has a leading `INTENT:` line
|
|
2155
|
+
* (the opt-in convention). With no `INTENT:` line we no-op (sticky): the
|
|
2156
|
+
* prior active task is left as-is, so a multi-turn session declares once at
|
|
2157
|
+
* the top and holds it; a later `INTENT:` line supersedes via the existing
|
|
2158
|
+
* IntentTracker auto-end+replace. We do NOT feed the whole prompt as the
|
|
2159
|
+
* description (keyword extraction would be noisy).
|
|
2160
|
+
*
|
|
2161
|
+
* Declaration only. This endpoint never blocks a prompt and emits no
|
|
2162
|
+
* permission decision; it reuses the existing per-workspace routing and the
|
|
2163
|
+
* existing Sentinel.startTask. No block-ladder interaction whatsoever.
|
|
2164
|
+
*/
|
|
2165
|
+
async handleUserPromptSubmit(body, res, translator) {
|
|
2166
|
+
let payload;
|
|
2167
|
+
try {
|
|
2168
|
+
payload = JSON.parse(body);
|
|
2169
|
+
} catch {
|
|
2170
|
+
this.sendJson(res, 400, { error: "invalid JSON" });
|
|
2171
|
+
return;
|
|
2172
|
+
}
|
|
2173
|
+
const info = translator.translateUserPromptSubmit?.(payload) ?? null;
|
|
2174
|
+
if (!info) {
|
|
2175
|
+
this.sendJson(res, 200, { ok: true, intentDeclared: false });
|
|
2176
|
+
return;
|
|
2177
|
+
}
|
|
2178
|
+
const description = parseIntentLine(info.prompt);
|
|
2179
|
+
const scopePatterns = parseScopeLine(info.prompt);
|
|
2180
|
+
if (!description && !scopePatterns) {
|
|
2181
|
+
this.sendJson(res, 200, { ok: true, intentDeclared: false });
|
|
2182
|
+
return;
|
|
2183
|
+
}
|
|
2184
|
+
let routingId = this.agentId;
|
|
2185
|
+
if (this.workspaceIsolation) {
|
|
2186
|
+
const routed = await this.resolveBPathRouting(
|
|
2187
|
+
info.cwd,
|
|
2188
|
+
"tool_invocation",
|
|
2189
|
+
this.agentId,
|
|
2190
|
+
"pre"
|
|
2191
|
+
);
|
|
2192
|
+
if (!routed.ok) {
|
|
2193
|
+
this.sendJson(res, 200, { ok: true, intentDeclared: false });
|
|
2194
|
+
return;
|
|
2195
|
+
}
|
|
2196
|
+
routingId = routed.agentId;
|
|
2197
|
+
}
|
|
2198
|
+
const taskDescription = description ?? `scope: ${scopePatterns.join(" ")}`;
|
|
2199
|
+
const taskId = `prompt-${info.sessionId ?? "session"}-${Date.now()}`;
|
|
2200
|
+
const task = scopePatterns ? this.sentinel.startTask(routingId, taskId, taskDescription, { scopePatterns }) : this.sentinel.startTask(routingId, taskId, taskDescription);
|
|
2201
|
+
this.sendJson(res, 200, {
|
|
2202
|
+
ok: true,
|
|
2203
|
+
intentDeclared: task !== null && description !== null,
|
|
2204
|
+
scopeDeclared: task !== null && scopePatterns !== null,
|
|
2205
|
+
scopePatterns: scopePatterns ?? void 0,
|
|
2206
|
+
taskId
|
|
2207
|
+
});
|
|
2208
|
+
}
|
|
2209
|
+
// -------------------------------------------------------------------------
|
|
2210
|
+
// HTTP helpers (from webhookReceiver.ts patterns)
|
|
2211
|
+
// -------------------------------------------------------------------------
|
|
2212
|
+
readBody(req, res, onBody) {
|
|
2213
|
+
let size = 0;
|
|
2214
|
+
let exceeded = false;
|
|
2215
|
+
const chunks = [];
|
|
2216
|
+
req.on("data", (chunk) => {
|
|
2217
|
+
size += chunk.length;
|
|
2218
|
+
if (size > MAX_BODY_SIZE) {
|
|
2219
|
+
if (!exceeded) {
|
|
2220
|
+
exceeded = true;
|
|
2221
|
+
this.sendJson(res, 413, { error: "body too large" });
|
|
2222
|
+
req.destroy();
|
|
2223
|
+
}
|
|
2224
|
+
return;
|
|
2225
|
+
}
|
|
2226
|
+
chunks.push(chunk);
|
|
2227
|
+
});
|
|
2228
|
+
req.on("end", () => {
|
|
2229
|
+
if (exceeded) return;
|
|
2230
|
+
onBody(Buffer.concat(chunks).toString("utf-8"));
|
|
2231
|
+
});
|
|
2232
|
+
}
|
|
2233
|
+
sendJson(res, status, data) {
|
|
2234
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
2235
|
+
res.end(JSON.stringify(data));
|
|
2236
|
+
}
|
|
2237
|
+
};
|
|
2238
|
+
async function runGatewayDaemon({
|
|
2239
|
+
policyPath,
|
|
2240
|
+
port = DEFAULT_PORT
|
|
2241
|
+
}) {
|
|
2242
|
+
const { Sentinel: SentinelClass } = await import("./Sentinel-JLQL3YRD.js");
|
|
2243
|
+
const { writePidFile } = await import("./pidManager-ZYC7SICM.js");
|
|
2244
|
+
const { homedir } = await import("os");
|
|
2245
|
+
const { loadPolicy: loadPolicy2, policyToRole: policyToRole2 } = await import("./policyLoader-6KR5VFVV.js");
|
|
2246
|
+
const sentinel = await SentinelClass.fromPolicy(policyPath);
|
|
2247
|
+
const baseline = await sentinel.computeBaseline("claude-code");
|
|
2248
|
+
sentinel.setBaseline("claude-code", baseline);
|
|
2249
|
+
const operatorCeiling = policyToRole2(await loadPolicy2(policyPath));
|
|
2250
|
+
const gateway = new SentinelGateway({
|
|
2251
|
+
port,
|
|
2252
|
+
sentinel,
|
|
2253
|
+
// No translator passed — the constructor auto-wires the cc translator.
|
|
2254
|
+
agentId: "claude-code",
|
|
2255
|
+
workspaceIsolation: process.env.SENTINEL_WORKSPACE_ISOLATION !== "0",
|
|
2256
|
+
operatorCeiling,
|
|
2257
|
+
home: homedir()
|
|
2258
|
+
});
|
|
2259
|
+
await gateway.start();
|
|
2260
|
+
writePidFile(homedir(), process.pid);
|
|
2261
|
+
console.log(`[SENTINEL GATEWAY] PID ${process.pid} written`);
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
export {
|
|
2265
|
+
SentinelGateway,
|
|
2266
|
+
runGatewayDaemon
|
|
2267
|
+
};
|
|
2268
|
+
//# sourceMappingURL=chunk-Z3PWIJKT.js.map
|