@goodandready/dsh-approval-gate 0.1.17 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/lib/classifier.js +150 -20
- package/lib/contract-guard.js +147 -0
- package/lib/dispatcher.js +209 -207
- package/lib/engine.js +110 -17
- package/lib/index.js +42 -22
- package/lib/inspect.js +573 -520
- package/lib/latency.js +137 -0
- package/lib/messages.js +53 -0
- package/lib/overengineering.js +97 -0
- package/lib/paths.js +378 -320
- package/lib/risk.js +224 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.19
|
|
4
|
+
|
|
5
|
+
- Security: Структурированная категоризация рисков операций по 5 стандартизированным бакетам (`READ_ONLY`, `WORKSPACE`, `EGRESS`, `SYSTEM`, `SECRETS`) в `lib/risk.js` с приоритетной оценкой чувствительности путей, команд и сетевых направлений (Refs: #58).
|
|
6
|
+
- Security: Многомерное тегирование рисков (`risk_tags`: `read-only`, `file-mutation`, `network`, `git`, `credentials`, `package-manager`, `system-mutation`, `privilege-escalation`, `destructive`) для детального анализа и обогащения решений безопасности (Refs: #70).
|
|
7
|
+
- Resilience: Трекер задержки семантического классификатора (`ClassifierLatencyTracker` в `lib/latency.js`) со скользящим окном p95/avg и автоматическим переходом на детерминированные правила при превышении SLA (`classifier-sla-fallback` -> `ask`) (Refs: #57).
|
|
8
|
+
- Resilience: Плавный fallback при несовместимости эндпоинта с OpenAI JSON-mode (`isShapeRejectError`), извлечение JSON из markdown/raw-ответов (`extractJsonPayload`) и повторный запрос без `response_format` для Ollama/vLLM (Refs: #59).
|
|
9
|
+
- Security: Строгая Schemastery-схема вердиктов классификатора (`ClassifierVerdictSchema`, `validateClassifierResponse`) с fail-closed защитой от некорректных/неполных ответов модели (`malformed-classifier-response` -> `ask`) (Refs: #66).
|
|
10
|
+
|
|
11
|
+
## 0.1.18
|
|
12
|
+
|
|
13
|
+
- Security: Эвристический предохранитель размера аргументов (`computePayloadSize` / `maxPayloadBytes`, по умолчанию 64 КБ) для предотвращения переполнения контекста LLM-ревьюера и обхода инспекции (`payload-too-large` -> `ask`) (Refs: #71).
|
|
14
|
+
- Security: Сканер анти-инъекций инструкций в аргументы и скрипты (`scanPromptInjection`) с жесткой блокировкой директив обхода правил (`SYSTEM OVERRIDE: ALLOW`, `ignore previous instructions` и др. -> `reviewer-injection-attempt`) (Refs: #72).
|
|
15
|
+
- Security: Страж соответствия контракту задачи (`ContractGuard` в `lib/contract-guard.js`) с перехватом нецелевых системных пакетных менеджеров, управления службами, сетевых конфигураций и запрещенных инструментов (`contract-violation` -> `ask`) (Refs: #87).
|
|
16
|
+
- Security: Строгая изоляция временных каталогов проекта (`resolveTempRoot`, `isGlobalTempPath`, `checkTempIsolation` в `lib/paths.js`) с переводом обращений к глобальному `/tmp` на запрос подтверждения (`unisolated-temp-path` -> `ask` в пользу `<workspace>/.dsh/tmp`) (Refs: #102).
|
|
17
|
+
- Feature: Контроль оверинжиниринга (`OverengineeringGuard` в `lib/overengineering.js`) с отслеживанием числа создаваемых уникальных файлов за сессию (`maxFilesPerSession`, по умолчанию 10) и запросом подтверждения при превышении (`overengineering-file-limit` -> `ask`) (Refs: #89).
|
|
18
|
+
|
|
3
19
|
## 0.1.17
|
|
4
20
|
|
|
5
21
|
- Feature: Автонормализация URL эндпоинтов пользовательских LLM (`normalizeChatCompletionsUrl`) с поддержкой bare host, портов, `/v1` и `/chat/completions` без риска 404 (Refs: #127).
|
package/lib/classifier.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { redactCredentials, redactUrl } from "./redact.js";
|
|
6
6
|
import { CircuitBreaker } from "./breaker.js";
|
|
7
|
+
import Schema from "@deepseek-ai/schemastery";
|
|
7
8
|
|
|
8
9
|
// Additional regex patterns for sensitive tokens & API keys (#115)
|
|
9
10
|
const OPENAI_KEY_PATTERN = /\bsk-[a-zA-Z0-9_\-]{20,}\b/g;
|
|
@@ -244,7 +245,104 @@ export function isLowRiskExecution(execution) {
|
|
|
244
245
|
}
|
|
245
246
|
|
|
246
247
|
/**
|
|
247
|
-
*
|
|
248
|
+
* Checks whether an error is caused by endpoint rejection of JSON-mode / response_format (#59).
|
|
249
|
+
* Common in local LLM runtimes like Ollama, vLLM, and older endpoints.
|
|
250
|
+
*
|
|
251
|
+
* @param {any} err
|
|
252
|
+
* @returns {boolean}
|
|
253
|
+
*/
|
|
254
|
+
export function isShapeRejectError(err) {
|
|
255
|
+
if (!err) return false;
|
|
256
|
+
const status = err.status || (err.response && err.response.status);
|
|
257
|
+
if (status === 400 || status === 415 || status === 422) return true;
|
|
258
|
+
const msg = String(err.message || "").toLowerCase();
|
|
259
|
+
return (
|
|
260
|
+
msg.includes("response_format") ||
|
|
261
|
+
msg.includes("json_object") ||
|
|
262
|
+
msg.includes("unsupported parameter") ||
|
|
263
|
+
msg.includes("schema")
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Robust JSON extraction from raw strings, handling markdown code blocks and raw JSON (#59).
|
|
269
|
+
*
|
|
270
|
+
* @param {string} content
|
|
271
|
+
* @returns {any | null}
|
|
272
|
+
*/
|
|
273
|
+
export function extractJsonPayload(content) {
|
|
274
|
+
if (!content || typeof content !== "string") return null;
|
|
275
|
+
const trimmed = content.trim();
|
|
276
|
+
try {
|
|
277
|
+
return JSON.parse(trimmed);
|
|
278
|
+
} catch {
|
|
279
|
+
// Check markdown code block
|
|
280
|
+
const mdMatch = /```(?:json)?\s*([\s\S]*?)\s*```/i.exec(trimmed);
|
|
281
|
+
if (mdMatch && mdMatch[1]) {
|
|
282
|
+
try {
|
|
283
|
+
return JSON.parse(mdMatch[1].trim());
|
|
284
|
+
} catch {
|
|
285
|
+
// continue
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// Check bracketed JSON
|
|
289
|
+
const braceMatch = /\{[\s\S]*\}/.exec(trimmed);
|
|
290
|
+
if (braceMatch) {
|
|
291
|
+
try {
|
|
292
|
+
return JSON.parse(braceMatch[0]);
|
|
293
|
+
} catch {
|
|
294
|
+
// ignore
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Strict Schemastery schema for validating structured classifier decisions (#66).
|
|
303
|
+
*/
|
|
304
|
+
export const ClassifierVerdictSchema = Schema.object({
|
|
305
|
+
verdict: Schema.union(["allow", "deny", "ask"]).required(),
|
|
306
|
+
reason: Schema.string().default("classifier-evaluated"),
|
|
307
|
+
riskScore: Schema.number().default(0.5),
|
|
308
|
+
tags: Schema.array(Schema.string()).default([]),
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Validates and normalizes structured classifier response (#66).
|
|
313
|
+
*
|
|
314
|
+
* @param {any} parsed
|
|
315
|
+
* @returns {{ valid: boolean, verdict?: "allow" | "deny" | "ask", reason?: string, riskScore?: number, tags?: string[], error?: string }}
|
|
316
|
+
*/
|
|
317
|
+
export function validateClassifierResponse(parsed) {
|
|
318
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
319
|
+
return { valid: false, error: "Response is not a valid JSON object" };
|
|
320
|
+
}
|
|
321
|
+
const rawVerdict = String(parsed.verdict || "").toLowerCase().trim();
|
|
322
|
+
if (!["allow", "deny", "ask"].includes(rawVerdict)) {
|
|
323
|
+
return { valid: false, error: `Invalid verdict '${parsed.verdict}', expected allow|deny|ask` };
|
|
324
|
+
}
|
|
325
|
+
const reason = typeof parsed.reason === "string" && parsed.reason.trim()
|
|
326
|
+
? parsed.reason.trim()
|
|
327
|
+
: `classifier-${rawVerdict}`;
|
|
328
|
+
const riskScore = typeof parsed.riskScore === "number" && !isNaN(parsed.riskScore)
|
|
329
|
+
? Math.max(0, Math.min(1, parsed.riskScore))
|
|
330
|
+
: (rawVerdict === "deny" ? 1.0 : (rawVerdict === "ask" ? 0.6 : 0.1));
|
|
331
|
+
const tags = Array.isArray(parsed.tags)
|
|
332
|
+
? parsed.tags.filter((t) => typeof t === "string" && t.trim()).map((t) => t.trim())
|
|
333
|
+
: [];
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
valid: true,
|
|
337
|
+
verdict: rawVerdict,
|
|
338
|
+
reason,
|
|
339
|
+
riskScore,
|
|
340
|
+
tags,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Standalone resilient LLM Classifier client (#127, #129, #118, #115, #117, #59, #66).
|
|
248
346
|
*/
|
|
249
347
|
export class LlmClassifier {
|
|
250
348
|
/**
|
|
@@ -256,6 +354,7 @@ export class LlmClassifier {
|
|
|
256
354
|
* timeoutPolicy?: "strict" | "permissive",
|
|
257
355
|
* maxRetries?: number,
|
|
258
356
|
* maxContextChars?: number,
|
|
357
|
+
* supportsJsonMode?: boolean,
|
|
259
358
|
* fetch?: typeof globalThis.fetch
|
|
260
359
|
* }} [options]
|
|
261
360
|
*/
|
|
@@ -267,11 +366,12 @@ export class LlmClassifier {
|
|
|
267
366
|
this.timeoutPolicy = options.timeoutPolicy === "permissive" ? "permissive" : "strict";
|
|
268
367
|
this.maxRetries = typeof options.maxRetries === "number" ? options.maxRetries : 2;
|
|
269
368
|
this.maxContextChars = typeof options.maxContextChars === "number" ? options.maxContextChars : 1000;
|
|
369
|
+
this.supportsJsonMode = options.supportsJsonMode !== false;
|
|
270
370
|
this.fetch = options.fetch || globalThis.fetch;
|
|
271
371
|
}
|
|
272
372
|
|
|
273
373
|
/**
|
|
274
|
-
* Builds prompt messages with sanitized credentials and bounded context size (#115, #117).
|
|
374
|
+
* Builds prompt messages with sanitized credentials and bounded context size (#115, #117, #66).
|
|
275
375
|
*
|
|
276
376
|
* @param {any} execution
|
|
277
377
|
* @param {any} [context]
|
|
@@ -286,7 +386,7 @@ export class LlmClassifier {
|
|
|
286
386
|
role: "system",
|
|
287
387
|
content:
|
|
288
388
|
"You are a security approval gate classifier. Analyze the provided tool execution request. " +
|
|
289
|
-
"Respond strictly in valid JSON format: {\"verdict\": \"allow\"|\"deny\"|\"ask\", \"reason\": \"<short explanation>\"}.",
|
|
389
|
+
"Respond strictly in valid JSON format: {\"verdict\": \"allow\"|\"deny\"|\"ask\", \"reason\": \"<short explanation>\", \"riskScore\": <0.0-1.0>, \"tags\": [\"<tag>\"]}.",
|
|
290
390
|
},
|
|
291
391
|
{
|
|
292
392
|
role: "user",
|
|
@@ -296,11 +396,11 @@ export class LlmClassifier {
|
|
|
296
396
|
}
|
|
297
397
|
|
|
298
398
|
/**
|
|
299
|
-
* Classifies an execution request using the configured LLM endpoint (#118, #127, #129, #115, #117).
|
|
399
|
+
* Classifies an execution request using the configured LLM endpoint (#118, #127, #129, #115, #117, #59, #66).
|
|
300
400
|
*
|
|
301
401
|
* @param {any} execution
|
|
302
402
|
* @param {any} [context]
|
|
303
|
-
* @returns {Promise<{ verdict: "allow" | "deny" | "ask", reason: string, timeout?: boolean, error?: any }>}
|
|
403
|
+
* @returns {Promise<{ verdict: "allow" | "deny" | "ask", reason: string, riskScore?: number, tags?: string[], timeout?: boolean, error?: any }>}
|
|
304
404
|
*/
|
|
305
405
|
async classify(execution, context = {}) {
|
|
306
406
|
if (!this.endpoint || typeof this.fetch !== "function") {
|
|
@@ -308,15 +408,19 @@ export class LlmClassifier {
|
|
|
308
408
|
}
|
|
309
409
|
|
|
310
410
|
const messages = this.formatMessages(execution, context);
|
|
311
|
-
const body = JSON.stringify({
|
|
312
|
-
model: this.model,
|
|
313
|
-
messages,
|
|
314
|
-
temperature: 0.1,
|
|
315
|
-
response_format: { type: "json_object" },
|
|
316
|
-
});
|
|
317
411
|
|
|
318
|
-
|
|
319
|
-
const
|
|
412
|
+
const callApi = async (useJsonMode) => {
|
|
413
|
+
const payload = {
|
|
414
|
+
model: this.model,
|
|
415
|
+
messages,
|
|
416
|
+
temperature: 0.1,
|
|
417
|
+
};
|
|
418
|
+
if (useJsonMode) {
|
|
419
|
+
payload.response_format = { type: "json_object" };
|
|
420
|
+
}
|
|
421
|
+
const body = JSON.stringify(payload);
|
|
422
|
+
|
|
423
|
+
return withLlmRetry(
|
|
320
424
|
async () => {
|
|
321
425
|
const controller = new AbortController();
|
|
322
426
|
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
@@ -354,23 +458,49 @@ export class LlmClassifier {
|
|
|
354
458
|
retryOn: shouldRetryLlmError,
|
|
355
459
|
}
|
|
356
460
|
);
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
try {
|
|
464
|
+
let response;
|
|
465
|
+
if (this.supportsJsonMode) {
|
|
466
|
+
try {
|
|
467
|
+
response = await callApi(true);
|
|
468
|
+
} catch (err) {
|
|
469
|
+
if (isShapeRejectError(err)) {
|
|
470
|
+
this.supportsJsonMode = false;
|
|
471
|
+
response = await callApi(false);
|
|
472
|
+
} else {
|
|
473
|
+
throw err;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
} else {
|
|
477
|
+
response = await callApi(false);
|
|
478
|
+
}
|
|
357
479
|
|
|
358
480
|
const content = response?.choices?.[0]?.message?.content;
|
|
359
481
|
if (!content) {
|
|
360
482
|
return { verdict: "ask", reason: "classifier-empty-response" };
|
|
361
483
|
}
|
|
362
484
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
parsed = JSON.parse(content);
|
|
366
|
-
} catch {
|
|
485
|
+
const parsed = extractJsonPayload(content);
|
|
486
|
+
if (!parsed) {
|
|
367
487
|
return { verdict: "ask", reason: "classifier-invalid-json" };
|
|
368
488
|
}
|
|
369
489
|
|
|
370
|
-
const
|
|
490
|
+
const validated = validateClassifierResponse(parsed);
|
|
491
|
+
if (!validated.valid) {
|
|
492
|
+
return {
|
|
493
|
+
verdict: "ask",
|
|
494
|
+
reason: "malformed-classifier-response",
|
|
495
|
+
detail: validated.error,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
371
499
|
return {
|
|
372
|
-
verdict,
|
|
373
|
-
reason:
|
|
500
|
+
verdict: validated.verdict,
|
|
501
|
+
reason: validated.reason,
|
|
502
|
+
riskScore: validated.riskScore,
|
|
503
|
+
tags: validated.tags,
|
|
374
504
|
};
|
|
375
505
|
} catch (err) {
|
|
376
506
|
const isTimeout = CircuitBreaker.isFaultOrTimeout(err) || Boolean(err?.isTimeout);
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// lib/contract-guard.js
|
|
2
|
+
// Enforces task contract boundaries and blocks out-of-scope system mutations (#87).
|
|
3
|
+
|
|
4
|
+
const SYSTEM_PACKAGE_MANAGERS = new Set([
|
|
5
|
+
"apt",
|
|
6
|
+
"apt-get",
|
|
7
|
+
"dpkg",
|
|
8
|
+
"yum",
|
|
9
|
+
"dnf",
|
|
10
|
+
"pacman",
|
|
11
|
+
"zypper",
|
|
12
|
+
"brew",
|
|
13
|
+
"snap",
|
|
14
|
+
"flatpak",
|
|
15
|
+
"apk",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const SYSTEM_SERVICE_COMMANDS = new Set([
|
|
19
|
+
"systemctl",
|
|
20
|
+
"service",
|
|
21
|
+
"init",
|
|
22
|
+
"reboot",
|
|
23
|
+
"shutdown",
|
|
24
|
+
"poweroff",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const SYSTEM_NETWORKING_COMMANDS = new Set([
|
|
28
|
+
"iptables",
|
|
29
|
+
"ufw",
|
|
30
|
+
"firewalld",
|
|
31
|
+
"ifconfig",
|
|
32
|
+
"netplan",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const SYSTEM_CONFIG_TARGETS = [
|
|
36
|
+
"/etc/passwd",
|
|
37
|
+
"/etc/shadow",
|
|
38
|
+
"/etc/sudoers",
|
|
39
|
+
"/etc/fstab",
|
|
40
|
+
"/etc/hosts",
|
|
41
|
+
"/etc/pam.d",
|
|
42
|
+
"/etc/profile",
|
|
43
|
+
"/etc/bash.bashrc",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Validates whether a command or tool execution stays within task contract boundaries (#87).
|
|
48
|
+
*/
|
|
49
|
+
export class ContractGuard {
|
|
50
|
+
/**
|
|
51
|
+
* @param {{
|
|
52
|
+
* allowSystemMutations?: boolean,
|
|
53
|
+
* allowedTools?: string[],
|
|
54
|
+
* allowedCommands?: string[],
|
|
55
|
+
* forbiddenCommands?: string[]
|
|
56
|
+
* }} [contract]
|
|
57
|
+
*/
|
|
58
|
+
constructor(contract = {}) {
|
|
59
|
+
this.allowSystemMutations = contract.allowSystemMutations === true;
|
|
60
|
+
this.allowedTools = Array.isArray(contract.allowedTools) ? new Set(contract.allowedTools) : null;
|
|
61
|
+
this.allowedCommands = Array.isArray(contract.allowedCommands) ? new Set(contract.allowedCommands) : null;
|
|
62
|
+
this.forbiddenCommands = Array.isArray(contract.forbiddenCommands) ? new Set(contract.forbiddenCommands) : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Checks if an execution violates the task contract boundaries.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} toolName
|
|
69
|
+
* @param {string} [command]
|
|
70
|
+
* @param {any} [execution]
|
|
71
|
+
* @returns {{ allowed: boolean, reason?: string, detail?: string, snippet?: string }}
|
|
72
|
+
*/
|
|
73
|
+
check(toolName, command, execution) {
|
|
74
|
+
// 1. Tool-level restriction
|
|
75
|
+
if (this.allowedTools && toolName && !this.allowedTools.has(toolName)) {
|
|
76
|
+
return {
|
|
77
|
+
allowed: false,
|
|
78
|
+
reason: "contract-violation",
|
|
79
|
+
detail: `Tool '${toolName}' is not permitted by task contract`,
|
|
80
|
+
snippet: toolName,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!command || typeof command !== "string") {
|
|
85
|
+
return { allowed: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const trimmed = command.trim();
|
|
89
|
+
if (!trimmed) return { allowed: true };
|
|
90
|
+
|
|
91
|
+
const firstWord = trimmed.split(/\s+/)[0].replace(/^sudo\s+/, "");
|
|
92
|
+
|
|
93
|
+
// 2. Explicit forbidden command list
|
|
94
|
+
if (this.forbiddenCommands && this.forbiddenCommands.has(firstWord)) {
|
|
95
|
+
return {
|
|
96
|
+
allowed: false,
|
|
97
|
+
reason: "contract-violation",
|
|
98
|
+
detail: `Command '${firstWord}' is explicitly forbidden by task contract`,
|
|
99
|
+
snippet: firstWord,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 3. System mutations guard when allowSystemMutations is false
|
|
104
|
+
if (!this.allowSystemMutations) {
|
|
105
|
+
if (SYSTEM_PACKAGE_MANAGERS.has(firstWord)) {
|
|
106
|
+
return {
|
|
107
|
+
allowed: false,
|
|
108
|
+
reason: "contract-violation",
|
|
109
|
+
detail: `Out-of-scope system package manager invocation: ${firstWord}`,
|
|
110
|
+
snippet: trimmed,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (SYSTEM_SERVICE_COMMANDS.has(firstWord)) {
|
|
115
|
+
return {
|
|
116
|
+
allowed: false,
|
|
117
|
+
reason: "contract-violation",
|
|
118
|
+
detail: `Out-of-scope system service or reboot control: ${firstWord}`,
|
|
119
|
+
snippet: trimmed,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (SYSTEM_NETWORKING_COMMANDS.has(firstWord)) {
|
|
124
|
+
return {
|
|
125
|
+
allowed: false,
|
|
126
|
+
reason: "contract-violation",
|
|
127
|
+
detail: `Out-of-scope network configuration command: ${firstWord}`,
|
|
128
|
+
snippet: trimmed,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Check system configuration mutation redirects
|
|
133
|
+
for (const target of SYSTEM_CONFIG_TARGETS) {
|
|
134
|
+
if (trimmed.includes(target) && /[><]/.test(trimmed)) {
|
|
135
|
+
return {
|
|
136
|
+
allowed: false,
|
|
137
|
+
reason: "contract-violation",
|
|
138
|
+
detail: `Out-of-scope system configuration file write: ${target}`,
|
|
139
|
+
snippet: trimmed,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { allowed: true };
|
|
146
|
+
}
|
|
147
|
+
}
|