@goodandready/dsh-approval-gate 0.1.21 → 0.1.23

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/dossier.js ADDED
@@ -0,0 +1,122 @@
1
+ // lib/dossier.js
2
+ // Evidence Dossier Gathering (#64).
3
+ // Assembles compact evidence packets for tool calls, diffs, terminal output, and user prompts.
4
+
5
+ export const MAX_PROMPT_CHARS = 200;
6
+ export const MAX_DIFF_CHARS = 400;
7
+ export const MAX_OUTPUT_CHARS = 300;
8
+
9
+ /**
10
+ * Truncates a string safely with ellipsis.
11
+ * @param {any} str
12
+ * @param {number} max
13
+ * @returns {string}
14
+ */
15
+ function truncate(str, max) {
16
+ if (typeof str !== "string") return "";
17
+ const trimmed = str.trim();
18
+ if (trimmed.length <= max) return trimmed;
19
+ return trimmed.slice(0, max - 3) + "...";
20
+ }
21
+
22
+ /**
23
+ * Extracts a concise summary of arguments without sensitive credentials.
24
+ * @param {Record<string, any>} [args]
25
+ * @returns {Record<string, any>}
26
+ */
27
+ function sanitizeArgs(args) {
28
+ if (!args || typeof args !== "object") return {};
29
+ const summary = {};
30
+ for (const [k, v] of Object.entries(args)) {
31
+ if (k.toLowerCase().includes("token") || k.toLowerCase().includes("secret") || k.toLowerCase().includes("password")) {
32
+ summary[k] = "[REDACTED]";
33
+ } else if (typeof v === "string") {
34
+ summary[k] = v.length > 80 ? v.slice(0, 77) + "..." : v;
35
+ } else if (typeof v === "number" || typeof v === "boolean") {
36
+ summary[k] = v;
37
+ } else if (Array.isArray(v)) {
38
+ summary[k] = `Array(${v.length})`;
39
+ } else if (typeof v === "object" && v !== null) {
40
+ summary[k] = `Object(${Object.keys(v).length})`;
41
+ }
42
+ }
43
+ return summary;
44
+ }
45
+
46
+ /**
47
+ * Builds an evidence dossier object for an execution (#64).
48
+ *
49
+ * @param {Object} params
50
+ * @param {Record<string, any>} [params.execution]
51
+ * @param {Record<string, any>} [params.decision]
52
+ * @param {Record<string, any>} [params.context]
53
+ * @param {string} [params.toolName]
54
+ * @param {string} [params.command]
55
+ * @param {Record<string, any>} [params.args]
56
+ * @param {string} [params.userPrompt]
57
+ * @param {string} [params.diffSnippet]
58
+ * @param {string} [params.terminalOutput]
59
+ * @param {string} [params.track]
60
+ * @param {string} [params.riskBucket]
61
+ * @returns {{
62
+ * toolName: string,
63
+ * command: string,
64
+ * argsSummary: Record<string, any>,
65
+ * userPrompt: string,
66
+ * diffSnippet: string,
67
+ * terminalOutput: string,
68
+ * track: string,
69
+ * riskBucket: string,
70
+ * timestamp: number
71
+ * }}
72
+ */
73
+ export function buildEvidenceDossier({
74
+ execution = {},
75
+ decision = {},
76
+ context = {},
77
+ toolName,
78
+ command,
79
+ args,
80
+ userPrompt,
81
+ diffSnippet,
82
+ terminalOutput,
83
+ track,
84
+ riskBucket,
85
+ } = {}) {
86
+ const tool = String(toolName || execution?.name || execution?.toolName || "").trim();
87
+ const cmd = String(command || execution?.arguments?.command || execution?.command || "").trim();
88
+ const rawArgs = args || execution?.arguments || {};
89
+ const prompt = userPrompt || context?.userPrompt || context?.prompt || context?.goal || "";
90
+ const diff = diffSnippet || context?.diffSnippet || context?.diff || rawArgs?.diff || "";
91
+ const output = terminalOutput || context?.terminalOutput || context?.output || "";
92
+ const assignedTrack = track || decision?.track || context?.track || "external";
93
+ const assignedRisk = riskBucket || decision?.riskBucket || context?.riskBucket || "medium";
94
+
95
+ return {
96
+ toolName: tool,
97
+ command: cmd,
98
+ argsSummary: sanitizeArgs(rawArgs),
99
+ userPrompt: truncate(prompt, MAX_PROMPT_CHARS),
100
+ diffSnippet: truncate(diff, MAX_DIFF_CHARS),
101
+ terminalOutput: truncate(output, MAX_OUTPUT_CHARS),
102
+ track: assignedTrack,
103
+ riskBucket: assignedRisk,
104
+ timestamp: Date.now(),
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Formats a single-line summary of an evidence dossier.
110
+ * @param {ReturnType<typeof buildEvidenceDossier>} dossier
111
+ * @returns {string}
112
+ */
113
+ export function formatDossierSummary(dossier) {
114
+ if (!dossier) return "[Dossier: empty]";
115
+ const parts = [];
116
+ if (dossier.toolName) parts.push(`tool=${dossier.toolName}`);
117
+ if (dossier.track) parts.push(`track=${dossier.track}`);
118
+ if (dossier.riskBucket) parts.push(`risk=${dossier.riskBucket}`);
119
+ if (dossier.userPrompt) parts.push(`prompt="${dossier.userPrompt.slice(0, 30)}"`);
120
+ if (dossier.diffSnippet) parts.push(`diff="${dossier.diffSnippet.slice(0, 30)}"`);
121
+ return `[Dossier: ${parts.join(" ")}]`;
122
+ }
package/lib/drift.js ADDED
@@ -0,0 +1,214 @@
1
+ // lib/drift.js
2
+ // Trajectory Drift Detection for dsh-approval-gate (#68).
3
+ // Detects when agent deviates from session goal into port scanning, credential hunting, or out-of-scope probing.
4
+
5
+ export const DRIFT_CATEGORIES = Object.freeze({
6
+ NETWORK_RECON: "network-recon",
7
+ CREDENTIAL_HUNT: "credential-hunt",
8
+ SYSTEM_PROBING: "system-probing",
9
+ OUT_OF_SCOPE: "out-of-scope",
10
+ });
11
+
12
+ const NETWORK_RECON_PATTERNS = [
13
+ /\b(?:nmap|masscan|zmap)\b/i,
14
+ /\bnetstat\s+-[antulp]+/i,
15
+ /\bss\s+-[antulp]+/i,
16
+ /\barp\s+-(?:a|n)\b/i,
17
+ /\b(?:tcpdump|tshark|wireshark)\b/i,
18
+ /\b(?:traceroute|tracepath)\b/i,
19
+ ];
20
+
21
+ const NETWORK_GOAL_KEYWORDS = [
22
+ "network", "port", "scan", "packet", "interface", "traceroute", "traffic", "firewall", "diagnose"
23
+ ];
24
+
25
+ const CREDENTIAL_HUNT_PATTERNS = [
26
+ /[\\/]\.ssh[\\/](?:id_rsa|id_ed25519|id_ecdsa|authorized_keys|known_hosts)/i,
27
+ /[\\/]\.aws[\\/]credentials/i,
28
+ /\/etc\/(?:shadow|gshadow|sudoers)/i,
29
+ /\bcat\s+[^\n]*(?:id_rsa|id_ed25519|\.ssh|\.aws\/credentials)/i,
30
+ /\bfind\s+[^\n]*-(?:name|iname)\s+['"]?\*?\.(?:pem|key|pfx|pkcs12)['"]?/i,
31
+ ];
32
+
33
+ const CREDENTIAL_GOAL_KEYWORDS = [
34
+ "ssh", "key", "credential", "auth", "secret", "password", "token", "certificate", "cert", "deploy key"
35
+ ];
36
+
37
+ const SYSTEM_PROBE_PATTERNS = [
38
+ /\bfind\s+\/(?:\s|$)[^\n]*-perm\s+-[0-9]+/i,
39
+ /\/proc\/(?:kallsyms|keys|sched_debug)/i,
40
+ /\b(?:linpeas|winpeas|chkrootkit|rkhunter)\b/i,
41
+ /\bls\s+-[la]*\s+\/root\b/i,
42
+ ];
43
+
44
+ const SYSTEM_PROBE_GOAL_KEYWORDS = [
45
+ "kernel", "root", "audit", "security audit", "permission audit", "probe", "privilege"
46
+ ];
47
+
48
+ /**
49
+ * Checks if text contains any of the given keywords.
50
+ * @param {string} text
51
+ * @param {string[]} keywords
52
+ * @returns {boolean}
53
+ */
54
+ function containsKeyword(text, keywords) {
55
+ if (!text || typeof text !== "string") return false;
56
+ const lower = text.toLowerCase();
57
+ return keywords.some((kw) => lower.includes(kw));
58
+ }
59
+
60
+ /**
61
+ * Detects trajectory drift for an execution against session goal (#68).
62
+ *
63
+ * @param {string} [sessionId]
64
+ * @param {Record<string, any>} [execution]
65
+ * @param {Record<string, any>} [context]
66
+ * @param {{ goal?: string, allowedScopes?: string[] }} [goalInfo]
67
+ * @returns {{
68
+ * hasDrift: boolean,
69
+ * driftScore: number,
70
+ * driftWarning: boolean,
71
+ * category?: string,
72
+ * reason?: string,
73
+ * detail?: string
74
+ * }}
75
+ */
76
+ export function detectTrajectoryDrift(sessionId, execution = {}, context = {}, goalInfo = {}) {
77
+ const goal = String(
78
+ goalInfo?.goal ||
79
+ context?.goal ||
80
+ context?.userPrompt ||
81
+ context?.prompt ||
82
+ ""
83
+ ).trim();
84
+
85
+ const command = String(
86
+ execution?.arguments?.command ||
87
+ execution?.command ||
88
+ ""
89
+ ).trim();
90
+
91
+ const targetPath = String(
92
+ execution?.arguments?.TargetFile ||
93
+ execution?.arguments?.targetPath ||
94
+ execution?.arguments?.path ||
95
+ execution?.arguments?.file ||
96
+ ""
97
+ ).trim();
98
+
99
+ const textToScan = `${command} ${targetPath}`.trim();
100
+ if (!textToScan) {
101
+ return { hasDrift: false, driftScore: 0, driftWarning: false };
102
+ }
103
+
104
+ // 1. Network reconnaissance check
105
+ if (NETWORK_RECON_PATTERNS.some((p) => p.test(textToScan))) {
106
+ if (!containsKeyword(goal, NETWORK_GOAL_KEYWORDS)) {
107
+ return {
108
+ hasDrift: true,
109
+ driftScore: 0.85,
110
+ driftWarning: true,
111
+ category: DRIFT_CATEGORIES.NETWORK_RECON,
112
+ reason: "trajectory-drift-detected",
113
+ detail: `Operation matches network reconnaissance without matching session goal (${goal.slice(0, 50) || "none"})`,
114
+ };
115
+ }
116
+ }
117
+
118
+ // 2. Credential hunting check
119
+ if (CREDENTIAL_HUNT_PATTERNS.some((p) => p.test(textToScan))) {
120
+ if (!containsKeyword(goal, CREDENTIAL_GOAL_KEYWORDS)) {
121
+ return {
122
+ hasDrift: true,
123
+ driftScore: 0.95,
124
+ driftWarning: true,
125
+ category: DRIFT_CATEGORIES.CREDENTIAL_HUNT,
126
+ reason: "trajectory-drift-detected",
127
+ detail: `Operation matches credential inspection without matching session goal (${goal.slice(0, 50) || "none"})`,
128
+ };
129
+ }
130
+ }
131
+
132
+ // 3. System probing check
133
+ if (SYSTEM_PROBE_PATTERNS.some((p) => p.test(textToScan))) {
134
+ if (!containsKeyword(goal, SYSTEM_PROBE_GOAL_KEYWORDS)) {
135
+ return {
136
+ hasDrift: true,
137
+ driftScore: 0.9,
138
+ driftWarning: true,
139
+ category: DRIFT_CATEGORIES.SYSTEM_PROBING,
140
+ reason: "trajectory-drift-detected",
141
+ detail: `Operation matches system probing/privilege reconnaissance outside session goal`,
142
+ };
143
+ }
144
+ }
145
+
146
+ // 4. Allowed scopes check
147
+ const allowedScopes = goalInfo?.allowedScopes || context?.allowedScopes;
148
+ if (Array.isArray(allowedScopes) && allowedScopes.length > 0 && targetPath) {
149
+ const normTarget = targetPath.replace(/\\/g, "/").toLowerCase();
150
+ const isWithinScope = allowedScopes.some((scope) => {
151
+ const normScope = String(scope).replace(/\\/g, "/").toLowerCase();
152
+ return normTarget.startsWith(normScope);
153
+ });
154
+ if (!isWithinScope && (normTarget.startsWith("/etc") || normTarget.startsWith("/root") || normTarget.includes("/.ssh"))) {
155
+ return {
156
+ hasDrift: true,
157
+ driftScore: 0.8,
158
+ driftWarning: true,
159
+ category: DRIFT_CATEGORIES.OUT_OF_SCOPE,
160
+ reason: "trajectory-drift-detected",
161
+ detail: `Access to ${targetPath} is outside declared session scopes`,
162
+ };
163
+ }
164
+ }
165
+
166
+ return { hasDrift: false, driftScore: 0, driftWarning: false };
167
+ }
168
+
169
+ /**
170
+ * Trajectory Tracker for sessions (#68).
171
+ */
172
+ export class TrajectoryTracker {
173
+ constructor() {
174
+ /** @type {Map<string, { goal: string, allowedScopes: string[], stepCount: number }>} */
175
+ this.sessions = new Map();
176
+ }
177
+
178
+ setGoal(sessionId, goal, allowedScopes = []) {
179
+ const key = sessionId || "default";
180
+ this.sessions.set(key, {
181
+ goal: String(goal || ""),
182
+ allowedScopes: Array.isArray(allowedScopes) ? [...allowedScopes] : [],
183
+ stepCount: 0,
184
+ });
185
+ }
186
+
187
+ getGoal(sessionId) {
188
+ const key = sessionId || "default";
189
+ return this.sessions.get(key) || null;
190
+ }
191
+
192
+ recordStep(sessionId) {
193
+ const key = sessionId || "default";
194
+ const entry = this.sessions.get(key);
195
+ if (entry) {
196
+ entry.stepCount += 1;
197
+ }
198
+ }
199
+
200
+ detect(sessionId, execution, context = {}) {
201
+ const key = sessionId || "default";
202
+ const goalInfo = this.sessions.get(key) || {};
203
+ return detectTrajectoryDrift(key, execution, context, goalInfo);
204
+ }
205
+
206
+ clearSession(sessionId) {
207
+ const key = sessionId || "default";
208
+ this.sessions.delete(key);
209
+ }
210
+
211
+ reset() {
212
+ this.sessions.clear();
213
+ }
214
+ }
@@ -0,0 +1,101 @@
1
+ // lib/dualtrack.js
2
+ // Dual-Track Markers for separating internal agent chores from external project mutations (#92).
3
+ // Provides classification and predicates to differentiate internal sandbox actions from project changes.
4
+
5
+ export const TRACK_TYPES = Object.freeze({
6
+ INTERNAL: "internal",
7
+ EXTERNAL: "external",
8
+ });
9
+
10
+ const INTERNAL_TOOL_NAMES = new Set([
11
+ "read_url_content",
12
+ "search_web",
13
+ "web_search",
14
+ "view_file",
15
+ "read_file",
16
+ "view",
17
+ "inspect_file",
18
+ ]);
19
+
20
+ const INTERNAL_PATH_PATTERNS = [
21
+ /[\\/]\.dsh[\\/](?:tmp|trash|cache|sessions)[\\/]/i,
22
+ /[\\/]\.gemini[\\/]/i,
23
+ /[\\/]scratch[\\/]/i,
24
+ /[\\/]\.cache[\\/]/i,
25
+ /[\\/]tmp[\\/]/i,
26
+ ];
27
+
28
+ const INTERNAL_COMMAND_REGEX = /^(?:git\s+(?:status|diff|rev-parse|branch|log)|npm\s+(?:view|help)|node\s+--version|echo\b|cat\s+[^\n]*\.(?:dsh|tmp|cache)|ls\s+-[la]*\s*(?:\.|$))/i;
29
+
30
+ /**
31
+ * Classifies an operation into internal or external track (#92).
32
+ *
33
+ * @param {string} [toolName]
34
+ * @param {string} [command]
35
+ * @param {Record<string, any>} [args]
36
+ * @param {Record<string, any>} [context]
37
+ * @returns {'internal' | 'external'}
38
+ */
39
+ export function classifyTrack(toolName, command, args = {}, context = {}) {
40
+ // Explicit overrides in context or args
41
+ if (context.track === TRACK_TYPES.INTERNAL || args?.track === TRACK_TYPES.INTERNAL || context.internal === true || args?.internal === true) {
42
+ return TRACK_TYPES.INTERNAL;
43
+ }
44
+ if (context.track === TRACK_TYPES.EXTERNAL || args?.track === TRACK_TYPES.EXTERNAL || context.external === true || args?.external === true) {
45
+ return TRACK_TYPES.EXTERNAL;
46
+ }
47
+
48
+ const tool = String(toolName || "").trim();
49
+ const cmd = String(command || (args && args.command) || "").trim();
50
+ const target = String((args && (args.TargetFile || args.targetPath || args.path || args.file)) || "").trim();
51
+
52
+ // Operations targeting internal scratch/temp paths are internal
53
+ if (target && INTERNAL_PATH_PATTERNS.some((p) => p.test(target))) {
54
+ return TRACK_TYPES.INTERNAL;
55
+ }
56
+
57
+ // Pure read-only knowledge tools are internal
58
+ if (INTERNAL_TOOL_NAMES.has(tool)) {
59
+ return TRACK_TYPES.INTERNAL;
60
+ }
61
+
62
+ // File mutations on project files are external
63
+ if (tool === "write_to_file" || tool === "replace_file_content" || tool === "create_file") {
64
+ return TRACK_TYPES.EXTERNAL;
65
+ }
66
+
67
+ // Shell command classification
68
+ if (cmd) {
69
+ if (INTERNAL_PATH_PATTERNS.some((p) => p.test(cmd))) {
70
+ return TRACK_TYPES.INTERNAL;
71
+ }
72
+ if (INTERNAL_COMMAND_REGEX.test(cmd)) {
73
+ return TRACK_TYPES.INTERNAL;
74
+ }
75
+ }
76
+
77
+ // Default for non-read commands or mutations
78
+ return TRACK_TYPES.EXTERNAL;
79
+ }
80
+
81
+ /**
82
+ * Checks whether track or decision is marked as internal.
83
+ * @param {string | { track?: string }} [trackOrDecision]
84
+ * @returns {boolean}
85
+ */
86
+ export function isInternalTrack(trackOrDecision) {
87
+ if (!trackOrDecision) return false;
88
+ if (typeof trackOrDecision === "string") return trackOrDecision === TRACK_TYPES.INTERNAL;
89
+ return trackOrDecision.track === TRACK_TYPES.INTERNAL;
90
+ }
91
+
92
+ /**
93
+ * Checks whether track or decision is marked as external.
94
+ * @param {string | { track?: string }} [trackOrDecision]
95
+ * @returns {boolean}
96
+ */
97
+ export function isExternalTrack(trackOrDecision) {
98
+ if (!trackOrDecision) return false;
99
+ if (typeof trackOrDecision === "string") return trackOrDecision === TRACK_TYPES.EXTERNAL;
100
+ return trackOrDecision.track === TRACK_TYPES.EXTERNAL;
101
+ }
package/lib/engine.js CHANGED
@@ -22,6 +22,12 @@ 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";
27
+ import { classifyTrack } from "./dualtrack.js";
28
+ import { applySessionModePolicy, SessionModeRegistry } from "./session-modes.js";
29
+ import { buildEvidenceDossier } from "./dossier.js";
30
+ import { TrajectoryTracker } from "./drift.js";
25
31
 
26
32
  /**
27
33
  * Determine whether an operation belongs to a critical risk category requiring lockedAsk (#52).
@@ -37,29 +43,12 @@ export function isCriticalOperation(toolName, command, execution) {
37
43
  const tool = String(toolName || "").trim();
38
44
  if (!cmd && !tool) return false;
39
45
 
40
- // 1. Root and privilege escalation
41
- if (/^(sudo|su|doas)\b/i.test(cmd)) return true;
42
- if (/\b(chmod\s+(-[a-zA-Z]*\s+)?777|chown\s+(-[a-zA-Z]*\s+)?root)\b/i.test(cmd)) return true;
43
-
44
- // 2. Recursive or bulk destructive deletion
45
- if (/\brm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s+|--recursive\s+)/i.test(cmd)) return true;
46
- if (/\b(shred|wipefs)\b/i.test(cmd)) return true;
47
-
48
- // 3. Sensitive credentials and SSH access
46
+ if (/^(sudo|su|doas)\b/i.test(cmd) || /\b(chmod\s+(-[a-zA-Z]*\s+)?777|chown\s+(-[a-zA-Z]*\s+)?root)\b/i.test(cmd)) return true;
47
+ if (/\brm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s+|--recursive\s+)/i.test(cmd) || /\b(shred|wipefs)\b/i.test(cmd)) return true;
49
48
  if (/(\.ssh\/(id_|authorized_keys|known_hosts)|\.gnupg\/|\/etc\/shadow)/i.test(cmd)) return true;
50
-
51
- // 4. Raw disk and filesystem manipulation
52
- if (/\b(mkfs|fdisk|parted)\b/i.test(cmd)) return true;
53
- if (/\bdd\s+.*of=\/dev\/(sd[a-z]|nvme|vd[a-z]|hd[a-z]|loop)/i.test(cmd)) return true;
54
-
55
- // 5. Remote shell piping and reverse shells
56
- if (/curl\s+.*\|\s*(bash|sh|python|perl)/i.test(cmd)) return true;
57
- if (/\b(nc|ncat|netcat)\s+.*-e\s+/i.test(cmd)) return true;
58
- if (/>&\s*\/dev\/tcp\//i.test(cmd)) return true;
59
-
60
- // 6. System halts and service shutdowns
61
- if (/\b(shutdown|reboot|poweroff|halt|init\s+0)\b/i.test(cmd)) return true;
62
- if (/systemctl\s+(stop|disable|mask)\s+(sshd|systemd|ufw|iptables)/i.test(cmd)) return true;
49
+ if (/\b(mkfs|fdisk|parted)\b/i.test(cmd) || /\bdd\s+.*of=\/dev\/(sd[a-z]|nvme|vd[a-z]|hd[a-z]|loop)/i.test(cmd)) return true;
50
+ if (/curl\s+.*\|\s*(bash|sh|python|perl)/i.test(cmd) || /\b(nc|ncat|netcat)\s+.*-e\s+/i.test(cmd) || />&\s*\/dev\/tcp\//i.test(cmd)) return true;
51
+ if (/\b(shutdown|reboot|poweroff|halt|init\s+0)\b/i.test(cmd) || /systemctl\s+(stop|disable|mask)\s+(sshd|systemd|ufw|iptables)/i.test(cmd)) return true;
63
52
 
64
53
  // 7. Protected file writes via tools
65
54
  if (tool === "write_to_file" || tool === "edit" || tool === "replace_file_content" || tool === "str_replace_editor") {
@@ -99,6 +88,11 @@ export class SecurityEngine {
99
88
  this.versionGuard = options.versionGuard !== undefined ? options.versionGuard : new VersionGuard({ workspaceDir: this.inspectConfig?.workspaceDir });
100
89
  this.agentsRules = options.agentsRules !== undefined ? options.agentsRules : (loadAgentsRules(this.inspectConfig?.workspaceDir)?.rules || null);
101
90
  this.snapshotManager = options.snapshotManager !== undefined ? options.snapshotManager : new SnapshotManager({ workspaceDir: this.inspectConfig?.workspaceDir });
91
+ this.lineage = options.lineage !== undefined ? options.lineage : new LineageManager();
92
+ this.enableRedundantSandboxRecovery = options.enableRedundantSandboxRecovery !== false;
93
+ this.requireJustification = options.requireJustification === true;
94
+ this.sessionModes = options.sessionModes || new SessionModeRegistry(options.defaultSessionMode);
95
+ this.trajectoryTracker = options.trajectoryTracker !== undefined ? options.trajectoryTracker : new TrajectoryTracker();
102
96
  }
103
97
 
104
98
  /**
@@ -116,12 +110,7 @@ export class SecurityEngine {
116
110
 
117
111
  // Check Retry Exhaustion guard (#96)
118
112
  if (this.retryGuard && this.retryGuard.isExhausted(sessionId)) {
119
- const exhHit = {
120
- deny: true,
121
- reason: "retry-exhaustion",
122
- detail: "Retry exhaustion: consecutive blocked attempts exceeded threshold",
123
- snippet: command || toolName,
124
- };
113
+ const exhHit = { deny: true, reason: "retry-exhaustion", detail: "Retry exhaustion: consecutive blocked attempts exceeded threshold", snippet: command || toolName };
125
114
  const exhDecision = { verdict: "deny", stage: "P0", reason: "retry-exhaustion", detail: exhHit.detail, hit: exhHit };
126
115
  if (this.ledger) this.ledger.record(sessionId, "exhaustion", { reason: "retry-exhaustion", tool: toolName });
127
116
  if (this.counters) this.counters.record(exhDecision, execution);
@@ -157,6 +146,10 @@ export class SecurityEngine {
157
146
 
158
147
  let decision = this._evaluateWaterfall(execution, context, sessionId, toolName, command);
159
148
  this._enrichDecision(decision, execution, context);
149
+ if (this.sessionModes) {
150
+ const mode = this.sessionModes.getMode(sessionId, context?.preset || context?.sessionMode);
151
+ decision = applySessionModePolicy(mode, decision, { track: decision.track, riskBucket: decision.riskBucket, lockedAsk: decision.lockedAsk });
152
+ }
160
153
 
161
154
  // Post-decision hooks: ledger, retryGuard, cache
162
155
  if (decision.verdict === "deny") {
@@ -408,19 +401,8 @@ export class SecurityEngine {
408
401
  // If Circuit Breaker is tripped, force P4 Human Ask
409
402
  if (this.breaker.isTripped(sessionId)) {
410
403
  if (this.ledger) this.ledger.record(sessionId, "breaker", { reason: "circuit-breaker-tripped", tool: toolName });
411
- const breakerHit = {
412
- ask: true,
413
- reason: "circuit-breaker-tripped",
414
- detail: "Circuit breaker tripped due to repeated security denials. Manual confirmation required.",
415
- snippet: command || toolName,
416
- };
417
- const res = {
418
- verdict: "ask",
419
- stage: "P4",
420
- reason: "circuit-breaker-tripped",
421
- detail: breakerHit.detail,
422
- hit: breakerHit,
423
- };
404
+ const breakerHit = { ask: true, reason: "circuit-breaker-tripped", detail: "Circuit breaker tripped due to repeated security denials. Manual confirmation required.", snippet: command || toolName };
405
+ const res = { verdict: "ask", stage: "P4", reason: "circuit-breaker-tripped", detail: breakerHit.detail, hit: breakerHit };
424
406
  this._applyLockedAskIfCritical(res, toolName, command, execution);
425
407
  return res;
426
408
  }
@@ -432,7 +414,45 @@ export class SecurityEngine {
432
414
  return res;
433
415
  }
434
416
 
435
- // P2: Static Rules / Safe Allow
417
+ // Redundant Sandbox Recovery (#101)
418
+ if (this.enableRedundantSandboxRecovery && isRedundantSandboxRequest(command, context?.sandboxMode || context?.preset, { toolName, args: execution?.arguments })) {
419
+ const advice = buildRedundantSandboxRemediation(command);
420
+ const hit = { ask: true, reason: "redundant-sandbox-privilege", detail: advice, snippet: command };
421
+ const res = { verdict: "ask", stage: "P4", reason: "redundant-sandbox-privilege", detail: advice, redundantSandbox: true, hit };
422
+ this._applyLockedAskIfCritical(res, toolName, command, execution);
423
+ return res;
424
+ }
425
+
426
+ // Trajectory Drift (#68)
427
+ if (this.trajectoryTracker) {
428
+ const drift = this.trajectoryTracker.detect(sessionId, execution, context);
429
+ if (drift && drift.hasDrift) {
430
+ const driftHit = { ask: true, reason: drift.reason, detail: drift.detail, snippet: command || toolName };
431
+ const res = { verdict: "ask", stage: "P4", reason: drift.reason, detail: drift.detail, drift, hit: driftHit };
432
+ this._applyLockedAskIfCritical(res, toolName, command, execution);
433
+ return res;
434
+ }
435
+ }
436
+
437
+ // P2: Static Rules / Safe Allow (Bands Engine)
438
+ if (bandVerdict === "allow" && !this.requireJustification) {
439
+ this.breaker.resetConsecutive(sessionId);
440
+ return { verdict: "allow", stage: "P2", reason: "safe-allow", hit };
441
+ }
442
+
443
+ // Escalation Justification Protocol (#107)
444
+ const just = checkEscalationJustification(command, toolName, execution?.arguments, {
445
+ context,
446
+ justification: context?.justification || execution?.justification || execution?.arguments?.justification,
447
+ requireJustification: this.requireJustification,
448
+ });
449
+ if (just.required && !just.valid) {
450
+ const hit = { ask: true, reason: just.reason, detail: just.detail, snippet: command || toolName };
451
+ const res = { verdict: "ask", stage: "P4", reason: just.reason, detail: just.detail, justificationAdvice: just.advice, hit };
452
+ this._applyLockedAskIfCritical(res, toolName, command, execution);
453
+ return res;
454
+ }
455
+
436
456
  if (bandVerdict === "allow") {
437
457
  this.breaker.resetConsecutive(sessionId);
438
458
  return { verdict: "allow", stage: "P2", reason: "safe-allow", hit };
@@ -504,40 +524,13 @@ export class SecurityEngine {
504
524
  }
505
525
  }
506
526
 
507
- addGrant(options) {
508
- if (this.grantRegistry && typeof this.grantRegistry.addGrant === "function") {
509
- return this.grantRegistry.addGrant(options);
510
- }
511
- return null;
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
-
527
+ addGrant(options) { return this.grantRegistry?.addGrant ? this.grantRegistry.addGrant(options) : null; }
528
+ getGrants(sessionId) { return this.grantRegistry?.getGrants ? this.grantRegistry.getGrants(sessionId) : []; }
529
+ getLedgerSummary(sessionId) { return this.ledger ? this.ledger.getSummary(sessionId) : null; }
530
+ isRetryExhausted(sessionId) { return this.retryGuard ? this.retryGuard.isExhausted(sessionId) : false; }
531
+ clearCache() { if (this.cache) this.cache.clear(); }
532
+ getCounters() { return this.counters ? this.counters.getSnapshot() : null; }
533
+ resetCounters() { if (this.counters) this.counters.reset(); }
541
534
  resume(sessionId, reason) {
542
535
  if (this.retryGuard) this.retryGuard.reset(sessionId);
543
536
  if (this.overengineeringGuard) this.overengineeringGuard.reset(sessionId);
@@ -551,13 +544,19 @@ export class SecurityEngine {
551
544
  */
552
545
  _enrichDecision(decision, execution, context) {
553
546
  if (!decision) return decision;
547
+ const command = execution && execution.arguments && execution.arguments.command;
548
+ if (!decision.track) decision.track = classifyTrack(execution?.name, command, execution?.arguments, context);
549
+ if (!decision.dossier) decision.dossier = buildEvidenceDossier({ execution, decision, context, toolName: execution?.name, command });
554
550
  if (!decision.riskBucket) decision.riskBucket = categorizeRiskBucket(execution, context);
555
551
  if (!decision.riskTags || !Array.isArray(decision.riskTags)) decision.riskTags = extractRiskTags(execution, context);
556
552
  if (!decision.measureKind) decision.measureKind = classifyMeasureKind(execution, context);
557
- const command = execution && execution.arguments && execution.arguments.command;
558
- if (command && isRmCommand(command)) {
559
- decision.trashAlternative = suggestTrashAlternative(command, this.inspectConfig?.workspaceDir, context?.sessionId);
560
- }
553
+ if (command && isRmCommand(command)) decision.trashAlternative = suggestTrashAlternative(command, this.inspectConfig?.workspaceDir, context?.sessionId);
554
+ const callId = context?.callId || execution?.callId || execution?.id || context?.execution?.id;
555
+ const agentId = context?.agentId || context?.agent?.id || context?.session?.agentId || execution?.agentId;
556
+ const subagentId = context?.subagentId || context?.session?.subagentId || execution?.subagentId;
557
+ if (callId) decision.callId = callId;
558
+ if (agentId) decision.agentId = agentId;
559
+ if (subagentId) decision.subagentId = subagentId;
561
560
  return decision;
562
561
  }
563
562
 
@@ -570,6 +569,11 @@ export class SecurityEngine {
570
569
  getSnapshots(sessionId) { return this.snapshotManager ? this.snapshotManager.getSnapshots(sessionId) : []; }
571
570
  clearSnapshots(sessionId) { return this.snapshotManager ? this.snapshotManager.clearSnapshots(sessionId) : { cleared: 0 }; }
572
571
  clearAllSnapshots() { return this.snapshotManager ? this.snapshotManager.clearAllSnapshots() : { cleared: 0 }; }
572
+ getLineage(sessionId) { return this.lineage ? this.lineage.getLineage(sessionId) : [sessionId]; }
573
+ registerLineage(child, parent, root) { return this.lineage ? this.lineage.registerLineage(child, parent, root) : false; }
574
+ setSessionGoal(sessionId, goal, scopes) { if (this.trajectoryTracker) this.trajectoryTracker.setGoal(sessionId, goal, scopes); }
575
+ getSessionGoal(sessionId) { return this.trajectoryTracker ? this.trajectoryTracker.getGoal(sessionId) : null; }
576
+ setSessionMode(sessionId, mode) { return this.sessionModes ? this.sessionModes.setMode(sessionId, mode) : null; }
577
+ getSessionMode(sessionId, preset) { return this.sessionModes ? this.sessionModes.getMode(sessionId, preset) : null; }
573
578
  }
574
579
 
575
-