@goodandready/dsh-approval-gate 0.1.21 → 0.1.22
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 +8 -0
- package/lib/audit-log.js +348 -345
- package/lib/engine.js +48 -35
- package/lib/escalation.js +151 -0
- package/lib/guidance.js +119 -0
- package/lib/index.js +44 -2
- package/lib/lineage.js +182 -0
- package/lib/measure-kinds.js +3 -0
- package/lib/messages.js +27 -0
- package/package.json +55 -55
package/lib/engine.js
CHANGED
|
@@ -22,6 +22,8 @@ import { VersionGuard } from "./version-guard.js";
|
|
|
22
22
|
import { loadAgentsRules, evaluateAgentsRules } from "./agents-parser.js";
|
|
23
23
|
import { classifyMeasureKind } from "./measure-kinds.js";
|
|
24
24
|
import { SnapshotManager } from "./snapshots.js";
|
|
25
|
+
import { LineageManager } from "./lineage.js";
|
|
26
|
+
import { isRedundantSandboxRequest, buildRedundantSandboxRemediation, checkEscalationJustification } from "./escalation.js";
|
|
25
27
|
|
|
26
28
|
/**
|
|
27
29
|
* Determine whether an operation belongs to a critical risk category requiring lockedAsk (#52).
|
|
@@ -99,6 +101,9 @@ export class SecurityEngine {
|
|
|
99
101
|
this.versionGuard = options.versionGuard !== undefined ? options.versionGuard : new VersionGuard({ workspaceDir: this.inspectConfig?.workspaceDir });
|
|
100
102
|
this.agentsRules = options.agentsRules !== undefined ? options.agentsRules : (loadAgentsRules(this.inspectConfig?.workspaceDir)?.rules || null);
|
|
101
103
|
this.snapshotManager = options.snapshotManager !== undefined ? options.snapshotManager : new SnapshotManager({ workspaceDir: this.inspectConfig?.workspaceDir });
|
|
104
|
+
this.lineage = options.lineage !== undefined ? options.lineage : new LineageManager();
|
|
105
|
+
this.enableRedundantSandboxRecovery = options.enableRedundantSandboxRecovery !== false;
|
|
106
|
+
this.requireJustification = options.requireJustification === true;
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
/**
|
|
@@ -432,7 +437,34 @@ export class SecurityEngine {
|
|
|
432
437
|
return res;
|
|
433
438
|
}
|
|
434
439
|
|
|
435
|
-
//
|
|
440
|
+
// Redundant Sandbox Recovery (#101)
|
|
441
|
+
if (this.enableRedundantSandboxRecovery && isRedundantSandboxRequest(command, context?.sandboxMode || context?.preset, { toolName, args: execution?.arguments })) {
|
|
442
|
+
const advice = buildRedundantSandboxRemediation(command);
|
|
443
|
+
const hit = { ask: true, reason: "redundant-sandbox-privilege", detail: advice, snippet: command };
|
|
444
|
+
const res = { verdict: "ask", stage: "P4", reason: "redundant-sandbox-privilege", detail: advice, redundantSandbox: true, hit };
|
|
445
|
+
this._applyLockedAskIfCritical(res, toolName, command, execution);
|
|
446
|
+
return res;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// P2: Static Rules / Safe Allow (Bands Engine)
|
|
450
|
+
if (bandVerdict === "allow" && !this.requireJustification) {
|
|
451
|
+
this.breaker.resetConsecutive(sessionId);
|
|
452
|
+
return { verdict: "allow", stage: "P2", reason: "safe-allow", hit };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Escalation Justification Protocol (#107)
|
|
456
|
+
const just = checkEscalationJustification(command, toolName, execution?.arguments, {
|
|
457
|
+
context,
|
|
458
|
+
justification: context?.justification || execution?.justification || execution?.arguments?.justification,
|
|
459
|
+
requireJustification: this.requireJustification,
|
|
460
|
+
});
|
|
461
|
+
if (just.required && !just.valid) {
|
|
462
|
+
const hit = { ask: true, reason: just.reason, detail: just.detail, snippet: command || toolName };
|
|
463
|
+
const res = { verdict: "ask", stage: "P4", reason: just.reason, detail: just.detail, justificationAdvice: just.advice, hit };
|
|
464
|
+
this._applyLockedAskIfCritical(res, toolName, command, execution);
|
|
465
|
+
return res;
|
|
466
|
+
}
|
|
467
|
+
|
|
436
468
|
if (bandVerdict === "allow") {
|
|
437
469
|
this.breaker.resetConsecutive(sessionId);
|
|
438
470
|
return { verdict: "allow", stage: "P2", reason: "safe-allow", hit };
|
|
@@ -504,40 +536,13 @@ export class SecurityEngine {
|
|
|
504
536
|
}
|
|
505
537
|
}
|
|
506
538
|
|
|
507
|
-
addGrant(options) {
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
getGrants(sessionId) {
|
|
515
|
-
if (this.grantRegistry && typeof this.grantRegistry.getGrants === "function") {
|
|
516
|
-
return this.grantRegistry.getGrants(sessionId);
|
|
517
|
-
}
|
|
518
|
-
return [];
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
getLedgerSummary(sessionId) {
|
|
522
|
-
return this.ledger ? this.ledger.getSummary(sessionId) : null;
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
isRetryExhausted(sessionId) {
|
|
526
|
-
return this.retryGuard ? this.retryGuard.isExhausted(sessionId) : false;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
clearCache() {
|
|
530
|
-
if (this.cache) this.cache.clear();
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
getCounters() {
|
|
534
|
-
return this.counters ? this.counters.getSnapshot() : null;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
resetCounters() {
|
|
538
|
-
if (this.counters) this.counters.reset();
|
|
539
|
-
}
|
|
540
|
-
|
|
539
|
+
addGrant(options) { return this.grantRegistry?.addGrant ? this.grantRegistry.addGrant(options) : null; }
|
|
540
|
+
getGrants(sessionId) { return this.grantRegistry?.getGrants ? this.grantRegistry.getGrants(sessionId) : []; }
|
|
541
|
+
getLedgerSummary(sessionId) { return this.ledger ? this.ledger.getSummary(sessionId) : null; }
|
|
542
|
+
isRetryExhausted(sessionId) { return this.retryGuard ? this.retryGuard.isExhausted(sessionId) : false; }
|
|
543
|
+
clearCache() { if (this.cache) this.cache.clear(); }
|
|
544
|
+
getCounters() { return this.counters ? this.counters.getSnapshot() : null; }
|
|
545
|
+
resetCounters() { if (this.counters) this.counters.reset(); }
|
|
541
546
|
resume(sessionId, reason) {
|
|
542
547
|
if (this.retryGuard) this.retryGuard.reset(sessionId);
|
|
543
548
|
if (this.overengineeringGuard) this.overengineeringGuard.reset(sessionId);
|
|
@@ -558,6 +563,12 @@ export class SecurityEngine {
|
|
|
558
563
|
if (command && isRmCommand(command)) {
|
|
559
564
|
decision.trashAlternative = suggestTrashAlternative(command, this.inspectConfig?.workspaceDir, context?.sessionId);
|
|
560
565
|
}
|
|
566
|
+
const callId = context?.callId || execution?.callId || execution?.id || context?.execution?.id || null;
|
|
567
|
+
const agentId = context?.agentId || context?.agent?.id || context?.session?.agentId || execution?.agentId || null;
|
|
568
|
+
const subagentId = context?.subagentId || context?.session?.subagentId || execution?.subagentId || null;
|
|
569
|
+
if (callId) decision.callId = callId;
|
|
570
|
+
if (agentId) decision.agentId = agentId;
|
|
571
|
+
if (subagentId) decision.subagentId = subagentId;
|
|
561
572
|
return decision;
|
|
562
573
|
}
|
|
563
574
|
|
|
@@ -570,6 +581,8 @@ export class SecurityEngine {
|
|
|
570
581
|
getSnapshots(sessionId) { return this.snapshotManager ? this.snapshotManager.getSnapshots(sessionId) : []; }
|
|
571
582
|
clearSnapshots(sessionId) { return this.snapshotManager ? this.snapshotManager.clearSnapshots(sessionId) : { cleared: 0 }; }
|
|
572
583
|
clearAllSnapshots() { return this.snapshotManager ? this.snapshotManager.clearAllSnapshots() : { cleared: 0 }; }
|
|
584
|
+
getLineage(sessionId) { return this.lineage ? this.lineage.getLineage(sessionId) : [sessionId]; }
|
|
585
|
+
registerLineage(child, parent, root) { return this.lineage ? this.lineage.registerLineage(child, parent, root) : false; }
|
|
573
586
|
}
|
|
574
587
|
|
|
575
588
|
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// lib/escalation.js
|
|
2
|
+
// Redundant sandbox recovery (#101) and privilege escalation justification (#107).
|
|
3
|
+
// Prevents unnecessary danger-full-access requests and enforces accountable escalation justifications.
|
|
4
|
+
|
|
5
|
+
import { isSystemLevelExecution } from "./measure-kinds.js";
|
|
6
|
+
|
|
7
|
+
export const REDUNDANT_SANDBOX_REASON = "redundant-sandbox-privilege";
|
|
8
|
+
export const MISSING_JUSTIFICATION_REASON = "missing-escalation-justification";
|
|
9
|
+
export const INSUFFICIENT_JUSTIFICATION_REASON = "insufficient-escalation-justification";
|
|
10
|
+
|
|
11
|
+
const PLACEHOLDER_JUSTIFICATIONS = new Set([
|
|
12
|
+
"none",
|
|
13
|
+
"n/a",
|
|
14
|
+
"na",
|
|
15
|
+
"test",
|
|
16
|
+
"testing",
|
|
17
|
+
"sudo",
|
|
18
|
+
"fix",
|
|
19
|
+
"run",
|
|
20
|
+
"escalate",
|
|
21
|
+
"please",
|
|
22
|
+
"bypass",
|
|
23
|
+
"temp",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const INTERNAL_SAFE_COMMAND_REGEX = /^(?:git\s+(?:status|diff|log|branch|show)|npm\s+(?:test|run\s+test|run\s+lint)|pnpm\s+(?:test|run\s+test)|node\s+[\w\-./]+\.m?[jt]s|cat\s+|head\s+|tail\s+|grep\s+|find\s+|ls\b|pwd\b|echo\b)/i;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Checks whether an elevated sandbox request is redundant for a safe internal command (#101).
|
|
30
|
+
*
|
|
31
|
+
* @param {string} [command]
|
|
32
|
+
* @param {string} [requestedMode]
|
|
33
|
+
* @param {{ toolName?: string, args?: any }} [options]
|
|
34
|
+
* @returns {boolean}
|
|
35
|
+
*/
|
|
36
|
+
export function isRedundantSandboxRequest(command, requestedMode, options = {}) {
|
|
37
|
+
const mode = String(requestedMode || options.sandboxMode || "").toLowerCase();
|
|
38
|
+
const isElevated = mode === "danger-full-access" || mode === "full-access" || mode === "danger" || mode === "unrestricted";
|
|
39
|
+
if (!isElevated) return false;
|
|
40
|
+
|
|
41
|
+
const cmd = String(command || "").trim();
|
|
42
|
+
if (!cmd) return false;
|
|
43
|
+
|
|
44
|
+
// If command is system-level, escalation might be genuine (not redundant)
|
|
45
|
+
if (isSystemLevelExecution(cmd, options.toolName, options.args)) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// If command contains explicit dangerous tokens, it's not a redundant request
|
|
50
|
+
if (/\b(?:sudo|docker|systemctl|service|apt|apt-get|apk|dnf|pacman|chown|chmod\s+-R)\b/i.test(cmd)) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Safe internal commands: reading, status, unit testing
|
|
55
|
+
return INTERNAL_SAFE_COMMAND_REGEX.test(cmd);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Builds actionable remediation advice for redundant sandbox requests (#101).
|
|
60
|
+
*
|
|
61
|
+
* @param {string} command
|
|
62
|
+
* @param {"en" | "ru" | "zh"} [lang="en"]
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
export function buildRedundantSandboxRemediation(command, lang = "en") {
|
|
66
|
+
const cmd = String(command || "").trim();
|
|
67
|
+
if (lang === "ru") {
|
|
68
|
+
return `Избыточный запрос привилегий песочницы: команда '${cmd}' является безопасной внутрипроектной операцией. Повторите вызов в стандартном режиме песочницы без 'danger-full-access'.`;
|
|
69
|
+
}
|
|
70
|
+
if (lang === "zh") {
|
|
71
|
+
return `请求了冗余的沙箱提权:命令 '${cmd}' 为工作区内部安全操作。请以普通沙箱模式重新执行,无需 'danger-full-access'。`;
|
|
72
|
+
}
|
|
73
|
+
return `Redundant sandbox elevation requested: command '${cmd}' is workspace-internal and safe. Repeat the command in standard sandbox mode without 'danger-full-access'.`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Checks whether privilege escalation requires justification and whether provided justification is valid (#107).
|
|
78
|
+
*
|
|
79
|
+
* @param {string} [command]
|
|
80
|
+
* @param {string} [toolName]
|
|
81
|
+
* @param {any} [args]
|
|
82
|
+
* @param {{
|
|
83
|
+
* justification?: string,
|
|
84
|
+
* context?: any,
|
|
85
|
+
* requireJustification?: boolean,
|
|
86
|
+
* lang?: "en" | "ru" | "zh"
|
|
87
|
+
* }} [options]
|
|
88
|
+
* @returns {{
|
|
89
|
+
* required: boolean,
|
|
90
|
+
* valid: boolean,
|
|
91
|
+
* reason?: string,
|
|
92
|
+
* detail?: string,
|
|
93
|
+
* advice?: string
|
|
94
|
+
* }}
|
|
95
|
+
*/
|
|
96
|
+
export function checkEscalationJustification(command, toolName, args, options = {}) {
|
|
97
|
+
const cmd = String(command || "").trim();
|
|
98
|
+
const execObj = { name: toolName || "bash", arguments: { ...(args || {}), command: cmd } };
|
|
99
|
+
const isSystem = isSystemLevelExecution(execObj, options.context);
|
|
100
|
+
const explicitRequire = options.requireJustification === true || options.context?.requireJustification === true;
|
|
101
|
+
const isEscalatedSandbox = Boolean(options.context?.sandboxMode && options.context.sandboxMode !== "sandboxed");
|
|
102
|
+
|
|
103
|
+
// Escalation justification is required when explicitly required OR when escalated sandbox mode is requested for system operations
|
|
104
|
+
const escalationRequired = explicitRequire || (isEscalatedSandbox && isSystem);
|
|
105
|
+
if (!escalationRequired) {
|
|
106
|
+
return { required: false, valid: true };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const rawJustification =
|
|
110
|
+
(typeof options.justification === "string" && options.justification) ||
|
|
111
|
+
(typeof args?.justification === "string" && args.justification) ||
|
|
112
|
+
(typeof options.context?.justification === "string" && options.context.justification) ||
|
|
113
|
+
"";
|
|
114
|
+
|
|
115
|
+
const justification = rawJustification.trim();
|
|
116
|
+
const lang = options.lang || "en";
|
|
117
|
+
|
|
118
|
+
if (!justification) {
|
|
119
|
+
return {
|
|
120
|
+
required: true,
|
|
121
|
+
valid: false,
|
|
122
|
+
reason: MISSING_JUSTIFICATION_REASON,
|
|
123
|
+
detail: `Privilege escalation for '${cmd || toolName}' requires an explicit justification.`,
|
|
124
|
+
advice: lang === "ru"
|
|
125
|
+
? "Укажите поле 'justification' с объяснением необходимости системных привилегий."
|
|
126
|
+
: lang === "zh"
|
|
127
|
+
? "执行系统级操作必须提供 'justification' 理由说明。"
|
|
128
|
+
: "Provide a 'justification' field explaining why elevated privileges are necessary.",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (justification.length < 10 || PLACEHOLDER_JUSTIFICATIONS.has(justification.toLowerCase())) {
|
|
133
|
+
return {
|
|
134
|
+
required: true,
|
|
135
|
+
valid: false,
|
|
136
|
+
reason: INSUFFICIENT_JUSTIFICATION_REASON,
|
|
137
|
+
detail: `Justification '${justification}' is too short or generic (minimum 10 non-placeholder characters required).`,
|
|
138
|
+
advice: lang === "ru"
|
|
139
|
+
? "Обоснование слишком короткое или шаблонное. Опишите конкретную причину эскалации."
|
|
140
|
+
: lang === "zh"
|
|
141
|
+
? "理由说明过短或为占位符。请详细描述提权的实际原因(至少10个字符)。"
|
|
142
|
+
: "Justification is too brief or placeholder. Describe the specific reason for privilege escalation.",
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
required: true,
|
|
148
|
+
valid: true,
|
|
149
|
+
justification,
|
|
150
|
+
};
|
|
151
|
+
}
|
package/lib/guidance.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// lib/guidance.js
|
|
2
|
+
// Dynamic security guidance for system prompts (AUTO_MODE_AGENT_GUIDANCE) (#99).
|
|
3
|
+
// Injects structured execution expectations and gate constraints directly into agent context.
|
|
4
|
+
|
|
5
|
+
export const GUIDANCE_MARKER = "<!-- dsh-approval-gate:guidance -->";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generates structured agent safety guidance.
|
|
9
|
+
*
|
|
10
|
+
* @param {{
|
|
11
|
+
* language?: "en" | "ru" | "zh",
|
|
12
|
+
* failClosed?: boolean,
|
|
13
|
+
* trashEnabled?: boolean,
|
|
14
|
+
* snapshotsEnabled?: boolean,
|
|
15
|
+
* justificationRequired?: boolean,
|
|
16
|
+
* redundantSandboxRecovery?: boolean,
|
|
17
|
+
* workspaceDir?: string
|
|
18
|
+
* }} [options]
|
|
19
|
+
* @returns {string}
|
|
20
|
+
*/
|
|
21
|
+
export function generateAgentGuidance(options = {}) {
|
|
22
|
+
const lang = options.language || "en";
|
|
23
|
+
const failClosed = options.failClosed !== false;
|
|
24
|
+
const trash = options.trashEnabled !== false;
|
|
25
|
+
const snapshots = options.snapshotsEnabled !== false;
|
|
26
|
+
const justification = options.justificationRequired !== false;
|
|
27
|
+
|
|
28
|
+
if (lang === "ru") {
|
|
29
|
+
return [
|
|
30
|
+
GUIDANCE_MARKER,
|
|
31
|
+
"### Инструкции безопасности среды выполнения (dsh-approval-gate)",
|
|
32
|
+
failClosed
|
|
33
|
+
? "- **Режим выполнения**: Включен строгий контроль (fail-closed). Нераспознанные шелл-команды требуют ручного подтверждения."
|
|
34
|
+
: "- **Режим выполнения**: Стандартный контроль команд.",
|
|
35
|
+
snapshots
|
|
36
|
+
? "- **Снимки файлов**: Перед изменением файлов автоматически создаются pre-execution снимки. Избегайте слепой перезаписи."
|
|
37
|
+
: "",
|
|
38
|
+
trash
|
|
39
|
+
? "- **Удаление файлов**: Запрещено безусловное `rm -rf`. Используйте перемещение в сессионную корзину `.dsh/trash/<session>/` или очистку сессионных артефактов."
|
|
40
|
+
: "- **Удаление файлов**: Удаляйте файлы строго в границах текущего рабочего каталога.",
|
|
41
|
+
"- **Песочница**: Не запрашивайте `danger-full-access` или выход из песочницы для стандартных задач внутри проекта.",
|
|
42
|
+
justification
|
|
43
|
+
? "- **Эскалация привилегий**: Любая системная операция (apt, systemctl, сетевые вызовы) требует обязательного поля `justification` с обоснованием."
|
|
44
|
+
: "",
|
|
45
|
+
"- **Безопасные команды**: Чтение файлов, `git status`, `git diff`, `npm test` выполняются быстро и без запроса подтверждения.",
|
|
46
|
+
].filter(Boolean).join("\n");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (lang === "zh") {
|
|
50
|
+
return [
|
|
51
|
+
GUIDANCE_MARKER,
|
|
52
|
+
"### 执行环境安全指引 (dsh-approval-gate)",
|
|
53
|
+
failClosed
|
|
54
|
+
? "- **执行模式**: 已启用严格安全保护 (fail-closed)。未识别的命令需人工确认。"
|
|
55
|
+
: "- **执行模式**: 标准命令控制。",
|
|
56
|
+
snapshots
|
|
57
|
+
? "- **文件快照**: 修改文件前会自动生成执行前快照。请避免盲目覆盖文件。"
|
|
58
|
+
: "",
|
|
59
|
+
trash
|
|
60
|
+
? "- **文件删除**: 禁止盲目运行 `rm -rf`。优先移动至会话回收站 `.dsh/trash/<session>/`。"
|
|
61
|
+
: "- **文件删除**: 请确保仅在项目工作区内删除文件。",
|
|
62
|
+
"- **沙箱权限**: 请勿对常规项目任务请求 `danger-full-access` 或沙箱提升。",
|
|
63
|
+
justification
|
|
64
|
+
? "- **权限提升**: 任何系统级操作或跨目录访问必须附带明确的 `justification` 说明。"
|
|
65
|
+
: "",
|
|
66
|
+
"- **安全检查**: `git status`、`git diff`、`npm test` 等检查操作自动放行。",
|
|
67
|
+
].filter(Boolean).join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Default: English
|
|
71
|
+
return [
|
|
72
|
+
GUIDANCE_MARKER,
|
|
73
|
+
"### Security & Execution Guidance (dsh-approval-gate)",
|
|
74
|
+
failClosed
|
|
75
|
+
? "- **Execution Mode**: Strict fail-closed policy is active. Unrecognized shell commands require human approval."
|
|
76
|
+
: "- **Execution Mode**: Standard execution controls active.",
|
|
77
|
+
snapshots
|
|
78
|
+
? "- **File Mutations**: Pre-execution snapshots are captured automatically before writes. Avoid blind destructive overwrites."
|
|
79
|
+
: "",
|
|
80
|
+
trash
|
|
81
|
+
? "- **Deletion Policy**: Do NOT run unconditional `rm -rf`. Prefer moving to session trash `.dsh/trash/<session>/` or safe artifact cleanup."
|
|
82
|
+
: "- **Deletion Policy**: Keep all deletions strictly within the workspace boundary.",
|
|
83
|
+
"- **Sandbox Boundaries**: Do NOT request `danger-full-access` or sandbox elevation for standard internal project tasks.",
|
|
84
|
+
justification
|
|
85
|
+
? "- **Privilege Escalation**: Any system-level command or out-of-workspace access requires an explicit `justification` explanation."
|
|
86
|
+
: "",
|
|
87
|
+
"- **Safe Inspection**: Inspection commands (`git status`, `git diff`, `npm test`, `cat`, `ls`) are fast-tracked and safe.",
|
|
88
|
+
].filter(Boolean).join("\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Injects guidance into system prompt idempotently.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} systemPrompt
|
|
95
|
+
* @param {Parameters<typeof generateAgentGuidance>[0]} [options]
|
|
96
|
+
* @returns {string}
|
|
97
|
+
*/
|
|
98
|
+
export function injectAgentGuidance(systemPrompt, options = {}) {
|
|
99
|
+
if (typeof systemPrompt !== "string") {
|
|
100
|
+
return generateAgentGuidance(options);
|
|
101
|
+
}
|
|
102
|
+
if (systemPrompt.includes(GUIDANCE_MARKER)) {
|
|
103
|
+
return systemPrompt;
|
|
104
|
+
}
|
|
105
|
+
const guidance = generateAgentGuidance(options);
|
|
106
|
+
return `${systemPrompt.trimEnd()}\n\n${guidance}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Extracts injected guidance block from prompt if present.
|
|
111
|
+
* @param {string} prompt
|
|
112
|
+
* @returns {string|null}
|
|
113
|
+
*/
|
|
114
|
+
export function extractGuidance(prompt) {
|
|
115
|
+
if (typeof prompt !== "string") return null;
|
|
116
|
+
const idx = prompt.indexOf(GUIDANCE_MARKER);
|
|
117
|
+
if (idx === -1) return null;
|
|
118
|
+
return prompt.slice(idx);
|
|
119
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -8,6 +8,9 @@ import { assertHarnessCompatibility, provideServicePolyfill } from "./compat.js"
|
|
|
8
8
|
import { extractFilePath } from "./dispatcher.js";
|
|
9
9
|
import { SnapshotManager } from "./snapshots.js";
|
|
10
10
|
import { MEASURE_KINDS, classifyMeasureKind } from "./measure-kinds.js";
|
|
11
|
+
import { LineageManager } from "./lineage.js";
|
|
12
|
+
import { generateAgentGuidance, injectAgentGuidance, extractGuidance, GUIDANCE_MARKER } from "./guidance.js";
|
|
13
|
+
import { isRedundantSandboxRequest, buildRedundantSandboxRemediation, checkEscalationJustification } from "./escalation.js";
|
|
11
14
|
import Schema from "@deepseek-ai/schemastery";
|
|
12
15
|
|
|
13
16
|
if (typeof Schema.prototype?.volatile !== "function") {
|
|
@@ -183,6 +186,16 @@ export function readKnobs(ctx, execution) {
|
|
|
183
186
|
}
|
|
184
187
|
}
|
|
185
188
|
|
|
189
|
+
// 5b. Lineage authority inheritance (#98)
|
|
190
|
+
if (!policy && session && (session.parentSession || session.parentId || session.parentSessionId)) {
|
|
191
|
+
const parent = session.parentSession || { id: session.parentId || session.parentSessionId };
|
|
192
|
+
if (typeof parent.approvalPolicy === "string") {
|
|
193
|
+
policy = parent.approvalPolicy;
|
|
194
|
+
} else if (approval && typeof approval.effectivePolicy === "function") {
|
|
195
|
+
try { policy = approval.effectivePolicy(parent) || ""; } catch {}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
186
199
|
// 6. Global static fallback from approval service config
|
|
187
200
|
if (!policy && approval && approval.config && approval.config.policy) {
|
|
188
201
|
policy = approval.config.policy;
|
|
@@ -253,6 +266,10 @@ export const Config = Schema.object({
|
|
|
253
266
|
agentsRules: Schema.boolean().default(true).volatile(),
|
|
254
267
|
/** Enforce human confirmation on breaking major dependency bumps in package.json (#95). */
|
|
255
268
|
versionGuard: Schema.boolean().default(true).volatile(),
|
|
269
|
+
/** Require explicit justification for privilege escalation (#107). */
|
|
270
|
+
requireJustification: Schema.boolean().default(false).volatile(),
|
|
271
|
+
/** Enable automatic remediation advice for redundant sandbox privilege requests (#101). */
|
|
272
|
+
enableRedundantSandboxRecovery: Schema.boolean().default(true).volatile(),
|
|
256
273
|
/** Security engine instance (internal/testing). */
|
|
257
274
|
engine: Schema.any().hidden(),
|
|
258
275
|
});
|
|
@@ -321,6 +338,8 @@ export function apply(ctx, config) {
|
|
|
321
338
|
slaMs: cfg.classifierSlaMs !== undefined ? cfg.classifierSlaMs : 2000,
|
|
322
339
|
}),
|
|
323
340
|
defaultVerdict: cfg.defaultVerdict || "ask",
|
|
341
|
+
requireJustification: cfg.requireJustification === true,
|
|
342
|
+
enableRedundantSandboxRecovery: cfg.enableRedundantSandboxRecovery !== false,
|
|
324
343
|
snapshotManager: new SnapshotManager({ workspaceDir: cfg.workspaceDir || "", maxSnapshotsPerSession: cfg.maxSnapshotsPerSession, maxSnapshotBytes: cfg.maxSnapshotBytes }),
|
|
325
344
|
dryRun: Boolean(cfg.dryRun || cfg.shadowMode),
|
|
326
345
|
auditFailClosed: Boolean(cfg.auditFailClosed),
|
|
@@ -356,6 +375,20 @@ export function apply(ctx, config) {
|
|
|
356
375
|
clearSnapshots: (sessionId) => (engine.clearSnapshots ? engine.clearSnapshots(sessionId) : { cleared: 0 }),
|
|
357
376
|
clearAllSnapshots: () => (engine.clearAllSnapshots ? engine.clearAllSnapshots() : { cleared: 0 }),
|
|
358
377
|
getSnapshotStats: () => (engine.snapshotManager ? engine.snapshotManager.getStats() : null),
|
|
378
|
+
lineage: engine.lineage,
|
|
379
|
+
registerLineage: (child, parent, root) => engine.registerLineage(child, parent, root),
|
|
380
|
+
getLineage: (id) => engine.getLineage(id),
|
|
381
|
+
guidance: {
|
|
382
|
+
generate: (opts) => generateAgentGuidance(opts),
|
|
383
|
+
inject: (prompt, opts) => injectAgentGuidance(prompt, opts),
|
|
384
|
+
extract: (prompt) => extractGuidance(prompt),
|
|
385
|
+
},
|
|
386
|
+
getAgentGuidance: (opts) => generateAgentGuidance(opts),
|
|
387
|
+
redundantSandbox: {
|
|
388
|
+
check: (cmd, mode, opts) => isRedundantSandboxRequest(cmd, mode, opts),
|
|
389
|
+
remediation: (cmd, lang) => buildRedundantSandboxRemediation(cmd, lang),
|
|
390
|
+
},
|
|
391
|
+
checkEscalationJustification: (cmd, tool, args, opts) => checkEscalationJustification(cmd, tool, args, opts),
|
|
359
392
|
getSnapshot: () => ({
|
|
360
393
|
active: true,
|
|
361
394
|
dryRun: Boolean(cfg.dryRun || cfg.shadowMode),
|
|
@@ -379,9 +412,12 @@ export function apply(ctx, config) {
|
|
|
379
412
|
const knobs = readKnobs(ctx, execution);
|
|
380
413
|
const who = sessionTag(execution);
|
|
381
414
|
const sessionId = resolveSessionId(execution);
|
|
415
|
+
const callId = execution?.callId || execution?.id || null;
|
|
416
|
+
const agentId = execution?.agent?.id || execution?.agentId || null;
|
|
417
|
+
const subagentId = execution?.agent?.subagentId || execution?.subagentId || null;
|
|
382
418
|
let decision;
|
|
383
419
|
try {
|
|
384
|
-
decision = engine.decide(execution, { sessionId, knobs });
|
|
420
|
+
decision = engine.decide(execution, { sessionId, knobs, callId, agentId, subagentId, sandboxMode: knobs.sandbox, preset: knobs.preset });
|
|
385
421
|
} catch (err) {
|
|
386
422
|
const hit = {
|
|
387
423
|
ask: true,
|
|
@@ -444,9 +480,12 @@ export function apply(ctx, config) {
|
|
|
444
480
|
const guard = function (execution) {
|
|
445
481
|
const who = sessionTag(execution);
|
|
446
482
|
const sessionId = resolveSessionId(execution);
|
|
483
|
+
const callId = execution?.callId || execution?.id || null;
|
|
484
|
+
const agentId = execution?.agent?.id || execution?.agentId || null;
|
|
485
|
+
const subagentId = execution?.agent?.subagentId || execution?.subagentId || null;
|
|
447
486
|
let decision;
|
|
448
487
|
try {
|
|
449
|
-
decision = engine.decide(execution, { sessionId });
|
|
488
|
+
decision = engine.decide(execution, { sessionId, callId, agentId, subagentId });
|
|
450
489
|
} catch (err) {
|
|
451
490
|
const hit = {
|
|
452
491
|
deny: true,
|
|
@@ -533,6 +572,9 @@ export { detectHarnessCapabilities, assertHarnessCompatibility, provideServicePo
|
|
|
533
572
|
export { parseSemver, isMajorBump, inspectPackageJsonDiff, VersionGuard } from "./version-guard.js";
|
|
534
573
|
export { SnapshotManager, DEFAULT_MAX_SNAPSHOTS_PER_SESSION, DEFAULT_MAX_SNAPSHOT_BYTES } from "./snapshots.js";
|
|
535
574
|
export { MEASURE_KINDS, classifyMeasureKind, isSystemLevelExecution, isWorkspaceWideExecution } from "./measure-kinds.js";
|
|
575
|
+
export { LineageManager } from "./lineage.js";
|
|
576
|
+
export { generateAgentGuidance, injectAgentGuidance, extractGuidance, GUIDANCE_MARKER } from "./guidance.js";
|
|
577
|
+
export { isRedundantSandboxRequest, buildRedundantSandboxRemediation, checkEscalationJustification, REDUNDANT_SANDBOX_REASON, MISSING_JUSTIFICATION_REASON, INSUFFICIENT_JUSTIFICATION_REASON } from "./escalation.js";
|
|
536
578
|
|
|
537
579
|
|
|
538
580
|
|
package/lib/lineage.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// lib/lineage.js
|
|
2
|
+
// Subagent hierarchy and parentSession lineage authority (#98).
|
|
3
|
+
// Tracks session ancestry trees and propagates authority/policies down the lineage chain.
|
|
4
|
+
|
|
5
|
+
export class LineageManager {
|
|
6
|
+
constructor() {
|
|
7
|
+
/** @type {Map<string, string>} childSessionId -> parentSessionId */
|
|
8
|
+
this.parentMap = new Map();
|
|
9
|
+
/** @type {Map<string, string>} childSessionId -> explicit rootSessionId */
|
|
10
|
+
this.rootMap = new Map();
|
|
11
|
+
/** @type {Map<string, Set<string>>} parentSessionId -> Set of childSessionIds */
|
|
12
|
+
this.childrenMap = new Map();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Registers parent-child session relationship.
|
|
17
|
+
* Prevents circular parent-child references.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} childSessionId
|
|
20
|
+
* @param {string} parentSessionId
|
|
21
|
+
* @param {string} [rootSessionId]
|
|
22
|
+
* @returns {boolean} True if successfully registered, false if invalid or circular
|
|
23
|
+
*/
|
|
24
|
+
registerLineage(childSessionId, parentSessionId, rootSessionId = null) {
|
|
25
|
+
if (!childSessionId || !parentSessionId) return false;
|
|
26
|
+
if (childSessionId === parentSessionId) return false;
|
|
27
|
+
|
|
28
|
+
// Check circular references: walking upwards from parent must not reach child
|
|
29
|
+
let curr = parentSessionId;
|
|
30
|
+
const visited = new Set([childSessionId]);
|
|
31
|
+
while (curr) {
|
|
32
|
+
if (visited.has(curr)) return false;
|
|
33
|
+
visited.add(curr);
|
|
34
|
+
curr = this.parentMap.get(curr);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
this.parentMap.set(childSessionId, parentSessionId);
|
|
38
|
+
if (rootSessionId && rootSessionId !== childSessionId) {
|
|
39
|
+
this.rootMap.set(childSessionId, rootSessionId);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!this.childrenMap.has(parentSessionId)) {
|
|
43
|
+
this.childrenMap.set(parentSessionId, new Set());
|
|
44
|
+
}
|
|
45
|
+
this.childrenMap.get(parentSessionId).add(childSessionId);
|
|
46
|
+
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Retrieves the direct parent session ID for a session.
|
|
52
|
+
* @param {string} sessionId
|
|
53
|
+
* @returns {string|null}
|
|
54
|
+
*/
|
|
55
|
+
getParent(sessionId) {
|
|
56
|
+
if (!sessionId) return null;
|
|
57
|
+
return this.parentMap.get(sessionId) || null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Retrieves the ultimate root session ID for a session.
|
|
62
|
+
* @param {string} sessionId
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
getRoot(sessionId) {
|
|
66
|
+
if (!sessionId) return "";
|
|
67
|
+
if (this.rootMap.has(sessionId)) {
|
|
68
|
+
return this.rootMap.get(sessionId);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let curr = sessionId;
|
|
72
|
+
const visited = new Set();
|
|
73
|
+
while (curr && !visited.has(curr)) {
|
|
74
|
+
visited.add(curr);
|
|
75
|
+
const parent = this.parentMap.get(curr);
|
|
76
|
+
if (!parent) break;
|
|
77
|
+
curr = parent;
|
|
78
|
+
}
|
|
79
|
+
return curr;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Returns ancestry array from current session up to root: [sessionId, parentId, ..., rootId].
|
|
84
|
+
* @param {string} sessionId
|
|
85
|
+
* @returns {string[]}
|
|
86
|
+
*/
|
|
87
|
+
getLineage(sessionId) {
|
|
88
|
+
if (!sessionId) return [];
|
|
89
|
+
const chain = [];
|
|
90
|
+
let curr = sessionId;
|
|
91
|
+
const visited = new Set();
|
|
92
|
+
|
|
93
|
+
while (curr && !visited.has(curr)) {
|
|
94
|
+
visited.add(curr);
|
|
95
|
+
chain.push(curr);
|
|
96
|
+
curr = this.parentMap.get(curr);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return chain;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Checks whether ancestorSessionId is an ancestor of childSessionId.
|
|
104
|
+
* @param {string} childSessionId
|
|
105
|
+
* @param {string} ancestorSessionId
|
|
106
|
+
* @returns {boolean}
|
|
107
|
+
*/
|
|
108
|
+
hasLineageAuthority(childSessionId, ancestorSessionId) {
|
|
109
|
+
if (!childSessionId || !ancestorSessionId) return false;
|
|
110
|
+
if (childSessionId === ancestorSessionId) return true;
|
|
111
|
+
|
|
112
|
+
const lineage = this.getLineage(childSessionId);
|
|
113
|
+
return lineage.includes(ancestorSessionId);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Resolves effective policy walking up the lineage hierarchy.
|
|
118
|
+
* Evaluates getPolicyFn(sessionId). If undefined or null, falls back to parent, then root.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} sessionId
|
|
121
|
+
* @param {(id: string) => any} getPolicyFn
|
|
122
|
+
* @returns {any}
|
|
123
|
+
*/
|
|
124
|
+
resolveEffectivePolicy(sessionId, getPolicyFn) {
|
|
125
|
+
if (!sessionId || typeof getPolicyFn !== "function") return null;
|
|
126
|
+
|
|
127
|
+
const lineage = this.getLineage(sessionId);
|
|
128
|
+
for (const sid of lineage) {
|
|
129
|
+
try {
|
|
130
|
+
const policy = getPolicyFn(sid);
|
|
131
|
+
if (policy !== undefined && policy !== null) {
|
|
132
|
+
return {
|
|
133
|
+
policy,
|
|
134
|
+
sourceSessionId: sid,
|
|
135
|
+
inherited: sid !== sessionId,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
// Continue upward traversal on error
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Removes tracking for a session and unlinks from its parent.
|
|
148
|
+
* @param {string} sessionId
|
|
149
|
+
* @returns {boolean}
|
|
150
|
+
*/
|
|
151
|
+
clearSession(sessionId) {
|
|
152
|
+
if (!sessionId) return false;
|
|
153
|
+
const parent = this.parentMap.get(sessionId);
|
|
154
|
+
if (parent && this.childrenMap.has(parent)) {
|
|
155
|
+
this.childrenMap.get(parent).delete(sessionId);
|
|
156
|
+
}
|
|
157
|
+
this.parentMap.delete(sessionId);
|
|
158
|
+
this.rootMap.delete(sessionId);
|
|
159
|
+
this.childrenMap.delete(sessionId);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Clears all lineage mappings.
|
|
165
|
+
*/
|
|
166
|
+
clearAll() {
|
|
167
|
+
this.parentMap.clear();
|
|
168
|
+
this.rootMap.clear();
|
|
169
|
+
this.childrenMap.clear();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Returns current statistics for debugging and auditing.
|
|
174
|
+
* @returns {{ trackedSessions: number, rootSessions: number }}
|
|
175
|
+
*/
|
|
176
|
+
getStats() {
|
|
177
|
+
return {
|
|
178
|
+
trackedSessions: this.parentMap.size,
|
|
179
|
+
parentCount: this.childrenMap.size,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
}
|