@goodandready/dsh-approval-gate 0.1.22 → 0.1.24

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.24
4
+
5
+ - Security: Жесткое исключение критических категорий из механизма авто-обучения (`canLearnOperation` в `lib/learning.js`) с гарантированным запретом запоминания деструктивных операций (`rm`), секретов (`.env`, `.ssh`), эскалации прав (`sudo`) и системных путей (Refs: #81).
6
+ - Feature: Авто-обучение правилам на основе N-1 ручных подтверждений пользователя (`ConfirmationLearner` в `lib/learning.js`) с автоматическим переводом рутинных операций в `learned-auto-allow` на стадии P1 (Refs: #79).
7
+ - Feature: Архитектура независимого ревьюера (Second-Model AI) без права самоодобрения основным агентом (`IndependentReviewer`, `SAFETY_REVIEWER_SYSTEM_PROMPT` в `lib/reviewer.js`) со строгим разделением полномочий (Separation of Duties, Refs: #62).
8
+ - Feature: Автономный раннер бенчмарков безопасности (`runEvaluation`, `formatEvaluationReport` в `lib/eval-runner.js`, CLI `bin/eval.mjs`) для объективного замера False Positive Rate (FPR) и False Negative Rate (FNR) с интеграцией в `npm run eval` (Refs: #69).
9
+ - Feature: Валидация семантического сходства команд (`canonicalizeCommand`, `isSemanticallyEquivalent`, `validateSemanticSimilarity` в `lib/similarity.js`) с поддержкой эквивалентностей пакетных менеджеров (`npm test` vs `pnpm test`) и Flash-модели (Refs: #85).
10
+
11
+ ## 0.1.23
12
+
13
+ - Tech-debt: Версионирование схемы хранения сессионных вердиктов (`CURRENT_SCHEMA_VERSION = 3` в `lib/ledger.js`) с полной обратной совместимостью и бесшовной миграцией исторических форматов v1/v2/v3 (`migrateVerdictRecord`) (Refs: #73).
14
+ - Feature: Поддержка двухтрековых маркеров (`Dual-Track Markers` в `lib/dualtrack.js`: `TRACK_TYPES.INTERNAL` vs `TRACK_TYPES.EXTERNAL`) для четкого разделения скрытых служебных шагов агента и пользовательских мутаций проекта (Refs: #92).
15
+ - Feature: Сессионное переключение режимов гейта (`auto-approval` vs `manual` / `strict` / `hybrid` в `lib/session-modes.js`) на основе активного пресета сессии (`resolveSessionGateMode`, `applySessionModePolicy`, `SessionModeRegistry`) (Refs: #61).
16
+ - Feature: Сбор доказательного досье (`Evidence Dossier` в `lib/dossier.js`: инструмент, diff, терминал, исходный промпт юзера) с безопасной санитизацией чувствительных токенов и защитой от переполнения памяти (Refs: #64).
17
+ - Security: Детекция отклонения от траектории задачи (`Trajectory Drift Detection` в `lib/drift.js`: `TrajectoryTracker`) с перехватом несанкционированного сканирования сети, поиска учетных данных и системного зондирования вне рамок цели сессии (Refs: #68).
18
+
3
19
  ## 0.1.22
4
20
 
5
21
  - Feature: Наследование разрешений в иерархии субагентов через `parentSession lineage authority` (`LineageManager` в `lib/lineage.js`) с безопасным обходом дерева предков и защитой от циклических ссылок (Refs: #98).
package/bin/eval.mjs ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ // bin/eval.mjs
3
+ // Command-line entry point for running security benchmarks (#69).
4
+
5
+ import { SecurityEngine } from "../lib/engine.js";
6
+ import { runEvaluation, formatEvaluationReport } from "../lib/eval-runner.js";
7
+
8
+ const engine = new SecurityEngine();
9
+ const results = runEvaluation(engine);
10
+ console.log(formatEvaluationReport(results));
11
+
12
+ if (results.fpr > 0 || results.fnr > 0 || results.failedCases > 0) {
13
+ process.exit(1);
14
+ } else {
15
+ process.exit(0);
16
+ }
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
+ }