@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/CHANGELOG.md +16 -0
- package/lib/audit-log.js +348 -345
- package/lib/dossier.js +122 -0
- package/lib/drift.js +214 -0
- package/lib/dualtrack.js +101 -0
- package/lib/engine.js +85 -81
- package/lib/escalation.js +151 -0
- package/lib/guidance.js +119 -0
- package/lib/index.js +50 -12
- package/lib/ledger.js +264 -164
- package/lib/lineage.js +182 -0
- package/lib/measure-kinds.js +3 -0
- package/lib/messages.js +27 -0
- package/lib/session-modes.js +146 -0
- package/package.json +2 -2
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
|
+
}
|
package/lib/dualtrack.js
ADDED
|
@@ -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
|
-
|
|
41
|
-
if (
|
|
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
|
-
|
|
52
|
-
if (/\b(
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
getGrants(sessionId) {
|
|
515
|
-
if (this.grantRegistry && typeof this.grantRegistry.getGrants === "function") {
|
|
516
|
-
return this.grantRegistry.getGrants(sessionId);
|
|
517
|
-
}
|
|
518
|
-
return [];
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
getLedgerSummary(sessionId) {
|
|
522
|
-
return this.ledger ? this.ledger.getSummary(sessionId) : null;
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
isRetryExhausted(sessionId) {
|
|
526
|
-
return this.retryGuard ? this.retryGuard.isExhausted(sessionId) : false;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
clearCache() {
|
|
530
|
-
if (this.cache) this.cache.clear();
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
getCounters() {
|
|
534
|
-
return this.counters ? this.counters.getSnapshot() : null;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
resetCounters() {
|
|
538
|
-
if (this.counters) this.counters.reset();
|
|
539
|
-
}
|
|
540
|
-
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|