@goodandready/dsh-approval-gate 0.1.22 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.23
4
+
5
+ - Tech-debt: Версионирование схемы хранения сессионных вердиктов (`CURRENT_SCHEMA_VERSION = 3` в `lib/ledger.js`) с полной обратной совместимостью и бесшовной миграцией исторических форматов v1/v2/v3 (`migrateVerdictRecord`) (Refs: #73).
6
+ - Feature: Поддержка двухтрековых маркеров (`Dual-Track Markers` в `lib/dualtrack.js`: `TRACK_TYPES.INTERNAL` vs `TRACK_TYPES.EXTERNAL`) для четкого разделения скрытых служебных шагов агента и пользовательских мутаций проекта (Refs: #92).
7
+ - Feature: Сессионное переключение режимов гейта (`auto-approval` vs `manual` / `strict` / `hybrid` в `lib/session-modes.js`) на основе активного пресета сессии (`resolveSessionGateMode`, `applySessionModePolicy`, `SessionModeRegistry`) (Refs: #61).
8
+ - Feature: Сбор доказательного досье (`Evidence Dossier` в `lib/dossier.js`: инструмент, diff, терминал, исходный промпт юзера) с безопасной санитизацией чувствительных токенов и защитой от переполнения памяти (Refs: #64).
9
+ - Security: Детекция отклонения от траектории задачи (`Trajectory Drift Detection` в `lib/drift.js`: `TrajectoryTracker`) с перехватом несанкционированного сканирования сети, поиска учетных данных и системного зондирования вне рамок цели сессии (Refs: #68).
10
+
3
11
  ## 0.1.22
4
12
 
5
13
  - Feature: Наследование разрешений в иерархии субагентов через `parentSession lineage authority` (`LineageManager` в `lib/lineage.js`) с безопасным обходом дерева предков и защитой от циклических ссылок (Refs: #98).
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
@@ -24,6 +24,10 @@ import { classifyMeasureKind } from "./measure-kinds.js";
24
24
  import { SnapshotManager } from "./snapshots.js";
25
25
  import { LineageManager } from "./lineage.js";
26
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";
27
31
 
28
32
  /**
29
33
  * Determine whether an operation belongs to a critical risk category requiring lockedAsk (#52).
@@ -39,29 +43,12 @@ export function isCriticalOperation(toolName, command, execution) {
39
43
  const tool = String(toolName || "").trim();
40
44
  if (!cmd && !tool) return false;
41
45
 
42
- // 1. Root and privilege escalation
43
- if (/^(sudo|su|doas)\b/i.test(cmd)) return true;
44
- if (/\b(chmod\s+(-[a-zA-Z]*\s+)?777|chown\s+(-[a-zA-Z]*\s+)?root)\b/i.test(cmd)) return true;
45
-
46
- // 2. Recursive or bulk destructive deletion
47
- if (/\brm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s+|--recursive\s+)/i.test(cmd)) return true;
48
- if (/\b(shred|wipefs)\b/i.test(cmd)) return true;
49
-
50
- // 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;
51
48
  if (/(\.ssh\/(id_|authorized_keys|known_hosts)|\.gnupg\/|\/etc\/shadow)/i.test(cmd)) return true;
52
-
53
- // 4. Raw disk and filesystem manipulation
54
- if (/\b(mkfs|fdisk|parted)\b/i.test(cmd)) return true;
55
- if (/\bdd\s+.*of=\/dev\/(sd[a-z]|nvme|vd[a-z]|hd[a-z]|loop)/i.test(cmd)) return true;
56
-
57
- // 5. Remote shell piping and reverse shells
58
- if (/curl\s+.*\|\s*(bash|sh|python|perl)/i.test(cmd)) return true;
59
- if (/\b(nc|ncat|netcat)\s+.*-e\s+/i.test(cmd)) return true;
60
- if (/>&\s*\/dev\/tcp\//i.test(cmd)) return true;
61
-
62
- // 6. System halts and service shutdowns
63
- if (/\b(shutdown|reboot|poweroff|halt|init\s+0)\b/i.test(cmd)) return true;
64
- 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;
65
52
 
66
53
  // 7. Protected file writes via tools
67
54
  if (tool === "write_to_file" || tool === "edit" || tool === "replace_file_content" || tool === "str_replace_editor") {
@@ -104,6 +91,8 @@ export class SecurityEngine {
104
91
  this.lineage = options.lineage !== undefined ? options.lineage : new LineageManager();
105
92
  this.enableRedundantSandboxRecovery = options.enableRedundantSandboxRecovery !== false;
106
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();
107
96
  }
108
97
 
109
98
  /**
@@ -121,12 +110,7 @@ export class SecurityEngine {
121
110
 
122
111
  // Check Retry Exhaustion guard (#96)
123
112
  if (this.retryGuard && this.retryGuard.isExhausted(sessionId)) {
124
- const exhHit = {
125
- deny: true,
126
- reason: "retry-exhaustion",
127
- detail: "Retry exhaustion: consecutive blocked attempts exceeded threshold",
128
- snippet: command || toolName,
129
- };
113
+ const exhHit = { deny: true, reason: "retry-exhaustion", detail: "Retry exhaustion: consecutive blocked attempts exceeded threshold", snippet: command || toolName };
130
114
  const exhDecision = { verdict: "deny", stage: "P0", reason: "retry-exhaustion", detail: exhHit.detail, hit: exhHit };
131
115
  if (this.ledger) this.ledger.record(sessionId, "exhaustion", { reason: "retry-exhaustion", tool: toolName });
132
116
  if (this.counters) this.counters.record(exhDecision, execution);
@@ -162,6 +146,10 @@ export class SecurityEngine {
162
146
 
163
147
  let decision = this._evaluateWaterfall(execution, context, sessionId, toolName, command);
164
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
+ }
165
153
 
166
154
  // Post-decision hooks: ledger, retryGuard, cache
167
155
  if (decision.verdict === "deny") {
@@ -413,19 +401,8 @@ export class SecurityEngine {
413
401
  // If Circuit Breaker is tripped, force P4 Human Ask
414
402
  if (this.breaker.isTripped(sessionId)) {
415
403
  if (this.ledger) this.ledger.record(sessionId, "breaker", { reason: "circuit-breaker-tripped", tool: toolName });
416
- const breakerHit = {
417
- ask: true,
418
- reason: "circuit-breaker-tripped",
419
- detail: "Circuit breaker tripped due to repeated security denials. Manual confirmation required.",
420
- snippet: command || toolName,
421
- };
422
- const res = {
423
- verdict: "ask",
424
- stage: "P4",
425
- reason: "circuit-breaker-tripped",
426
- detail: breakerHit.detail,
427
- hit: breakerHit,
428
- };
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 };
429
406
  this._applyLockedAskIfCritical(res, toolName, command, execution);
430
407
  return res;
431
408
  }
@@ -446,6 +423,17 @@ export class SecurityEngine {
446
423
  return res;
447
424
  }
448
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
+
449
437
  // P2: Static Rules / Safe Allow (Bands Engine)
450
438
  if (bandVerdict === "allow" && !this.requireJustification) {
451
439
  this.breaker.resetConsecutive(sessionId);
@@ -556,16 +544,16 @@ export class SecurityEngine {
556
544
  */
557
545
  _enrichDecision(decision, execution, context) {
558
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 });
559
550
  if (!decision.riskBucket) decision.riskBucket = categorizeRiskBucket(execution, context);
560
551
  if (!decision.riskTags || !Array.isArray(decision.riskTags)) decision.riskTags = extractRiskTags(execution, context);
561
552
  if (!decision.measureKind) decision.measureKind = classifyMeasureKind(execution, context);
562
- const command = execution && execution.arguments && execution.arguments.command;
563
- if (command && isRmCommand(command)) {
564
- decision.trashAlternative = suggestTrashAlternative(command, this.inspectConfig?.workspaceDir, context?.sessionId);
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;
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;
569
557
  if (callId) decision.callId = callId;
570
558
  if (agentId) decision.agentId = agentId;
571
559
  if (subagentId) decision.subagentId = subagentId;
@@ -583,6 +571,9 @@ export class SecurityEngine {
583
571
  clearAllSnapshots() { return this.snapshotManager ? this.snapshotManager.clearAllSnapshots() : { cleared: 0 }; }
584
572
  getLineage(sessionId) { return this.lineage ? this.lineage.getLineage(sessionId) : [sessionId]; }
585
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; }
586
578
  }
587
579
 
588
-
package/lib/index.js CHANGED
@@ -532,12 +532,7 @@ export function apply(ctx, config) {
532
532
 
533
533
  export { SecurityEngine } from "./engine.js";
534
534
  export { BandsEngine } from "./bands.js";
535
- export {
536
- CircuitBreaker,
537
- CIRCUIT_STATE_ACTIVE,
538
- CIRCUIT_STATE_TRIPPED,
539
- CIRCUIT_STATE_RESUMED,
540
- } from "./breaker.js";
535
+ export { CircuitBreaker, CIRCUIT_STATE_ACTIVE, CIRCUIT_STATE_TRIPPED, CIRCUIT_STATE_RESUMED } from "./breaker.js";
541
536
  export { SessionGrantStore, DEFAULT_GRANT_TTL_MS, DEFAULT_GRANT_MAX_USES, matchGrantCriteria } from "./grants.js";
542
537
  export { canonicalizeCall, canonicalString, hashCall, sortKeys } from "./canonical.js";
543
538
  export { expandHome, resolveSafePath, isPathTraversal, canonicalizeFsPath } from "./paths.js";
@@ -550,7 +545,7 @@ export { isLoopbackHost, isLoopbackUrl, isLoopbackCommand } from "./loopback.js"
550
545
  export { computeFingerprint, computeFingerprintHash, normalizeCommandString } from "./fingerprint.js";
551
546
  export { VerdictCache, DEFAULT_CACHE_MAX_SIZE, DEFAULT_CACHE_TTL_MS } from "./cache.js";
552
547
  export { RetryExhaustionGuard, DEFAULT_MAX_RETRY_ATTEMPTS } from "./retry-exhaustion.js";
553
- export { QualityLedger, MAX_LEDGER_EVENTS_PER_SESSION } from "./ledger.js";
548
+ export { QualityLedger, MAX_LEDGER_EVENTS_PER_SESSION, CURRENT_SCHEMA_VERSION, SUPPORTED_SCHEMA_VERSIONS, migrateVerdictRecord } from "./ledger.js";
554
549
  export { AuditLogger, DEFAULT_AUDIT_LOG_RELATIVE, DEFAULT_MAX_AUDIT_BYTES, DEFAULT_MAX_AUDIT_LINES, DEFAULT_RETAIN_LINES, auditRotateContent } from "./audit-log.js";
555
550
  export { BlockCounters, categorizeReason } from "./counters.js";
556
551
  export { isCriticalOperation } from "./engine.js";
@@ -575,6 +570,7 @@ export { MEASURE_KINDS, classifyMeasureKind, isSystemLevelExecution, isWorkspace
575
570
  export { LineageManager } from "./lineage.js";
576
571
  export { generateAgentGuidance, injectAgentGuidance, extractGuidance, GUIDANCE_MARKER } from "./guidance.js";
577
572
  export { isRedundantSandboxRequest, buildRedundantSandboxRemediation, checkEscalationJustification, REDUNDANT_SANDBOX_REASON, MISSING_JUSTIFICATION_REASON, INSUFFICIENT_JUSTIFICATION_REASON } from "./escalation.js";
578
-
579
-
580
-
573
+ export { TRACK_TYPES, classifyTrack, isInternalTrack, isExternalTrack } from "./dualtrack.js";
574
+ export { GATE_MODES, PRESET_MODE_MAP, resolveSessionGateMode, applySessionModePolicy, SessionModeRegistry } from "./session-modes.js";
575
+ export { buildEvidenceDossier, formatDossierSummary, MAX_PROMPT_CHARS, MAX_DIFF_CHARS, MAX_OUTPUT_CHARS } from "./dossier.js";
576
+ export { DRIFT_CATEGORIES, detectTrajectoryDrift, TrajectoryTracker } from "./drift.js";
package/lib/ledger.js CHANGED
@@ -1,164 +1,264 @@
1
- // lib/ledger.js
2
- // Quality and Anomaly Ledger for dsh-approval-gate (#88).
3
- // Tracks session health, consecutive denials, and execution anomalies.
4
-
5
- export const MAX_LEDGER_EVENTS_PER_SESSION = 50;
6
-
7
- /**
8
- * @typedef {Object} LedgerEvent
9
- * @property {number} timestamp
10
- * @property {string} type - "allow" | "deny" | "ask" | "exhaustion" | "breaker" | "error"
11
- * @property {string} reason
12
- * @property {string} [detail]
13
- * @property {string} [tool]
14
- */
15
-
16
- /**
17
- * @typedef {Object} SessionLedger
18
- * @property {string} sessionId
19
- * @property {number} totalDecisions
20
- * @property {number} allowedCount
21
- * @property {number} deniedCount
22
- * @property {number} askCount
23
- * @property {number} anomalyCount
24
- * @property {number} consecutiveDenies
25
- * @property {boolean} breakerTripped
26
- * @property {boolean} retryExhausted
27
- * @property {LedgerEvent[]} recentEvents
28
- */
29
-
30
- export class QualityLedger {
31
- constructor() {
32
- /** @type {Map<string, SessionLedger>} */
33
- this.sessions = new Map();
34
- }
35
-
36
- /**
37
- * @private
38
- */
39
- _getOrCreate(sessionId) {
40
- const key = sessionId || "default";
41
- let ledger = this.sessions.get(key);
42
- if (!ledger) {
43
- ledger = {
44
- sessionId: key,
45
- totalDecisions: 0,
46
- allowedCount: 0,
47
- deniedCount: 0,
48
- askCount: 0,
49
- anomalyCount: 0,
50
- consecutiveDenies: 0,
51
- breakerTripped: false,
52
- retryExhausted: false,
53
- recentEvents: [],
54
- };
55
- this.sessions.set(key, ledger);
56
- }
57
- return ledger;
58
- }
59
-
60
- /**
61
- * Record a decision or system event in the ledger.
62
- *
63
- * @param {string} sessionId
64
- * @param {string} type - "allow" | "deny" | "ask" | "exhaustion" | "breaker" | "error"
65
- * @param {{ reason?: string, detail?: string, tool?: string }} [details]
66
- */
67
- record(sessionId, type, details = {}) {
68
- const ledger = this._getOrCreate(sessionId);
69
- const now = Date.now();
70
-
71
- const event = {
72
- timestamp: now,
73
- type,
74
- reason: details.reason || "",
75
- detail: details.detail ? String(details.detail).slice(0, 200) : undefined,
76
- tool: details.tool || "",
77
- };
78
-
79
- ledger.recentEvents.push(event);
80
- if (ledger.recentEvents.length > MAX_LEDGER_EVENTS_PER_SESSION) {
81
- ledger.recentEvents.shift();
82
- }
83
-
84
- if (type === "allow") {
85
- ledger.totalDecisions += 1;
86
- ledger.allowedCount += 1;
87
- ledger.consecutiveDenies = 0;
88
- } else if (type === "deny") {
89
- ledger.totalDecisions += 1;
90
- ledger.deniedCount += 1;
91
- ledger.consecutiveDenies += 1;
92
- if (ledger.consecutiveDenies > 1) {
93
- ledger.anomalyCount += 1;
94
- }
95
- } else if (type === "ask") {
96
- ledger.totalDecisions += 1;
97
- ledger.askCount += 1;
98
- } else if (type === "exhaustion") {
99
- ledger.retryExhausted = true;
100
- ledger.anomalyCount += 1;
101
- } else if (type === "breaker") {
102
- ledger.breakerTripped = true;
103
- ledger.anomalyCount += 1;
104
- } else if (type === "error") {
105
- ledger.anomalyCount += 1;
106
- }
107
- }
108
-
109
- /**
110
- * Returns a quality summary for the session.
111
- *
112
- * @param {string} sessionId
113
- * @returns {{
114
- * sessionId: string,
115
- * totalDecisions: number,
116
- * allowedCount: number,
117
- * deniedCount: number,
118
- * askCount: number,
119
- * anomalyCount: number,
120
- * consecutiveDenies: number,
121
- * qualityScore: number,
122
- * degradationWarning: boolean,
123
- * recentEvents: LedgerEvent[]
124
- * }}
125
- */
126
- getSummary(sessionId) {
127
- const ledger = this._getOrCreate(sessionId);
128
- const total = ledger.totalDecisions;
129
-
130
- // Quality score: 1.0 down towards 0.0 based on denial & anomaly ratio
131
- let score = 1.0;
132
- if (total > 0) {
133
- const penalty = (ledger.deniedCount * 0.2 + ledger.anomalyCount * 0.3) / total;
134
- score = Math.max(0, Math.min(1.0, 1.0 - penalty));
135
- }
136
-
137
- const degradationWarning = ledger.consecutiveDenies >= 2 ||
138
- ledger.retryExhausted ||
139
- ledger.breakerTripped ||
140
- (total >= 3 && ledger.deniedCount / total >= 0.5);
141
-
142
- return {
143
- sessionId: ledger.sessionId,
144
- totalDecisions: ledger.totalDecisions,
145
- allowedCount: ledger.allowedCount,
146
- deniedCount: ledger.deniedCount,
147
- askCount: ledger.askCount,
148
- anomalyCount: ledger.anomalyCount,
149
- consecutiveDenies: ledger.consecutiveDenies,
150
- qualityScore: Number(score.toFixed(2)),
151
- degradationWarning,
152
- recentEvents: ledger.recentEvents.map((e) => ({ ...e })),
153
- };
154
- }
155
-
156
- reset(sessionId) {
157
- const key = sessionId || "default";
158
- this.sessions.delete(key);
159
- }
160
-
161
- clear() {
162
- this.sessions.clear();
163
- }
164
- }
1
+ // lib/ledger.js
2
+ // Quality and Anomaly Ledger for dsh-approval-gate (#88, #73).
3
+ // Tracks session health, consecutive denials, and execution anomalies with schema versioning.
4
+
5
+ export const MAX_LEDGER_EVENTS_PER_SESSION = 50;
6
+
7
+ export const CURRENT_SCHEMA_VERSION = 3;
8
+ export const SUPPORTED_SCHEMA_VERSIONS = Object.freeze([1, 2, 3]);
9
+
10
+ /**
11
+ * Migrates a verdict record across schema versions (v1 -> v2 -> v3) (#73).
12
+ * - v1: { id, verdict, ruleId, timestamp, riskBucket }
13
+ * - v2: + { reviewDecision, bandVerdict, mitigation, auditSummary }
14
+ * - v3: + { track, dossier, driftScore, driftWarning, schema_version: 3 }
15
+ *
16
+ * @param {Record<string, any>} record
17
+ * @param {number} [targetVersion=CURRENT_SCHEMA_VERSION]
18
+ * @returns {Record<string, any>}
19
+ */
20
+ export function migrateVerdictRecord(record, targetVersion = CURRENT_SCHEMA_VERSION) {
21
+ if (!record || typeof record !== "object") return record;
22
+
23
+ let currentVersion = record.schema_version;
24
+ if (!currentVersion) {
25
+ // Infer version from fields
26
+ if (record.reviewDecision !== undefined || record.bandVerdict !== undefined) {
27
+ currentVersion = 2;
28
+ } else {
29
+ currentVersion = 1;
30
+ }
31
+ }
32
+
33
+ let migrated = { ...record };
34
+
35
+ // v1 -> v2 migration
36
+ if (currentVersion < 2 && targetVersion >= 2) {
37
+ migrated.reviewDecision = migrated.reviewDecision || migrated.verdict || "allow";
38
+ migrated.bandVerdict = migrated.bandVerdict || migrated.verdict || "allow";
39
+ migrated.mitigation = migrated.mitigation || null;
40
+ migrated.auditSummary = migrated.auditSummary || null;
41
+ migrated.schema_version = 2;
42
+ currentVersion = 2;
43
+ }
44
+
45
+ // v2 -> v3 migration
46
+ if (currentVersion < 3 && targetVersion >= 3) {
47
+ migrated.track = migrated.track || "external";
48
+ migrated.dossier = migrated.dossier || null;
49
+ migrated.driftScore = typeof migrated.driftScore === "number" ? migrated.driftScore : 0;
50
+ migrated.driftWarning = Boolean(migrated.driftWarning);
51
+ migrated.schema_version = 3;
52
+ currentVersion = 3;
53
+ }
54
+
55
+ migrated.schema_version = currentVersion;
56
+ return migrated;
57
+ }
58
+
59
+ /**
60
+ * @typedef {Object} LedgerEvent
61
+ * @property {number} timestamp
62
+ * @property {string} type - "allow" | "deny" | "ask" | "exhaustion" | "breaker" | "error"
63
+ * @property {string} reason
64
+ * @property {string} [detail]
65
+ * @property {string} [tool]
66
+ * @property {number} [schema_version]
67
+ */
68
+
69
+ /**
70
+ * @typedef {Object} SessionLedger
71
+ * @property {string} sessionId
72
+ * @property {number} totalDecisions
73
+ * @property {number} allowedCount
74
+ * @property {number} deniedCount
75
+ * @property {number} askCount
76
+ * @property {number} anomalyCount
77
+ * @property {number} consecutiveDenies
78
+ * @property {boolean} breakerTripped
79
+ * @property {boolean} retryExhausted
80
+ * @property {LedgerEvent[]} recentEvents
81
+ */
82
+
83
+ export class QualityLedger {
84
+ constructor() {
85
+ /** @type {Map<string, SessionLedger>} */
86
+ this.sessions = new Map();
87
+ }
88
+
89
+ /**
90
+ * @private
91
+ */
92
+ _getOrCreate(sessionId) {
93
+ const key = sessionId || "default";
94
+ let ledger = this.sessions.get(key);
95
+ if (!ledger) {
96
+ ledger = {
97
+ sessionId: key,
98
+ totalDecisions: 0,
99
+ allowedCount: 0,
100
+ deniedCount: 0,
101
+ askCount: 0,
102
+ anomalyCount: 0,
103
+ consecutiveDenies: 0,
104
+ breakerTripped: false,
105
+ retryExhausted: false,
106
+ recentEvents: [],
107
+ };
108
+ this.sessions.set(key, ledger);
109
+ }
110
+ return ledger;
111
+ }
112
+
113
+ /**
114
+ * Record a decision or system event in the ledger.
115
+ *
116
+ * @param {string} sessionId
117
+ * @param {string} type - "allow" | "deny" | "ask" | "exhaustion" | "breaker" | "error"
118
+ * @param {{ reason?: string, detail?: string, tool?: string, track?: string, dossier?: any }} [details]
119
+ */
120
+ record(sessionId, type, details = {}) {
121
+ const ledger = this._getOrCreate(sessionId);
122
+ const now = Date.now();
123
+
124
+ const rawEvent = {
125
+ timestamp: now,
126
+ type,
127
+ reason: details.reason || "",
128
+ detail: details.detail ? String(details.detail).slice(0, 200) : undefined,
129
+ tool: details.tool || "",
130
+ track: details.track,
131
+ dossier: details.dossier,
132
+ schema_version: CURRENT_SCHEMA_VERSION,
133
+ };
134
+
135
+ const event = migrateVerdictRecord(rawEvent, CURRENT_SCHEMA_VERSION);
136
+
137
+ ledger.recentEvents.push(event);
138
+ if (ledger.recentEvents.length > MAX_LEDGER_EVENTS_PER_SESSION) {
139
+ ledger.recentEvents.shift();
140
+ }
141
+
142
+ if (type === "allow") {
143
+ ledger.totalDecisions += 1;
144
+ ledger.allowedCount += 1;
145
+ ledger.consecutiveDenies = 0;
146
+ } else if (type === "deny") {
147
+ ledger.totalDecisions += 1;
148
+ ledger.deniedCount += 1;
149
+ ledger.consecutiveDenies += 1;
150
+ if (ledger.consecutiveDenies > 1) {
151
+ ledger.anomalyCount += 1;
152
+ }
153
+ } else if (type === "ask") {
154
+ ledger.totalDecisions += 1;
155
+ ledger.askCount += 1;
156
+ } else if (type === "exhaustion") {
157
+ ledger.retryExhausted = true;
158
+ ledger.anomalyCount += 1;
159
+ } else if (type === "breaker") {
160
+ ledger.breakerTripped = true;
161
+ ledger.anomalyCount += 1;
162
+ } else if (type === "error") {
163
+ ledger.anomalyCount += 1;
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Returns a quality summary for the session.
169
+ *
170
+ * @param {string} sessionId
171
+ * @returns {{
172
+ * sessionId: string,
173
+ * totalDecisions: number,
174
+ * allowedCount: number,
175
+ * deniedCount: number,
176
+ * askCount: number,
177
+ * anomalyCount: number,
178
+ * consecutiveDenies: number,
179
+ * qualityScore: number,
180
+ * degradationWarning: boolean,
181
+ * recentEvents: LedgerEvent[]
182
+ * }}
183
+ */
184
+ getSummary(sessionId) {
185
+ const ledger = this._getOrCreate(sessionId);
186
+ const total = ledger.totalDecisions;
187
+
188
+ // Quality score: 1.0 down towards 0.0 based on denial & anomaly ratio
189
+ let score = 1.0;
190
+ if (total > 0) {
191
+ const penalty = (ledger.deniedCount * 0.2 + ledger.anomalyCount * 0.3) / total;
192
+ score = Math.max(0, Math.min(1.0, 1.0 - penalty));
193
+ }
194
+
195
+ const degradationWarning = ledger.consecutiveDenies >= 2 ||
196
+ ledger.retryExhausted ||
197
+ ledger.breakerTripped ||
198
+ (total >= 3 && ledger.deniedCount / total >= 0.5);
199
+
200
+ return {
201
+ sessionId: ledger.sessionId,
202
+ totalDecisions: ledger.totalDecisions,
203
+ allowedCount: ledger.allowedCount,
204
+ deniedCount: ledger.deniedCount,
205
+ askCount: ledger.askCount,
206
+ anomalyCount: ledger.anomalyCount,
207
+ consecutiveDenies: ledger.consecutiveDenies,
208
+ qualityScore: Number(score.toFixed(2)),
209
+ degradationWarning,
210
+ recentEvents: ledger.recentEvents.map((e) => ({ ...e })),
211
+ };
212
+ }
213
+
214
+ /**
215
+ * Serializes session ledger state with current schema version (#73).
216
+ * @param {string} sessionId
217
+ * @returns {Record<string, any>}
218
+ */
219
+ serializeSession(sessionId) {
220
+ const ledger = this._getOrCreate(sessionId);
221
+ return {
222
+ schema_version: CURRENT_SCHEMA_VERSION,
223
+ sessionId: ledger.sessionId,
224
+ totalDecisions: ledger.totalDecisions,
225
+ allowedCount: ledger.allowedCount,
226
+ deniedCount: ledger.deniedCount,
227
+ askCount: ledger.askCount,
228
+ anomalyCount: ledger.anomalyCount,
229
+ consecutiveDenies: ledger.consecutiveDenies,
230
+ breakerTripped: ledger.breakerTripped,
231
+ retryExhausted: ledger.retryExhausted,
232
+ recentEvents: ledger.recentEvents.map((e) => migrateVerdictRecord(e, CURRENT_SCHEMA_VERSION)),
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Restores session ledger state from serialized data with automatic migration (#73).
238
+ * @param {Record<string, any>} data
239
+ */
240
+ deserializeSession(data) {
241
+ if (!data || !data.sessionId) return;
242
+ const ledger = this._getOrCreate(data.sessionId);
243
+ ledger.totalDecisions = data.totalDecisions || 0;
244
+ ledger.allowedCount = data.allowedCount || 0;
245
+ ledger.deniedCount = data.deniedCount || 0;
246
+ ledger.askCount = data.askCount || 0;
247
+ ledger.anomalyCount = data.anomalyCount || 0;
248
+ ledger.consecutiveDenies = data.consecutiveDenies || 0;
249
+ ledger.breakerTripped = Boolean(data.breakerTripped);
250
+ ledger.retryExhausted = Boolean(data.retryExhausted);
251
+ ledger.recentEvents = Array.isArray(data.recentEvents)
252
+ ? data.recentEvents.map((e) => migrateVerdictRecord(e, CURRENT_SCHEMA_VERSION))
253
+ : [];
254
+ }
255
+
256
+ reset(sessionId) {
257
+ const key = sessionId || "default";
258
+ this.sessions.delete(key);
259
+ }
260
+
261
+ clear() {
262
+ this.sessions.clear();
263
+ }
264
+ }
@@ -0,0 +1,146 @@
1
+ // lib/session-modes.js
2
+ // Session-level Gate Mode Switching (#61).
3
+ // Allows per-session mode switching between auto-approval, hybrid, manual, and strict modes.
4
+
5
+ export const GATE_MODES = Object.freeze({
6
+ AUTO: "auto-approval",
7
+ MANUAL: "manual",
8
+ HYBRID: "hybrid",
9
+ STRICT: "strict",
10
+ });
11
+
12
+ export const PRESET_MODE_MAP = Object.freeze({
13
+ sandbox: GATE_MODES.AUTO,
14
+ test: GATE_MODES.AUTO,
15
+ auto: GATE_MODES.AUTO,
16
+ "auto-approval": GATE_MODES.AUTO,
17
+ prod: GATE_MODES.STRICT,
18
+ production: GATE_MODES.STRICT,
19
+ strict: GATE_MODES.STRICT,
20
+ manual: GATE_MODES.MANUAL,
21
+ dev: GATE_MODES.HYBRID,
22
+ development: GATE_MODES.HYBRID,
23
+ hybrid: GATE_MODES.HYBRID,
24
+ standard: GATE_MODES.HYBRID,
25
+ });
26
+
27
+ /**
28
+ * Resolves active gate mode for a session based on presets, context, or defaults.
29
+ *
30
+ * @param {string} [sessionId]
31
+ * @param {string} [sessionPreset]
32
+ * @param {Record<string, any>} [options]
33
+ * @returns {string}
34
+ */
35
+ export function resolveSessionGateMode(sessionId, sessionPreset, options = {}) {
36
+ // 1. Explicit mode in options or context
37
+ if (options.mode && Object.values(GATE_MODES).includes(options.mode)) {
38
+ return options.mode;
39
+ }
40
+ if (options.sessionMode && Object.values(GATE_MODES).includes(options.sessionMode)) {
41
+ return options.sessionMode;
42
+ }
43
+
44
+ // 2. Preset mapping
45
+ const presetKey = String(sessionPreset || options.preset || "").toLowerCase().trim();
46
+ if (presetKey && PRESET_MODE_MAP[presetKey]) {
47
+ return PRESET_MODE_MAP[presetKey];
48
+ }
49
+
50
+ // 3. Fallback default
51
+ return options.defaultMode || GATE_MODES.HYBRID;
52
+ }
53
+
54
+ /**
55
+ * Applies session gate mode policy to a verdict decision (#61).
56
+ *
57
+ * @param {string} mode - Gate mode
58
+ * @param {Record<string, any>} decision - Original verdict decision
59
+ * @param {{ track?: string, riskBucket?: string, lockedAsk?: boolean, isRead?: boolean }} [meta]
60
+ * @returns {Record<string, any>}
61
+ */
62
+ export function applySessionModePolicy(mode, decision, meta = {}) {
63
+ if (!decision) return decision;
64
+
65
+ const currentMode = mode || GATE_MODES.HYBRID;
66
+ const isLocked = Boolean(decision.lockedAsk || meta.lockedAsk);
67
+ const track = meta.track || decision.track || "external";
68
+ const riskBucket = meta.riskBucket || decision.riskBucket || "medium";
69
+
70
+ // Auto-approval mode: relaxes low-risk or internal chores that would otherwise ask
71
+ if (currentMode === GATE_MODES.AUTO && decision.verdict === "ask" && !isLocked) {
72
+ const isInternal = track === "internal";
73
+ const isLowRisk = riskBucket === "low" || riskBucket === "minimal";
74
+ if (isInternal || isLowRisk) {
75
+ return {
76
+ ...decision,
77
+ verdict: "allow",
78
+ autoApproved: true,
79
+ originalVerdict: "ask",
80
+ sessionMode: currentMode,
81
+ stage: "session-mode-auto",
82
+ autoReason: isInternal ? "auto-approve-internal-track" : "auto-approve-low-risk",
83
+ };
84
+ }
85
+ }
86
+
87
+ // Manual or strict mode: requires manual confirmation for external mutations or higher risk
88
+ if ((currentMode === GATE_MODES.MANUAL || currentMode === GATE_MODES.STRICT) && decision.verdict === "allow") {
89
+ const isSafeRead = Boolean(meta.isRead);
90
+ const isInternalSafe = track === "internal" && (riskBucket === "low" || riskBucket === "minimal");
91
+ if (!isSafeRead && !isInternalSafe) {
92
+ return {
93
+ ...decision,
94
+ verdict: "ask",
95
+ manualRequired: true,
96
+ originalVerdict: "allow",
97
+ sessionMode: currentMode,
98
+ stage: "session-mode-manual",
99
+ manualReason: currentMode === GATE_MODES.STRICT ? "strict-mode-enforcement" : "manual-mode-enforcement",
100
+ };
101
+ }
102
+ }
103
+
104
+ return {
105
+ ...decision,
106
+ sessionMode: currentMode,
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Registry for maintaining per-session gate modes (#61).
112
+ */
113
+ export class SessionModeRegistry {
114
+ constructor(defaultMode = GATE_MODES.HYBRID) {
115
+ this.defaultMode = defaultMode;
116
+ /** @type {Map<string, string>} */
117
+ this.sessionModes = new Map();
118
+ }
119
+
120
+ setMode(sessionId, mode) {
121
+ const key = sessionId || "default";
122
+ const resolved = PRESET_MODE_MAP[String(mode).toLowerCase().trim()] || mode;
123
+ if (Object.values(GATE_MODES).includes(resolved)) {
124
+ this.sessionModes.set(key, resolved);
125
+ return resolved;
126
+ }
127
+ return this.defaultMode;
128
+ }
129
+
130
+ getMode(sessionId, preset) {
131
+ const key = sessionId || "default";
132
+ if (this.sessionModes.has(key)) {
133
+ return this.sessionModes.get(key);
134
+ }
135
+ return resolveSessionGateMode(sessionId, preset, { defaultMode: this.defaultMode });
136
+ }
137
+
138
+ clearSession(sessionId) {
139
+ const key = sessionId || "default";
140
+ this.sessionModes.delete(key);
141
+ }
142
+
143
+ reset() {
144
+ this.sessionModes.clear();
145
+ }
146
+ }
package/package.json CHANGED
@@ -1,55 +1,55 @@
1
- {
2
- "name": "@goodandready/dsh-approval-gate",
3
- "version": "0.1.22",
4
- "description": "Host-only command safety gate for DeepSeek Harness: blocks recognized dangerous operations and requests DSH approval when a command cannot be inspected.",
5
- "type": "module",
6
- "main": "lib/index.js",
7
- "exports": {
8
- ".": "./lib/index.js",
9
- "./package.json": "./package.json",
10
- "./cordis.patch.yml": "./cordis.patch.yml"
11
- },
12
- "files": [
13
- "lib",
14
- "rules",
15
- "cordis.patch.yml",
16
- "README.md",
17
- "README.zh.md",
18
- "README.ru.md",
19
- "CHANGELOG.md",
20
- "LICENSE"
21
- ],
22
- "dsh": {
23
- "bundle": {
24
- "patch": "./cordis.patch.yml"
25
- }
26
- },
27
- "license": "MIT",
28
- "peerDependencies": {
29
- "@deepseek-ai/cordis": "^4.0.1",
30
- "@deepseek-ai/schemastery": "^3.18.1"
31
- },
32
- "repository": {
33
- "type": "git",
34
- "url": "git+https://github.com/GooDAnDReaDY/dsh-approval-gate.git"
35
- },
36
- "homepage": "https://github.com/GooDAnDReaDY/dsh-approval-gate#readme",
37
- "bugs": {
38
- "url": "https://github.com/GooDAnDReaDY/dsh-approval-gate/issues"
39
- },
40
- "scripts": {
41
- "test": "node --test test/*.test.mjs"
42
- },
43
- "publishConfig": {
44
- "registry": "https://registry.npmjs.org",
45
- "access": "public"
46
- },
47
- "keywords": [
48
- "dsh",
49
- "dsh-plugin",
50
- "deepseek-harness",
51
- "security",
52
- "approval-gate",
53
- "safety"
54
- ]
55
- }
1
+ {
2
+ "name": "@goodandready/dsh-approval-gate",
3
+ "version": "0.1.23",
4
+ "description": "Host-only command safety gate for DeepSeek Harness: blocks recognized dangerous operations and requests DSH approval when a command cannot be inspected.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./package.json": "./package.json",
10
+ "./cordis.patch.yml": "./cordis.patch.yml"
11
+ },
12
+ "files": [
13
+ "lib",
14
+ "rules",
15
+ "cordis.patch.yml",
16
+ "README.md",
17
+ "README.zh.md",
18
+ "README.ru.md",
19
+ "CHANGELOG.md",
20
+ "LICENSE"
21
+ ],
22
+ "dsh": {
23
+ "bundle": {
24
+ "patch": "./cordis.patch.yml"
25
+ }
26
+ },
27
+ "license": "MIT",
28
+ "peerDependencies": {
29
+ "@deepseek-ai/cordis": "^4.0.1",
30
+ "@deepseek-ai/schemastery": "^3.18.1"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/GooDAnDReaDY/dsh-approval-gate.git"
35
+ },
36
+ "homepage": "https://github.com/GooDAnDReaDY/dsh-approval-gate#readme",
37
+ "bugs": {
38
+ "url": "https://github.com/GooDAnDReaDY/dsh-approval-gate/issues"
39
+ },
40
+ "scripts": {
41
+ "test": "node --test test/*.test.mjs"
42
+ },
43
+ "publishConfig": {
44
+ "registry": "https://registry.npmjs.org",
45
+ "access": "public"
46
+ },
47
+ "keywords": [
48
+ "dsh",
49
+ "dsh-plugin",
50
+ "deepseek-harness",
51
+ "security",
52
+ "approval-gate",
53
+ "safety"
54
+ ]
55
+ }