@solongate/proxy 0.47.6 → 0.47.7
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/dist/index.js +1 -1
- package/dist/lib.js +1 -1
- package/hooks/audit.mjs +144 -0
- package/hooks/guard.bundled.mjs +147 -1
- package/hooks/guard.mjs +138 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -8682,7 +8682,7 @@ var PolicyRuleSchema = z.object({
|
|
|
8682
8682
|
effect: z.enum(["ALLOW", "DENY"]),
|
|
8683
8683
|
priority: z.number().int().min(0).max(1e4).default(1e3),
|
|
8684
8684
|
toolPattern: z.string().min(1).max(512),
|
|
8685
|
-
permission: z.enum(["READ", "WRITE", "EXECUTE"]).optional(),
|
|
8685
|
+
permission: z.enum(["READ", "WRITE", "EXECUTE", "NETWORK"]).optional(),
|
|
8686
8686
|
minimumTrustLevel: z.enum(["UNTRUSTED", "VERIFIED", "TRUSTED"]),
|
|
8687
8687
|
argumentConstraints: z.record(z.unknown()).optional(),
|
|
8688
8688
|
pathConstraints: z.object({
|
package/dist/lib.js
CHANGED
|
@@ -6269,7 +6269,7 @@ var PolicyRuleSchema = z.object({
|
|
|
6269
6269
|
effect: z.enum(["ALLOW", "DENY"]),
|
|
6270
6270
|
priority: z.number().int().min(0).max(1e4).default(1e3),
|
|
6271
6271
|
toolPattern: z.string().min(1).max(512),
|
|
6272
|
-
permission: z.enum(["READ", "WRITE", "EXECUTE"]).optional(),
|
|
6272
|
+
permission: z.enum(["READ", "WRITE", "EXECUTE", "NETWORK"]).optional(),
|
|
6273
6273
|
minimumTrustLevel: z.enum(["UNTRUSTED", "VERIFIED", "TRUSTED"]),
|
|
6274
6274
|
argumentConstraints: z.record(z.unknown()).optional(),
|
|
6275
6275
|
pathConstraints: z.object({
|
package/hooks/audit.mjs
CHANGED
|
@@ -34,6 +34,137 @@ function loadGlobalCloudConfig() {
|
|
|
34
34
|
} catch { return {}; }
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// ── Ghost paths (PostToolUse twin of guard.mjs) ──
|
|
38
|
+
// The guard's PreToolUse hook blocks MUTATIONS to hidden paths; here we make
|
|
39
|
+
// hidden paths invisible to READS and LISTINGS by rewriting the tool output the
|
|
40
|
+
// model sees (Claude Code `updatedToolOutput`). The matcher below is mirrored
|
|
41
|
+
// verbatim from guard.mjs — keep the two in sync. Config is read from the policy
|
|
42
|
+
// cache the guard just wrote (same PreToolUse call), so no extra API request.
|
|
43
|
+
function ghostGlobToRegExp(glob) {
|
|
44
|
+
let re = '';
|
|
45
|
+
for (let i = 0; i < glob.length; i++) {
|
|
46
|
+
const c = glob[i];
|
|
47
|
+
if (c === '*') {
|
|
48
|
+
if (glob[i + 1] === '*') { re += '.*'; i++; }
|
|
49
|
+
else re += '[^/]*';
|
|
50
|
+
} else if (c === '?') re += '[^/]';
|
|
51
|
+
else if ('\\^$.|+()[]{}'.indexOf(c) !== -1) re += '\\' + c;
|
|
52
|
+
else re += c;
|
|
53
|
+
}
|
|
54
|
+
try { return new RegExp('^' + re + '$'); } catch { return null; }
|
|
55
|
+
}
|
|
56
|
+
function ghostMatch(targetPath, patterns) {
|
|
57
|
+
if (!targetPath || !Array.isArray(patterns) || patterns.length === 0) return false;
|
|
58
|
+
const norm = String(targetPath).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
59
|
+
if (!norm) return false;
|
|
60
|
+
const segments = norm.split('/').filter(Boolean);
|
|
61
|
+
const base = segments.length ? segments[segments.length - 1] : norm;
|
|
62
|
+
for (let pat of patterns) {
|
|
63
|
+
pat = String(pat || '').trim();
|
|
64
|
+
if (!pat) continue;
|
|
65
|
+
let dirOnly = false;
|
|
66
|
+
if (pat.endsWith('/')) { dirOnly = true; pat = pat.slice(0, -1); }
|
|
67
|
+
if (!pat) continue;
|
|
68
|
+
const hasSlash = pat.indexOf('/') !== -1;
|
|
69
|
+
const hasWild = /[*?]/.test(pat);
|
|
70
|
+
const re = ghostGlobToRegExp(pat);
|
|
71
|
+
if (!re) continue;
|
|
72
|
+
if (dirOnly) {
|
|
73
|
+
if (!hasSlash && !hasWild) { if (segments.indexOf(pat) !== -1) return true; continue; }
|
|
74
|
+
let acc = '';
|
|
75
|
+
for (const s of segments) { acc = acc ? acc + '/' + s : s; if (re.test(acc) || re.test(s)) return true; }
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (!hasSlash) {
|
|
79
|
+
if (re.test(base)) return true;
|
|
80
|
+
if (segments.some((s) => re.test(s))) return true;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (re.test(norm)) return true;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
function ghostCleanToken(tok) {
|
|
88
|
+
let t = String(tok || '').trim();
|
|
89
|
+
t = t.replace(/^[<>|;&(]+/, '').replace(/[);&|]+$/, '');
|
|
90
|
+
t = t.replace(/^['"]+/, '').replace(/['"]+$/, '');
|
|
91
|
+
t = t.replace(/^\d*>>?/, '');
|
|
92
|
+
return t.trim();
|
|
93
|
+
}
|
|
94
|
+
// Drop listing lines that reference a ghost entry. `find`/glob output (one path
|
|
95
|
+
// per line) drops the whole line; multi-column `ls -l` rows drop entirely; a
|
|
96
|
+
// bare space-separated `ls` row drops only the matching names.
|
|
97
|
+
function ghostStripLines(text, pats) {
|
|
98
|
+
const lines = String(text).split('\n');
|
|
99
|
+
const kept = [];
|
|
100
|
+
for (const line of lines) {
|
|
101
|
+
const trimmed = line.trim();
|
|
102
|
+
if (!trimmed) { kept.push(line); continue; }
|
|
103
|
+
if (ghostMatch(trimmed, pats)) continue; // full-path listing line
|
|
104
|
+
const toks = trimmed.split(/\s+/);
|
|
105
|
+
const anyHit = toks.some((t) => ghostMatch(ghostCleanToken(t), pats));
|
|
106
|
+
if (!anyHit) { kept.push(line); continue; }
|
|
107
|
+
if (toks.length > 3) continue; // ls -l style row → drop entirely
|
|
108
|
+
const remaining = toks.filter((t) => !ghostMatch(ghostCleanToken(t), pats));
|
|
109
|
+
if (remaining.length === 0) continue;
|
|
110
|
+
kept.push(remaining.join(' '));
|
|
111
|
+
}
|
|
112
|
+
return kept.join('\n');
|
|
113
|
+
}
|
|
114
|
+
// Read ghost patterns from the policy cache the guard wrote on the matching
|
|
115
|
+
// PreToolUse call. Same agent-key derivation as guard.mjs.
|
|
116
|
+
function loadGhostPatterns() {
|
|
117
|
+
try {
|
|
118
|
+
const sel = (process.env.SOLONGATE_AGENT_ID || process.argv[2] || 'default').replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
119
|
+
const f = resolve(homedir(), '.solongate', '.policy-cache-' + sel + '.json');
|
|
120
|
+
if (!existsSync(f)) return [];
|
|
121
|
+
const c = JSON.parse(readFileSync(f, 'utf-8'));
|
|
122
|
+
const g = c && c.security && c.security.ghost;
|
|
123
|
+
return g && Array.isArray(g.patterns) ? g.patterns : [];
|
|
124
|
+
} catch { return []; }
|
|
125
|
+
}
|
|
126
|
+
// Returns the rewritten output text, or null if nothing is hidden.
|
|
127
|
+
function buildGhostOutput(toolName, toolInput, toolResponse, toolOutput, pats) {
|
|
128
|
+
if (!Array.isArray(pats) || pats.length === 0) return null;
|
|
129
|
+
const name = toolName || '';
|
|
130
|
+
const getText = () => {
|
|
131
|
+
if (typeof toolResponse === 'string') return toolResponse;
|
|
132
|
+
if (toolResponse && typeof toolResponse.stdout === 'string') return toolResponse.stdout;
|
|
133
|
+
if (toolResponse && typeof toolResponse.content === 'string') return toolResponse.content;
|
|
134
|
+
if (typeof toolOutput === 'string' && toolOutput) return toolOutput;
|
|
135
|
+
return null;
|
|
136
|
+
};
|
|
137
|
+
try {
|
|
138
|
+
// Direct read of a hidden file → looks like it doesn't exist.
|
|
139
|
+
if (name === 'Read' || name === 'NotebookRead') {
|
|
140
|
+
const p = toolInput && (toolInput.file_path || toolInput.notebook_path);
|
|
141
|
+
if (p && ghostMatch(p, pats)) return p + ': No such file or directory';
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
if (name === 'Bash' || name === 'BashOutput') {
|
|
145
|
+
const text = getText();
|
|
146
|
+
if (text == null) return null;
|
|
147
|
+
const cmd = String((toolInput && toolInput.command) || '');
|
|
148
|
+
for (const raw of cmd.split(/\s+/)) {
|
|
149
|
+
const t = ghostCleanToken(raw);
|
|
150
|
+
// A command that names the hidden path directly (cat A/Y/.data, ls A/Y)
|
|
151
|
+
// → not-found, regardless of what the command actually returned.
|
|
152
|
+
if (t && t[0] !== '-' && ghostMatch(t, pats)) return t + ': No such file or directory';
|
|
153
|
+
}
|
|
154
|
+
const stripped = ghostStripLines(text, pats);
|
|
155
|
+
return stripped === text ? null : stripped;
|
|
156
|
+
}
|
|
157
|
+
// Listing-style tools → strip hidden entries from the result.
|
|
158
|
+
if (name === 'Glob' || name === 'Grep' || name === 'LS') {
|
|
159
|
+
const text = getText();
|
|
160
|
+
if (text == null) return null;
|
|
161
|
+
const stripped = ghostStripLines(text, pats);
|
|
162
|
+
return stripped === text ? null : stripped;
|
|
163
|
+
}
|
|
164
|
+
} catch { /* fail open */ }
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
37
168
|
function guessPermission(toolName) {
|
|
38
169
|
const name = (toolName || '').toLowerCase();
|
|
39
170
|
if (name.includes('exec') || name.includes('shell') || name.includes('run') || name.includes('eval') || name === 'bash') return 'EXECUTE';
|
|
@@ -96,6 +227,19 @@ process.stdin.on('end', async () => {
|
|
|
96
227
|
const toolOutput = data.tool_output || data.toolOutput || '';
|
|
97
228
|
const resultJson = data.result_json ? (typeof data.result_json === 'string' ? data.result_json : JSON.stringify(data.result_json)) : '';
|
|
98
229
|
|
|
230
|
+
// Ghost paths: rewrite the output the model sees so hidden files/dirs are
|
|
231
|
+
// stripped from listings and direct reads look like "no such file". Emit the
|
|
232
|
+
// updated output BEFORE the (fire-and-forget) audit log. Fail-open: any
|
|
233
|
+
// error leaves the original output untouched.
|
|
234
|
+
try {
|
|
235
|
+
const ghostText = buildGhostOutput(toolName, toolInput, toolResponse, toolOutput, loadGhostPatterns());
|
|
236
|
+
if (typeof ghostText === 'string') {
|
|
237
|
+
process.stdout.write(JSON.stringify({
|
|
238
|
+
hookSpecificOutput: { hookEventName: 'PostToolUse', updatedToolOutput: { type: 'text', text: ghostText } },
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
} catch {}
|
|
242
|
+
|
|
99
243
|
const hasError = guardDenied ||
|
|
100
244
|
toolResponse.error ||
|
|
101
245
|
toolResponse.exitCode > 0 ||
|
package/hooks/guard.bundled.mjs
CHANGED
|
@@ -6535,7 +6535,7 @@ import { resolve, join } from "node:path";
|
|
|
6535
6535
|
import { homedir } from "node:os";
|
|
6536
6536
|
import { gunzipSync } from "node:zlib";
|
|
6537
6537
|
import { createHash } from "node:crypto";
|
|
6538
|
-
var HOOK_VERSION =
|
|
6538
|
+
var HOOK_VERSION = 15;
|
|
6539
6539
|
var MAX_FILE_READ = 1024 * 1024;
|
|
6540
6540
|
function safeReadFileSync(filePath, encoding = "utf-8") {
|
|
6541
6541
|
try {
|
|
@@ -7027,6 +7027,117 @@ function dlpScan(args, cfg) {
|
|
|
7027
7027
|
}
|
|
7028
7028
|
return null;
|
|
7029
7029
|
}
|
|
7030
|
+
function ghostGlobToRegExp(glob) {
|
|
7031
|
+
let re = "";
|
|
7032
|
+
for (let i = 0; i < glob.length; i++) {
|
|
7033
|
+
const c = glob[i];
|
|
7034
|
+
if (c === "*") {
|
|
7035
|
+
if (glob[i + 1] === "*") {
|
|
7036
|
+
re += ".*";
|
|
7037
|
+
i++;
|
|
7038
|
+
} else
|
|
7039
|
+
re += "[^/]*";
|
|
7040
|
+
} else if (c === "?")
|
|
7041
|
+
re += "[^/]";
|
|
7042
|
+
else if ("\\^$.|+()[]{}".indexOf(c) !== -1)
|
|
7043
|
+
re += "\\" + c;
|
|
7044
|
+
else
|
|
7045
|
+
re += c;
|
|
7046
|
+
}
|
|
7047
|
+
try {
|
|
7048
|
+
return new RegExp("^" + re + "$");
|
|
7049
|
+
} catch {
|
|
7050
|
+
return null;
|
|
7051
|
+
}
|
|
7052
|
+
}
|
|
7053
|
+
function ghostMatch(targetPath, patterns) {
|
|
7054
|
+
if (!targetPath || !Array.isArray(patterns) || patterns.length === 0)
|
|
7055
|
+
return false;
|
|
7056
|
+
const norm = String(targetPath).replace(/\\/g, "/").replace(/\/+$/, "");
|
|
7057
|
+
if (!norm)
|
|
7058
|
+
return false;
|
|
7059
|
+
const segments = norm.split("/").filter(Boolean);
|
|
7060
|
+
const base = segments.length ? segments[segments.length - 1] : norm;
|
|
7061
|
+
for (let pat of patterns) {
|
|
7062
|
+
pat = String(pat || "").trim();
|
|
7063
|
+
if (!pat)
|
|
7064
|
+
continue;
|
|
7065
|
+
let dirOnly = false;
|
|
7066
|
+
if (pat.endsWith("/")) {
|
|
7067
|
+
dirOnly = true;
|
|
7068
|
+
pat = pat.slice(0, -1);
|
|
7069
|
+
}
|
|
7070
|
+
if (!pat)
|
|
7071
|
+
continue;
|
|
7072
|
+
const hasSlash = pat.indexOf("/") !== -1;
|
|
7073
|
+
const hasWild = /[*?]/.test(pat);
|
|
7074
|
+
const re = ghostGlobToRegExp(pat);
|
|
7075
|
+
if (!re)
|
|
7076
|
+
continue;
|
|
7077
|
+
if (dirOnly) {
|
|
7078
|
+
if (!hasSlash && !hasWild) {
|
|
7079
|
+
if (segments.indexOf(pat) !== -1)
|
|
7080
|
+
return true;
|
|
7081
|
+
continue;
|
|
7082
|
+
}
|
|
7083
|
+
let acc = "";
|
|
7084
|
+
for (const s of segments) {
|
|
7085
|
+
acc = acc ? acc + "/" + s : s;
|
|
7086
|
+
if (re.test(acc) || re.test(s))
|
|
7087
|
+
return true;
|
|
7088
|
+
}
|
|
7089
|
+
continue;
|
|
7090
|
+
}
|
|
7091
|
+
if (!hasSlash) {
|
|
7092
|
+
if (re.test(base))
|
|
7093
|
+
return true;
|
|
7094
|
+
if (segments.some((s) => re.test(s)))
|
|
7095
|
+
return true;
|
|
7096
|
+
continue;
|
|
7097
|
+
}
|
|
7098
|
+
if (re.test(norm))
|
|
7099
|
+
return true;
|
|
7100
|
+
}
|
|
7101
|
+
return false;
|
|
7102
|
+
}
|
|
7103
|
+
function ghostCleanToken(tok) {
|
|
7104
|
+
let t = String(tok || "").trim();
|
|
7105
|
+
t = t.replace(/^[<>|;&(]+/, "").replace(/[);&|]+$/, "");
|
|
7106
|
+
t = t.replace(/^['"]+/, "").replace(/['"]+$/, "");
|
|
7107
|
+
t = t.replace(/^\d*>>?/, "");
|
|
7108
|
+
return t.trim();
|
|
7109
|
+
}
|
|
7110
|
+
var GHOST_MUTATORS = /(^|[\s;&|])(rm|mv|cp|dd|truncate|tee|chmod|chown|ln|mkdir|rmdir|touch|install|shred|unlink|rsync|sed\s+-i)([\s;&|]|$)/;
|
|
7111
|
+
function ghostBlock(toolName, args, ghostCfg) {
|
|
7112
|
+
if (!ghostCfg || !Array.isArray(ghostCfg.patterns) || ghostCfg.patterns.length === 0)
|
|
7113
|
+
return null;
|
|
7114
|
+
const pats = ghostCfg.patterns;
|
|
7115
|
+
const name = toolName || "";
|
|
7116
|
+
const notFound = (p) => p + ": No such file or directory";
|
|
7117
|
+
try {
|
|
7118
|
+
if (name === "Write" || name === "Edit" || name === "MultiEdit" || name === "NotebookEdit") {
|
|
7119
|
+
const p = args?.file_path || args?.notebook_path || args?.path || "";
|
|
7120
|
+
if (p && ghostMatch(p, pats))
|
|
7121
|
+
return notFound(p);
|
|
7122
|
+
return null;
|
|
7123
|
+
}
|
|
7124
|
+
if (name === "Bash" || name === "BashOutput" || guessPermission(name) === "EXECUTE") {
|
|
7125
|
+
const cmd = String(args?.command || "");
|
|
7126
|
+
if (!cmd)
|
|
7127
|
+
return null;
|
|
7128
|
+
if (!GHOST_MUTATORS.test(cmd))
|
|
7129
|
+
return null;
|
|
7130
|
+
const tokens = cmd.split(/\s+/);
|
|
7131
|
+
for (const raw of tokens) {
|
|
7132
|
+
const tok = ghostCleanToken(raw);
|
|
7133
|
+
if (tok && tok.indexOf("-") !== 0 && ghostMatch(tok, pats))
|
|
7134
|
+
return notFound(tok);
|
|
7135
|
+
}
|
|
7136
|
+
}
|
|
7137
|
+
} catch {
|
|
7138
|
+
}
|
|
7139
|
+
return null;
|
|
7140
|
+
}
|
|
7030
7141
|
var RL_WINDOWS = [
|
|
7031
7142
|
{ key: "perDay", ms: 864e5, label: "day" },
|
|
7032
7143
|
{ key: "perHour", ms: 36e5, label: "hour" },
|
|
@@ -7476,6 +7587,41 @@ process.stdin.on("end", async () => {
|
|
|
7476
7587
|
if (process.env.SOLONGATE_DEBUG) {
|
|
7477
7588
|
}
|
|
7478
7589
|
let reason = selfProtectEnabled ? tamperCheck(toolName, args) : null;
|
|
7590
|
+
if (!reason && securityCfg && securityCfg.ghost) {
|
|
7591
|
+
const ghostHit = ghostBlock(toolName, args, securityCfg.ghost);
|
|
7592
|
+
if (ghostHit) {
|
|
7593
|
+
try {
|
|
7594
|
+
writeDenyFlag(toolName);
|
|
7595
|
+
} catch {
|
|
7596
|
+
}
|
|
7597
|
+
try {
|
|
7598
|
+
await fetch(API_URL + "/api/v1/audit-logs", {
|
|
7599
|
+
method: "POST",
|
|
7600
|
+
headers: { "Content-Type": "application/json", ...AUTH_HEADERS },
|
|
7601
|
+
body: JSON.stringify({
|
|
7602
|
+
tool: toolName,
|
|
7603
|
+
arguments: args,
|
|
7604
|
+
decision: "DENY",
|
|
7605
|
+
reason: "ghost path (hidden from agent)",
|
|
7606
|
+
permission: guessPermission(toolName),
|
|
7607
|
+
source: `${AGENT_TYPE}-guard`,
|
|
7608
|
+
agent_id: AGENT_TYPE,
|
|
7609
|
+
agent_name: AGENT_NAME,
|
|
7610
|
+
evaluation_time_ms: Date.now() - _evalStart
|
|
7611
|
+
}),
|
|
7612
|
+
signal: AbortSignal.timeout(3e3)
|
|
7613
|
+
});
|
|
7614
|
+
} catch {
|
|
7615
|
+
}
|
|
7616
|
+
await maybeSelfUpdate();
|
|
7617
|
+
if (AGENT_TYPE === "gemini-cli") {
|
|
7618
|
+
process.stdout.write(JSON.stringify({ decision: "deny", reason: ghostHit }));
|
|
7619
|
+
process.exit(0);
|
|
7620
|
+
}
|
|
7621
|
+
process.stderr.write(ghostHit);
|
|
7622
|
+
process.exit(2);
|
|
7623
|
+
}
|
|
7624
|
+
}
|
|
7479
7625
|
if (!reason)
|
|
7480
7626
|
reason = securityLayerCheck(toolName, args, securityCfg, agentKey);
|
|
7481
7627
|
if (process.env.SOLONGATE_DEBUG) {
|
package/hooks/guard.mjs
CHANGED
|
@@ -31,7 +31,7 @@ import { createHash } from 'node:crypto';
|
|
|
31
31
|
// the installed hook self-updates when the cloud version is higher (see
|
|
32
32
|
// maybeSelfUpdate). This is what makes guard fixes propagate without a manual
|
|
33
33
|
// reinstall — the same trust model as the OPA WASM this hook already runs.
|
|
34
|
-
const HOOK_VERSION =
|
|
34
|
+
const HOOK_VERSION = 15;
|
|
35
35
|
|
|
36
36
|
// Safe file read with size limit (1MB max) to prevent DoS via large files
|
|
37
37
|
const MAX_FILE_READ = 1024 * 1024; // 1MB
|
|
@@ -671,6 +671,114 @@ function dlpScan(args, cfg) {
|
|
|
671
671
|
return null;
|
|
672
672
|
}
|
|
673
673
|
|
|
674
|
+
// ── Ghost paths ──
|
|
675
|
+
// Files/dirs matching a ghost glob are INVISIBLE to the agent. This module is
|
|
676
|
+
// mirrored verbatim in audit.mjs (the PostToolUse twin that strips them from
|
|
677
|
+
// output). Keep the two copies in sync.
|
|
678
|
+
//
|
|
679
|
+
// Split of responsibility:
|
|
680
|
+
// - PreToolUse (here): block MUTATIONS (write/edit/delete/move) targeting a
|
|
681
|
+
// ghost path, returning a plain "No such file or directory" — never a
|
|
682
|
+
// SolonGate/policy message — so the path looks like it simply doesn't exist.
|
|
683
|
+
// - PostToolUse (audit.mjs): strip ghost entries from listings and rewrite
|
|
684
|
+
// direct reads to not-found. Reads are NOT blocked here so the agent gets a
|
|
685
|
+
// natural "missing file" rather than a visible hook block.
|
|
686
|
+
function ghostGlobToRegExp(glob) {
|
|
687
|
+
let re = '';
|
|
688
|
+
for (let i = 0; i < glob.length; i++) {
|
|
689
|
+
const c = glob[i];
|
|
690
|
+
if (c === '*') {
|
|
691
|
+
if (glob[i + 1] === '*') { re += '.*'; i++; }
|
|
692
|
+
else re += '[^/]*';
|
|
693
|
+
} else if (c === '?') re += '[^/]';
|
|
694
|
+
else if ('\\^$.|+()[]{}'.indexOf(c) !== -1) re += '\\' + c;
|
|
695
|
+
else re += c;
|
|
696
|
+
}
|
|
697
|
+
try { return new RegExp('^' + re + '$'); } catch { return null; }
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// True if `targetPath` is ghosted by any pattern. A bare name (`.data`) matches
|
|
701
|
+
// that entry anywhere in the path; a trailing `/` (`secrets/`) ghosts a whole
|
|
702
|
+
// directory subtree; a pattern with `/` is matched against the full path.
|
|
703
|
+
function ghostMatch(targetPath, patterns) {
|
|
704
|
+
if (!targetPath || !Array.isArray(patterns) || patterns.length === 0) return false;
|
|
705
|
+
const norm = String(targetPath).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
706
|
+
if (!norm) return false;
|
|
707
|
+
const segments = norm.split('/').filter(Boolean);
|
|
708
|
+
const base = segments.length ? segments[segments.length - 1] : norm;
|
|
709
|
+
for (let pat of patterns) {
|
|
710
|
+
pat = String(pat || '').trim();
|
|
711
|
+
if (!pat) continue;
|
|
712
|
+
let dirOnly = false;
|
|
713
|
+
if (pat.endsWith('/')) { dirOnly = true; pat = pat.slice(0, -1); }
|
|
714
|
+
if (!pat) continue;
|
|
715
|
+
const hasSlash = pat.indexOf('/') !== -1;
|
|
716
|
+
const hasWild = /[*?]/.test(pat);
|
|
717
|
+
const re = ghostGlobToRegExp(pat);
|
|
718
|
+
if (!re) continue;
|
|
719
|
+
if (dirOnly) {
|
|
720
|
+
// Directory: ghost the dir itself and everything under it.
|
|
721
|
+
if (!hasSlash && !hasWild) { if (segments.indexOf(pat) !== -1) return true; continue; }
|
|
722
|
+
let acc = '';
|
|
723
|
+
for (const s of segments) { acc = acc ? acc + '/' + s : s; if (re.test(acc) || re.test(s)) return true; }
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
if (!hasSlash) {
|
|
727
|
+
// Name glob: match basename or any single path segment.
|
|
728
|
+
if (re.test(base)) return true;
|
|
729
|
+
if (segments.some((s) => re.test(s))) return true;
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
// Path glob (contains '/'): match the full normalized path.
|
|
733
|
+
if (re.test(norm)) return true;
|
|
734
|
+
}
|
|
735
|
+
return false;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Strip shell decoration from a token so it can be tested as a path:
|
|
739
|
+
// surrounding quotes, redirection operators, trailing punctuation.
|
|
740
|
+
function ghostCleanToken(tok) {
|
|
741
|
+
let t = String(tok || '').trim();
|
|
742
|
+
t = t.replace(/^[<>|;&(]+/, '').replace(/[);&|]+$/, '');
|
|
743
|
+
t = t.replace(/^['"]+/, '').replace(/['"]+$/, '');
|
|
744
|
+
t = t.replace(/^\d*>>?/, ''); // strip leading redirection like 2>
|
|
745
|
+
return t.trim();
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Bash verbs that MUTATE a target path. A read-ish command (cat/ls/grep/find…)
|
|
749
|
+
// is intentionally absent: those are not blocked, they are stripped/rewritten
|
|
750
|
+
// by the PostToolUse hook so the agent sees a natural empty/missing result.
|
|
751
|
+
const GHOST_MUTATORS = /(^|[\s;&|])(rm|mv|cp|dd|truncate|tee|chmod|chown|ln|mkdir|rmdir|touch|install|shred|unlink|rsync|sed\s+-i)([\s;&|]|$)/;
|
|
752
|
+
|
|
753
|
+
// Returns a plain not-found message if a MUTATING op targets a ghost path,
|
|
754
|
+
// else null. Never returns a branded/policy string.
|
|
755
|
+
function ghostBlock(toolName, args, ghostCfg) {
|
|
756
|
+
if (!ghostCfg || !Array.isArray(ghostCfg.patterns) || ghostCfg.patterns.length === 0) return null;
|
|
757
|
+
const pats = ghostCfg.patterns;
|
|
758
|
+
const name = (toolName || '');
|
|
759
|
+
const notFound = (p) => p + ': No such file or directory';
|
|
760
|
+
try {
|
|
761
|
+
// File-mutating tools carry an explicit path.
|
|
762
|
+
if (name === 'Write' || name === 'Edit' || name === 'MultiEdit' || name === 'NotebookEdit') {
|
|
763
|
+
const p = args?.file_path || args?.notebook_path || args?.path || '';
|
|
764
|
+
if (p && ghostMatch(p, pats)) return notFound(p);
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
// Bash: block only when a ghost path is the target of a mutating verb.
|
|
768
|
+
if (name === 'Bash' || name === 'BashOutput' || guessPermission(name) === 'EXECUTE') {
|
|
769
|
+
const cmd = String(args?.command || '');
|
|
770
|
+
if (!cmd) return null;
|
|
771
|
+
if (!GHOST_MUTATORS.test(cmd)) return null;
|
|
772
|
+
const tokens = cmd.split(/\s+/);
|
|
773
|
+
for (const raw of tokens) {
|
|
774
|
+
const tok = ghostCleanToken(raw);
|
|
775
|
+
if (tok && tok.indexOf('-') !== 0 && ghostMatch(tok, pats)) return notFound(tok);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
} catch { /* fail open */ }
|
|
779
|
+
return null;
|
|
780
|
+
}
|
|
781
|
+
|
|
674
782
|
// Multi-window sliding rate limit, persisted under ~/.solongate (tamper-protected
|
|
675
783
|
// from the agent, writable by the guard). One timestamps file per agent, pruned
|
|
676
784
|
// to the last 24h and capped for performance; counts this agent's calls within
|
|
@@ -1238,6 +1346,35 @@ process.stdin.on('end', async () => {
|
|
|
1238
1346
|
// Tamper / self-protection — runs before policy eval. ON by default; the
|
|
1239
1347
|
// per-project cloud setting can disable it (fail safe: stays on if unread).
|
|
1240
1348
|
let reason = selfProtectEnabled ? tamperCheck(toolName, args) : null;
|
|
1349
|
+
// Ghost paths — handled BEFORE the other layers and emitted as a STEALTH
|
|
1350
|
+
// block: a mutating op on a hidden path is denied with a bare OS-style
|
|
1351
|
+
// "No such file or directory" and NOTHING else (no ROUTE line, no SolonGate
|
|
1352
|
+
// wording), so the agent can't tell the path is protected — it just looks
|
|
1353
|
+
// absent. (Reads/listings aren't blocked; the PostToolUse hook strips them.)
|
|
1354
|
+
if (!reason && securityCfg && securityCfg.ghost) {
|
|
1355
|
+
const ghostHit = ghostBlock(toolName, args, securityCfg.ghost);
|
|
1356
|
+
if (ghostHit) {
|
|
1357
|
+
try { writeDenyFlag(toolName); } catch {}
|
|
1358
|
+
try {
|
|
1359
|
+
await fetch(API_URL + '/api/v1/audit-logs', {
|
|
1360
|
+
method: 'POST',
|
|
1361
|
+
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
1362
|
+
body: JSON.stringify({
|
|
1363
|
+
tool: toolName, arguments: args, decision: 'DENY',
|
|
1364
|
+
reason: 'ghost path (hidden from agent)',
|
|
1365
|
+
permission: guessPermission(toolName),
|
|
1366
|
+
source: `${AGENT_TYPE}-guard`, agent_id: AGENT_TYPE, agent_name: AGENT_NAME,
|
|
1367
|
+
evaluation_time_ms: Date.now() - _evalStart,
|
|
1368
|
+
}),
|
|
1369
|
+
signal: AbortSignal.timeout(3000),
|
|
1370
|
+
});
|
|
1371
|
+
} catch {}
|
|
1372
|
+
await maybeSelfUpdate();
|
|
1373
|
+
if (AGENT_TYPE === 'gemini-cli') { process.stdout.write(JSON.stringify({ decision: 'deny', reason: ghostHit })); process.exit(0); }
|
|
1374
|
+
process.stderr.write(ghostHit);
|
|
1375
|
+
process.exit(2);
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1241
1378
|
// Extra security layers run after tamper, before policy. Block reason wins
|
|
1242
1379
|
// immediately (BLACK). Fail-open by design.
|
|
1243
1380
|
if (!reason) reason = securityLayerCheck(toolName, args, securityCfg, agentKey);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solongate/proxy",
|
|
3
|
-
"version": "0.47.
|
|
3
|
+
"version": "0.47.7",
|
|
4
4
|
"description": "AI tool security proxy — protect any AI tool server with customizable policies, path/command constraints, rate limiting, and audit logging. Zero code changes required.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|