@goodandready/dsh-approval-gate 0.1.20 → 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/lib/engine.js CHANGED
@@ -13,13 +13,17 @@ import { DeclarativeRulesEngine } from "./rules-loader.js";
13
13
  import { BlockCounters } from "./counters.js";
14
14
  import { ContractGuard } from "./contract-guard.js";
15
15
  import { OverengineeringGuard } from "./overengineering.js";
16
- import { extractFilePath, DEFAULT_FILE_WRITE_TOOLS } from "./dispatcher.js";
16
+ import { extractFilePath, DEFAULT_BASH_TOOL, DEFAULT_FILE_WRITE_TOOLS } from "./dispatcher.js";
17
17
  import { categorizeRiskBucket, extractRiskTags } from "./risk.js";
18
18
  import { ClassifierLatencyTracker } from "./latency.js";
19
19
  import { ArtifactRegistry } from "./artifacts.js";
20
20
  import { isRmCommand, extractRmTargets, suggestTrashAlternative } from "./trash.js";
21
21
  import { VersionGuard } from "./version-guard.js";
22
22
  import { loadAgentsRules, evaluateAgentsRules } from "./agents-parser.js";
23
+ import { classifyMeasureKind } from "./measure-kinds.js";
24
+ import { SnapshotManager } from "./snapshots.js";
25
+ import { LineageManager } from "./lineage.js";
26
+ import { isRedundantSandboxRequest, buildRedundantSandboxRemediation, checkEscalationJustification } from "./escalation.js";
23
27
 
24
28
  /**
25
29
  * Determine whether an operation belongs to a critical risk category requiring lockedAsk (#52).
@@ -79,7 +83,7 @@ export class SecurityEngine {
79
83
  this.classifier = options.classifier || null;
80
84
  this.timeoutPolicy = options.timeoutPolicy === "permissive" ? "permissive" : "strict";
81
85
  this.grantRegistry = options.grantRegistry || new SessionGrantStore(options);
82
- this.defaultVerdict = options.defaultVerdict || "allow";
86
+ this.defaultVerdict = options.defaultVerdict || "ask";
83
87
 
84
88
  this.dryRun = Boolean(options.dryRun || options.shadowMode);
85
89
  this.auditFailClosed = options.auditFailClosed === true;
@@ -87,33 +91,19 @@ export class SecurityEngine {
87
91
  this.retryGuard = options.retryGuard !== undefined ? options.retryGuard : new RetryExhaustionGuard();
88
92
  this.ledger = options.ledger !== undefined ? options.ledger : new QualityLedger();
89
93
  this.counters = options.counters || new BlockCounters();
90
- this.auditLogger = options.auditLogger !== undefined ? options.auditLogger : new AuditLogger({
91
- failClosed: this.auditFailClosed,
92
- });
93
- if (this.auditFailClosed && this.auditLogger) {
94
- this.auditLogger.failClosed = true;
95
- }
96
- this.declarativeRules = options.declarativeRules || new DeclarativeRulesEngine({
97
- workspaceDir: this.inspectConfig?.workspaceDir,
98
- });
99
- this.contractGuard = options.contractGuard !== undefined
100
- ? options.contractGuard
101
- : (options.taskContract ? new ContractGuard(options.taskContract) : null);
102
- this.overengineeringGuard = options.overengineeringGuard !== undefined
103
- ? options.overengineeringGuard
104
- : (options.maxFilesPerSession !== undefined || options.maxCreatedFilesPerSession !== undefined
105
- ? new OverengineeringGuard(options)
106
- : null);
94
+ this.auditLogger = options.auditLogger !== undefined ? options.auditLogger : new AuditLogger({ failClosed: this.auditFailClosed });
95
+ if (this.auditFailClosed && this.auditLogger) this.auditLogger.failClosed = true;
96
+ this.declarativeRules = options.declarativeRules || new DeclarativeRulesEngine({ workspaceDir: this.inspectConfig?.workspaceDir });
97
+ this.contractGuard = options.contractGuard !== undefined ? options.contractGuard : (options.taskContract ? new ContractGuard(options.taskContract) : null);
98
+ this.overengineeringGuard = options.overengineeringGuard !== undefined ? options.overengineeringGuard : (options.maxFilesPerSession !== undefined || options.maxCreatedFilesPerSession !== undefined ? new OverengineeringGuard(options) : null);
107
99
  this.latencyTracker = options.latencyTracker || new ClassifierLatencyTracker(options);
108
- this.artifactRegistry = options.artifactRegistry !== undefined
109
- ? options.artifactRegistry
110
- : new ArtifactRegistry({ workspaceDir: this.inspectConfig?.workspaceDir });
111
- this.versionGuard = options.versionGuard !== undefined
112
- ? options.versionGuard
113
- : new VersionGuard({ workspaceDir: this.inspectConfig?.workspaceDir });
114
- this.agentsRules = options.agentsRules !== undefined
115
- ? options.agentsRules
116
- : (loadAgentsRules(this.inspectConfig?.workspaceDir)?.rules || null);
100
+ this.artifactRegistry = options.artifactRegistry !== undefined ? options.artifactRegistry : new ArtifactRegistry({ workspaceDir: this.inspectConfig?.workspaceDir });
101
+ this.versionGuard = options.versionGuard !== undefined ? options.versionGuard : new VersionGuard({ workspaceDir: this.inspectConfig?.workspaceDir });
102
+ this.agentsRules = options.agentsRules !== undefined ? options.agentsRules : (loadAgentsRules(this.inspectConfig?.workspaceDir)?.rules || null);
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;
117
107
  }
118
108
 
119
109
  /**
@@ -447,7 +437,34 @@ export class SecurityEngine {
447
437
  return res;
448
438
  }
449
439
 
450
- // P2: Static Rules / Safe Allow
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
+
451
468
  if (bandVerdict === "allow") {
452
469
  this.breaker.resetConsecutive(sessionId);
453
470
  return { verdict: "allow", stage: "P2", reason: "safe-allow", hit };
@@ -493,8 +510,10 @@ export class SecurityEngine {
493
510
  }
494
511
  }
495
512
 
496
- // Default verdict for unclassified commands (#163)
497
- if (this.defaultVerdict === "ask") {
513
+ // Default verdict for unclassified commands (#163, #172)
514
+ const bashTool = this.inspectConfig?.toolName || DEFAULT_BASH_TOOL;
515
+ const isBashTool = toolName === bashTool || !toolName;
516
+ if (isBashTool && this.defaultVerdict === "ask") {
498
517
  const askHit = { ask: true, reason: "unmatched-ask", snippet: command || toolName };
499
518
  const res = { verdict: "ask", stage: "P4", reason: "unmatched-ask", hit: askHit };
500
519
  this._applyLockedAskIfCritical(res, toolName, command, execution);
@@ -517,40 +536,13 @@ export class SecurityEngine {
517
536
  }
518
537
  }
519
538
 
520
- addGrant(options) {
521
- if (this.grantRegistry && typeof this.grantRegistry.addGrant === "function") {
522
- return this.grantRegistry.addGrant(options);
523
- }
524
- return null;
525
- }
526
-
527
- getGrants(sessionId) {
528
- if (this.grantRegistry && typeof this.grantRegistry.getGrants === "function") {
529
- return this.grantRegistry.getGrants(sessionId);
530
- }
531
- return [];
532
- }
533
-
534
- getLedgerSummary(sessionId) {
535
- return this.ledger ? this.ledger.getSummary(sessionId) : null;
536
- }
537
-
538
- isRetryExhausted(sessionId) {
539
- return this.retryGuard ? this.retryGuard.isExhausted(sessionId) : false;
540
- }
541
-
542
- clearCache() {
543
- if (this.cache) this.cache.clear();
544
- }
545
-
546
- getCounters() {
547
- return this.counters ? this.counters.getSnapshot() : null;
548
- }
549
-
550
- resetCounters() {
551
- if (this.counters) this.counters.reset();
552
- }
553
-
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(); }
554
546
  resume(sessionId, reason) {
555
547
  if (this.retryGuard) this.retryGuard.reset(sessionId);
556
548
  if (this.overengineeringGuard) this.overengineeringGuard.reset(sessionId);
@@ -566,28 +558,31 @@ export class SecurityEngine {
566
558
  if (!decision) return decision;
567
559
  if (!decision.riskBucket) decision.riskBucket = categorizeRiskBucket(execution, context);
568
560
  if (!decision.riskTags || !Array.isArray(decision.riskTags)) decision.riskTags = extractRiskTags(execution, context);
561
+ if (!decision.measureKind) decision.measureKind = classifyMeasureKind(execution, context);
569
562
  const command = execution && execution.arguments && execution.arguments.command;
570
563
  if (command && isRmCommand(command)) {
571
564
  decision.trashAlternative = suggestTrashAlternative(command, this.inspectConfig?.workspaceDir, context?.sessionId);
572
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;
573
572
  return decision;
574
573
  }
575
574
 
576
- getArtifacts(sessionId) {
577
- return this.artifactRegistry ? this.artifactRegistry.getArtifacts(sessionId) : [];
578
- }
579
-
580
- clearArtifacts(sessionId) {
581
- if (this.artifactRegistry) this.artifactRegistry.clearSession(sessionId);
582
- }
583
-
584
- getLatencyMetrics() {
585
- return this.latencyTracker ? this.latencyTracker.getMetrics() : null;
586
- }
587
-
588
- resetLatencyTracker() {
589
- if (this.latencyTracker) this.latencyTracker.reset();
590
- }
575
+ getArtifacts(sessionId) { return this.artifactRegistry ? this.artifactRegistry.getArtifacts(sessionId) : []; }
576
+ clearArtifacts(sessionId) { if (this.artifactRegistry) this.artifactRegistry.clearSession(sessionId); }
577
+ getLatencyMetrics() { return this.latencyTracker ? this.latencyTracker.getMetrics() : null; }
578
+ resetLatencyTracker() { if (this.latencyTracker) this.latencyTracker.reset(); }
579
+ takeSnapshot(targetPath, meta) { return this.snapshotManager ? this.snapshotManager.takeSnapshot(targetPath, meta) : null; }
580
+ rollbackSnapshot(id) { return this.snapshotManager ? this.snapshotManager.rollbackSnapshot(id) : { success: false, error: "no-snapshot-manager" }; }
581
+ getSnapshots(sessionId) { return this.snapshotManager ? this.snapshotManager.getSnapshots(sessionId) : []; }
582
+ clearSnapshots(sessionId) { return this.snapshotManager ? this.snapshotManager.clearSnapshots(sessionId) : { cleared: 0 }; }
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; }
591
586
  }
592
587
 
593
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
+ }
@@ -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
+ }