@solongate/proxy 0.47.6 → 0.48.0
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/init.js +0 -0
- package/dist/lib.js +1 -1
- package/hooks/audit.mjs +146 -0
- package/hooks/guard.bundled.mjs +203 -1
- package/hooks/guard.mjs +199 -1
- package/package.json +13 -13
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/init.js
CHANGED
|
File without changes
|
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,21 @@ 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
|
+
// updatedToolOutput must be a PLAIN STRING (an object form is silently
|
|
238
|
+
// ignored by Claude Code). This replaces the tool result the model sees.
|
|
239
|
+
process.stdout.write(JSON.stringify({
|
|
240
|
+
hookSpecificOutput: { hookEventName: 'PostToolUse', updatedToolOutput: ghostText },
|
|
241
|
+
}));
|
|
242
|
+
}
|
|
243
|
+
} catch {}
|
|
244
|
+
|
|
99
245
|
const hasError = guardDenied ||
|
|
100
246
|
toolResponse.error ||
|
|
101
247
|
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 = 17;
|
|
6539
6539
|
var MAX_FILE_READ = 1024 * 1024;
|
|
6540
6540
|
function safeReadFileSync(filePath, encoding = "utf-8") {
|
|
6541
6541
|
try {
|
|
@@ -6662,6 +6662,12 @@ function allowTool() {
|
|
|
6662
6662
|
}
|
|
6663
6663
|
process.exit(0);
|
|
6664
6664
|
}
|
|
6665
|
+
function rewriteTool(updatedInput) {
|
|
6666
|
+
process.stdout.write(JSON.stringify({
|
|
6667
|
+
hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput }
|
|
6668
|
+
}));
|
|
6669
|
+
process.exit(0);
|
|
6670
|
+
}
|
|
6665
6671
|
function writeDenyFlag(toolName) {
|
|
6666
6672
|
try {
|
|
6667
6673
|
const flagDir = resolve(".solongate");
|
|
@@ -7027,6 +7033,160 @@ function dlpScan(args, cfg) {
|
|
|
7027
7033
|
}
|
|
7028
7034
|
return null;
|
|
7029
7035
|
}
|
|
7036
|
+
function ghostGlobToRegExp(glob) {
|
|
7037
|
+
let re = "";
|
|
7038
|
+
for (let i = 0; i < glob.length; i++) {
|
|
7039
|
+
const c = glob[i];
|
|
7040
|
+
if (c === "*") {
|
|
7041
|
+
if (glob[i + 1] === "*") {
|
|
7042
|
+
re += ".*";
|
|
7043
|
+
i++;
|
|
7044
|
+
} else
|
|
7045
|
+
re += "[^/]*";
|
|
7046
|
+
} else if (c === "?")
|
|
7047
|
+
re += "[^/]";
|
|
7048
|
+
else if ("\\^$.|+()[]{}".indexOf(c) !== -1)
|
|
7049
|
+
re += "\\" + c;
|
|
7050
|
+
else
|
|
7051
|
+
re += c;
|
|
7052
|
+
}
|
|
7053
|
+
try {
|
|
7054
|
+
return new RegExp("^" + re + "$");
|
|
7055
|
+
} catch {
|
|
7056
|
+
return null;
|
|
7057
|
+
}
|
|
7058
|
+
}
|
|
7059
|
+
function ghostMatch(targetPath, patterns) {
|
|
7060
|
+
if (!targetPath || !Array.isArray(patterns) || patterns.length === 0)
|
|
7061
|
+
return false;
|
|
7062
|
+
const norm = String(targetPath).replace(/\\/g, "/").replace(/\/+$/, "");
|
|
7063
|
+
if (!norm)
|
|
7064
|
+
return false;
|
|
7065
|
+
const segments = norm.split("/").filter(Boolean);
|
|
7066
|
+
const base = segments.length ? segments[segments.length - 1] : norm;
|
|
7067
|
+
for (let pat of patterns) {
|
|
7068
|
+
pat = String(pat || "").trim();
|
|
7069
|
+
if (!pat)
|
|
7070
|
+
continue;
|
|
7071
|
+
let dirOnly = false;
|
|
7072
|
+
if (pat.endsWith("/")) {
|
|
7073
|
+
dirOnly = true;
|
|
7074
|
+
pat = pat.slice(0, -1);
|
|
7075
|
+
}
|
|
7076
|
+
if (!pat)
|
|
7077
|
+
continue;
|
|
7078
|
+
const hasSlash = pat.indexOf("/") !== -1;
|
|
7079
|
+
const hasWild = /[*?]/.test(pat);
|
|
7080
|
+
const re = ghostGlobToRegExp(pat);
|
|
7081
|
+
if (!re)
|
|
7082
|
+
continue;
|
|
7083
|
+
if (dirOnly) {
|
|
7084
|
+
if (!hasSlash && !hasWild) {
|
|
7085
|
+
if (segments.indexOf(pat) !== -1)
|
|
7086
|
+
return true;
|
|
7087
|
+
continue;
|
|
7088
|
+
}
|
|
7089
|
+
let acc = "";
|
|
7090
|
+
for (const s of segments) {
|
|
7091
|
+
acc = acc ? acc + "/" + s : s;
|
|
7092
|
+
if (re.test(acc) || re.test(s))
|
|
7093
|
+
return true;
|
|
7094
|
+
}
|
|
7095
|
+
continue;
|
|
7096
|
+
}
|
|
7097
|
+
if (!hasSlash) {
|
|
7098
|
+
if (re.test(base))
|
|
7099
|
+
return true;
|
|
7100
|
+
if (segments.some((s) => re.test(s)))
|
|
7101
|
+
return true;
|
|
7102
|
+
continue;
|
|
7103
|
+
}
|
|
7104
|
+
if (re.test(norm))
|
|
7105
|
+
return true;
|
|
7106
|
+
}
|
|
7107
|
+
return false;
|
|
7108
|
+
}
|
|
7109
|
+
function ghostCleanToken(tok) {
|
|
7110
|
+
let t = String(tok || "").trim();
|
|
7111
|
+
t = t.replace(/^[<>|;&(]+/, "").replace(/[);&|]+$/, "");
|
|
7112
|
+
t = t.replace(/^['"]+/, "").replace(/['"]+$/, "");
|
|
7113
|
+
t = t.replace(/^\d*>>?/, "");
|
|
7114
|
+
return t.trim();
|
|
7115
|
+
}
|
|
7116
|
+
function ghostBlock(toolName, args, ghostCfg) {
|
|
7117
|
+
if (!ghostCfg || !Array.isArray(ghostCfg.patterns) || ghostCfg.patterns.length === 0)
|
|
7118
|
+
return null;
|
|
7119
|
+
const pats = ghostCfg.patterns;
|
|
7120
|
+
const name = toolName || "";
|
|
7121
|
+
const notFound = (p) => p + ": No such file or directory";
|
|
7122
|
+
try {
|
|
7123
|
+
if (name === "Write" || name === "Edit" || name === "MultiEdit" || name === "NotebookEdit" || name === "Read" || name === "NotebookRead" || name === "LS") {
|
|
7124
|
+
const p = args?.file_path || args?.notebook_path || args?.path || "";
|
|
7125
|
+
if (p && ghostMatch(p, pats))
|
|
7126
|
+
return notFound(p);
|
|
7127
|
+
return null;
|
|
7128
|
+
}
|
|
7129
|
+
if (name === "Glob" || name === "Grep") {
|
|
7130
|
+
const p = args?.path || "";
|
|
7131
|
+
const pat = args?.pattern || args?.glob || "";
|
|
7132
|
+
if (p && ghostMatch(p, pats))
|
|
7133
|
+
return notFound(p);
|
|
7134
|
+
if (pat && ghostMatch(pat, pats))
|
|
7135
|
+
return notFound(String(pat));
|
|
7136
|
+
return null;
|
|
7137
|
+
}
|
|
7138
|
+
if (name === "Bash" || name === "BashOutput" || guessPermission(name) === "EXECUTE") {
|
|
7139
|
+
const cmd = String(args?.command || "");
|
|
7140
|
+
if (!cmd)
|
|
7141
|
+
return null;
|
|
7142
|
+
for (const raw of cmd.split(/\s+/)) {
|
|
7143
|
+
const tok = ghostCleanToken(raw);
|
|
7144
|
+
if (tok && tok.indexOf("-") !== 0 && ghostMatch(tok, pats))
|
|
7145
|
+
return notFound(tok);
|
|
7146
|
+
}
|
|
7147
|
+
}
|
|
7148
|
+
} catch {
|
|
7149
|
+
}
|
|
7150
|
+
return null;
|
|
7151
|
+
}
|
|
7152
|
+
function ghostListingRewrite(args, ghostCfg) {
|
|
7153
|
+
if (!ghostCfg || !Array.isArray(ghostCfg.patterns) || ghostCfg.patterns.length === 0)
|
|
7154
|
+
return null;
|
|
7155
|
+
const cmd = String(args?.command || "");
|
|
7156
|
+
if (!cmd)
|
|
7157
|
+
return null;
|
|
7158
|
+
if (/[|>;&\n`]/.test(cmd))
|
|
7159
|
+
return null;
|
|
7160
|
+
if (!/^\s*(ls|ll|dir|find|tree|exa|lsd|fd)(\s|$)/.test(cmd))
|
|
7161
|
+
return null;
|
|
7162
|
+
const alts = [];
|
|
7163
|
+
for (let p of ghostCfg.patterns) {
|
|
7164
|
+
p = String(p).replace(/\/$/, "");
|
|
7165
|
+
if (!p)
|
|
7166
|
+
continue;
|
|
7167
|
+
let re = "";
|
|
7168
|
+
for (let i = 0; i < p.length; i++) {
|
|
7169
|
+
const c = p[i];
|
|
7170
|
+
if (c === "*") {
|
|
7171
|
+
if (p[i + 1] === "*") {
|
|
7172
|
+
re += ".*";
|
|
7173
|
+
i++;
|
|
7174
|
+
} else
|
|
7175
|
+
re += "[^/]*";
|
|
7176
|
+
} else if (c === "?")
|
|
7177
|
+
re += "[^/]";
|
|
7178
|
+
else if (".^$+(){}[]|\\/".indexOf(c) >= 0)
|
|
7179
|
+
re += "\\" + c;
|
|
7180
|
+
else
|
|
7181
|
+
re += c;
|
|
7182
|
+
}
|
|
7183
|
+
alts.push(re);
|
|
7184
|
+
}
|
|
7185
|
+
if (alts.length === 0)
|
|
7186
|
+
return null;
|
|
7187
|
+
const ere = "(^|/| )(" + alts.join("|") + ")(/|$)";
|
|
7188
|
+
return cmd + " | grep -vE '" + ere.replace(/'/g, "'\\''") + "'";
|
|
7189
|
+
}
|
|
7030
7190
|
var RL_WINDOWS = [
|
|
7031
7191
|
{ key: "perDay", ms: 864e5, label: "day" },
|
|
7032
7192
|
{ key: "perHour", ms: 36e5, label: "hour" },
|
|
@@ -7476,6 +7636,48 @@ process.stdin.on("end", async () => {
|
|
|
7476
7636
|
if (process.env.SOLONGATE_DEBUG) {
|
|
7477
7637
|
}
|
|
7478
7638
|
let reason = selfProtectEnabled ? tamperCheck(toolName, args) : null;
|
|
7639
|
+
if (!reason && securityCfg && securityCfg.ghost) {
|
|
7640
|
+
const ghostHit = ghostBlock(toolName, args, securityCfg.ghost);
|
|
7641
|
+
if (ghostHit) {
|
|
7642
|
+
try {
|
|
7643
|
+
writeDenyFlag(toolName);
|
|
7644
|
+
} catch {
|
|
7645
|
+
}
|
|
7646
|
+
try {
|
|
7647
|
+
await fetch(API_URL + "/api/v1/audit-logs", {
|
|
7648
|
+
method: "POST",
|
|
7649
|
+
headers: { "Content-Type": "application/json", ...AUTH_HEADERS },
|
|
7650
|
+
body: JSON.stringify({
|
|
7651
|
+
tool: toolName,
|
|
7652
|
+
arguments: args,
|
|
7653
|
+
decision: "DENY",
|
|
7654
|
+
reason: "ghost path (hidden from agent)",
|
|
7655
|
+
permission: guessPermission(toolName),
|
|
7656
|
+
source: `${AGENT_TYPE}-guard`,
|
|
7657
|
+
agent_id: AGENT_TYPE,
|
|
7658
|
+
agent_name: AGENT_NAME,
|
|
7659
|
+
evaluation_time_ms: Date.now() - _evalStart
|
|
7660
|
+
}),
|
|
7661
|
+
signal: AbortSignal.timeout(3e3)
|
|
7662
|
+
});
|
|
7663
|
+
} catch {
|
|
7664
|
+
}
|
|
7665
|
+
await maybeSelfUpdate();
|
|
7666
|
+
if (AGENT_TYPE === "gemini-cli") {
|
|
7667
|
+
process.stdout.write(JSON.stringify({ decision: "deny", reason: ghostHit }));
|
|
7668
|
+
process.exit(0);
|
|
7669
|
+
}
|
|
7670
|
+
process.stderr.write(ghostHit);
|
|
7671
|
+
process.exit(2);
|
|
7672
|
+
}
|
|
7673
|
+
if (AGENT_TYPE !== "gemini-cli" && toolName === "Bash") {
|
|
7674
|
+
const rw = ghostListingRewrite(args, securityCfg.ghost);
|
|
7675
|
+
if (rw) {
|
|
7676
|
+
await maybeSelfUpdate();
|
|
7677
|
+
rewriteTool({ command: rw });
|
|
7678
|
+
}
|
|
7679
|
+
}
|
|
7680
|
+
}
|
|
7479
7681
|
if (!reason)
|
|
7480
7682
|
reason = securityLayerCheck(toolName, args, securityCfg, agentKey);
|
|
7481
7683
|
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 = 17;
|
|
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
|
|
@@ -188,6 +188,16 @@ function allowTool() {
|
|
|
188
188
|
process.exit(0);
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
// Allow the tool but REPLACE its input (Claude Code `updatedInput`). Used by the
|
|
192
|
+
// ghost layer to rewrite a listing command so hidden entries are filtered out of
|
|
193
|
+
// its output — the agent never sees them. Claude Code only.
|
|
194
|
+
function rewriteTool(updatedInput) {
|
|
195
|
+
process.stdout.write(JSON.stringify({
|
|
196
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', updatedInput },
|
|
197
|
+
}));
|
|
198
|
+
process.exit(0);
|
|
199
|
+
}
|
|
200
|
+
|
|
191
201
|
// Write flag file so stop.mjs knows a tool call (DENY) happened and doesn't log extra ALLOW
|
|
192
202
|
function writeDenyFlag(toolName) {
|
|
193
203
|
try {
|
|
@@ -671,6 +681,159 @@ function dlpScan(args, cfg) {
|
|
|
671
681
|
return null;
|
|
672
682
|
}
|
|
673
683
|
|
|
684
|
+
// ── Ghost paths ──
|
|
685
|
+
// Files/dirs matching a ghost glob are INVISIBLE to the agent. This module is
|
|
686
|
+
// mirrored verbatim in audit.mjs (the PostToolUse twin that strips them from
|
|
687
|
+
// output). Keep the two copies in sync.
|
|
688
|
+
//
|
|
689
|
+
// Split of responsibility:
|
|
690
|
+
// - PreToolUse (here): block MUTATIONS (write/edit/delete/move) targeting a
|
|
691
|
+
// ghost path, returning a plain "No such file or directory" — never a
|
|
692
|
+
// SolonGate/policy message — so the path looks like it simply doesn't exist.
|
|
693
|
+
// - PostToolUse (audit.mjs): strip ghost entries from listings and rewrite
|
|
694
|
+
// direct reads to not-found. Reads are NOT blocked here so the agent gets a
|
|
695
|
+
// natural "missing file" rather than a visible hook block.
|
|
696
|
+
function ghostGlobToRegExp(glob) {
|
|
697
|
+
let re = '';
|
|
698
|
+
for (let i = 0; i < glob.length; i++) {
|
|
699
|
+
const c = glob[i];
|
|
700
|
+
if (c === '*') {
|
|
701
|
+
if (glob[i + 1] === '*') { re += '.*'; i++; }
|
|
702
|
+
else re += '[^/]*';
|
|
703
|
+
} else if (c === '?') re += '[^/]';
|
|
704
|
+
else if ('\\^$.|+()[]{}'.indexOf(c) !== -1) re += '\\' + c;
|
|
705
|
+
else re += c;
|
|
706
|
+
}
|
|
707
|
+
try { return new RegExp('^' + re + '$'); } catch { return null; }
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// True if `targetPath` is ghosted by any pattern. A bare name (`.data`) matches
|
|
711
|
+
// that entry anywhere in the path; a trailing `/` (`secrets/`) ghosts a whole
|
|
712
|
+
// directory subtree; a pattern with `/` is matched against the full path.
|
|
713
|
+
function ghostMatch(targetPath, patterns) {
|
|
714
|
+
if (!targetPath || !Array.isArray(patterns) || patterns.length === 0) return false;
|
|
715
|
+
const norm = String(targetPath).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
716
|
+
if (!norm) return false;
|
|
717
|
+
const segments = norm.split('/').filter(Boolean);
|
|
718
|
+
const base = segments.length ? segments[segments.length - 1] : norm;
|
|
719
|
+
for (let pat of patterns) {
|
|
720
|
+
pat = String(pat || '').trim();
|
|
721
|
+
if (!pat) continue;
|
|
722
|
+
let dirOnly = false;
|
|
723
|
+
if (pat.endsWith('/')) { dirOnly = true; pat = pat.slice(0, -1); }
|
|
724
|
+
if (!pat) continue;
|
|
725
|
+
const hasSlash = pat.indexOf('/') !== -1;
|
|
726
|
+
const hasWild = /[*?]/.test(pat);
|
|
727
|
+
const re = ghostGlobToRegExp(pat);
|
|
728
|
+
if (!re) continue;
|
|
729
|
+
if (dirOnly) {
|
|
730
|
+
// Directory: ghost the dir itself and everything under it.
|
|
731
|
+
if (!hasSlash && !hasWild) { if (segments.indexOf(pat) !== -1) return true; continue; }
|
|
732
|
+
let acc = '';
|
|
733
|
+
for (const s of segments) { acc = acc ? acc + '/' + s : s; if (re.test(acc) || re.test(s)) return true; }
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
if (!hasSlash) {
|
|
737
|
+
// Name glob: match basename or any single path segment.
|
|
738
|
+
if (re.test(base)) return true;
|
|
739
|
+
if (segments.some((s) => re.test(s))) return true;
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
// Path glob (contains '/'): match the full normalized path.
|
|
743
|
+
if (re.test(norm)) return true;
|
|
744
|
+
}
|
|
745
|
+
return false;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Strip shell decoration from a token so it can be tested as a path:
|
|
749
|
+
// surrounding quotes, redirection operators, trailing punctuation.
|
|
750
|
+
function ghostCleanToken(tok) {
|
|
751
|
+
let t = String(tok || '').trim();
|
|
752
|
+
t = t.replace(/^[<>|;&(]+/, '').replace(/[);&|]+$/, '');
|
|
753
|
+
t = t.replace(/^['"]+/, '').replace(/['"]+$/, '');
|
|
754
|
+
t = t.replace(/^\d*>>?/, ''); // strip leading redirection like 2>
|
|
755
|
+
return t.trim();
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Returns a plain not-found message if a tool DIRECTLY targets a ghost path —
|
|
759
|
+
// read OR write — else null. Reads are sealed too: a hidden file must be
|
|
760
|
+
// inaccessible, not merely unlisted, so `cat A/Y/.data` looks as absent as
|
|
761
|
+
// `rm A/Y`. Listing a PARENT dir that only CONTAINS a ghost child is NOT a
|
|
762
|
+
// direct hit (no token equals the ghost) and falls through to the listing
|
|
763
|
+
// rewrite. Never returns a branded/policy string.
|
|
764
|
+
function ghostBlock(toolName, args, ghostCfg) {
|
|
765
|
+
if (!ghostCfg || !Array.isArray(ghostCfg.patterns) || ghostCfg.patterns.length === 0) return null;
|
|
766
|
+
const pats = ghostCfg.patterns;
|
|
767
|
+
const name = (toolName || '');
|
|
768
|
+
const notFound = (p) => p + ': No such file or directory';
|
|
769
|
+
try {
|
|
770
|
+
// Tools that carry an explicit path argument.
|
|
771
|
+
if (name === 'Write' || name === 'Edit' || name === 'MultiEdit' || name === 'NotebookEdit' ||
|
|
772
|
+
name === 'Read' || name === 'NotebookRead' || name === 'LS') {
|
|
773
|
+
const p = args?.file_path || args?.notebook_path || args?.path || '';
|
|
774
|
+
if (p && ghostMatch(p, pats)) return notFound(p);
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
if (name === 'Glob' || name === 'Grep') {
|
|
778
|
+
const p = args?.path || '';
|
|
779
|
+
const pat = args?.pattern || args?.glob || '';
|
|
780
|
+
if (p && ghostMatch(p, pats)) return notFound(p);
|
|
781
|
+
if (pat && ghostMatch(pat, pats)) return notFound(String(pat));
|
|
782
|
+
return null;
|
|
783
|
+
}
|
|
784
|
+
// Bash & other exec: deny if any token directly names a ghost path. Seals
|
|
785
|
+
// direct reads (cat/head/less/…) and mutations (rm/mv/…) alike. A listing of
|
|
786
|
+
// a parent dir has no ghost token and falls through to the rewrite.
|
|
787
|
+
if (name === 'Bash' || name === 'BashOutput' || guessPermission(name) === 'EXECUTE') {
|
|
788
|
+
const cmd = String(args?.command || '');
|
|
789
|
+
if (!cmd) return null;
|
|
790
|
+
for (const raw of cmd.split(/\s+/)) {
|
|
791
|
+
const tok = ghostCleanToken(raw);
|
|
792
|
+
if (tok && tok.indexOf('-') !== 0 && ghostMatch(tok, pats)) return notFound(tok);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
} catch { /* fail open */ }
|
|
796
|
+
return null;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Shell single-quote a string.
|
|
800
|
+
function ghostShq(s) { return "'" + String(s).replace(/'/g, "'\\''") + "'"; }
|
|
801
|
+
|
|
802
|
+
// If `args.command` is a simple directory listing, return a rewritten command
|
|
803
|
+
// that pipes its output through a filter dropping the ghost entries — so the
|
|
804
|
+
// agent never sees them. Returns null when there's nothing to rewrite. Only
|
|
805
|
+
// touches bare `ls` listings (no pipe/redirect/compound) to stay safe; richer
|
|
806
|
+
// output formats are handled by the PostToolUse filter on clients that honor it.
|
|
807
|
+
function ghostListingRewrite(args, ghostCfg) {
|
|
808
|
+
if (!ghostCfg || !Array.isArray(ghostCfg.patterns) || ghostCfg.patterns.length === 0) return null;
|
|
809
|
+
const cmd = String(args?.command || '');
|
|
810
|
+
if (!cmd) return null;
|
|
811
|
+
if (/[|>;&\n`]/.test(cmd)) return null; // no shell composition
|
|
812
|
+
if (!/^\s*(ls|ll|dir|find|tree|exa|lsd|fd)(\s|$)/.test(cmd)) return null;
|
|
813
|
+
// Translate each hidden glob to an ERE alternative, then match it as a whole
|
|
814
|
+
// path component, the whole line, or the trailing token — covers `ls`,
|
|
815
|
+
// `ls -la`, `find` and `tree`. A trailing slash (dir) is dropped.
|
|
816
|
+
const alts = [];
|
|
817
|
+
for (let p of ghostCfg.patterns) {
|
|
818
|
+
p = String(p).replace(/\/$/, '');
|
|
819
|
+
if (!p) continue;
|
|
820
|
+
let re = '';
|
|
821
|
+
for (let i = 0; i < p.length; i++) {
|
|
822
|
+
const c = p[i];
|
|
823
|
+
if (c === '*') { if (p[i + 1] === '*') { re += '.*'; i++; } else re += '[^/]*'; }
|
|
824
|
+
else if (c === '?') re += '[^/]';
|
|
825
|
+
else if ('.^$+(){}[]|\\/'.indexOf(c) >= 0) re += '\\' + c;
|
|
826
|
+
else re += c;
|
|
827
|
+
}
|
|
828
|
+
alts.push(re);
|
|
829
|
+
}
|
|
830
|
+
if (alts.length === 0) return null;
|
|
831
|
+
// grep -vE drops any line where a hidden name appears as a path component, the
|
|
832
|
+
// whole line, or the final token. Single-quoted so the shell leaves it intact.
|
|
833
|
+
const ere = '(^|/| )(' + alts.join('|') + ')(/|$)';
|
|
834
|
+
return cmd + " | grep -vE '" + ere.replace(/'/g, "'\\''") + "'";
|
|
835
|
+
}
|
|
836
|
+
|
|
674
837
|
// Multi-window sliding rate limit, persisted under ~/.solongate (tamper-protected
|
|
675
838
|
// from the agent, writable by the guard). One timestamps file per agent, pruned
|
|
676
839
|
// to the last 24h and capped for performance; counts this agent's calls within
|
|
@@ -1238,6 +1401,41 @@ process.stdin.on('end', async () => {
|
|
|
1238
1401
|
// Tamper / self-protection — runs before policy eval. ON by default; the
|
|
1239
1402
|
// per-project cloud setting can disable it (fail safe: stays on if unread).
|
|
1240
1403
|
let reason = selfProtectEnabled ? tamperCheck(toolName, args) : null;
|
|
1404
|
+
// Ghost paths — handled BEFORE the other layers and emitted as a STEALTH
|
|
1405
|
+
// block: a mutating op on a hidden path is denied with a bare OS-style
|
|
1406
|
+
// "No such file or directory" and NOTHING else (no ROUTE line, no SolonGate
|
|
1407
|
+
// wording), so the agent can't tell the path is protected — it just looks
|
|
1408
|
+
// absent. (Reads/listings aren't blocked; the PostToolUse hook strips them.)
|
|
1409
|
+
if (!reason && securityCfg && securityCfg.ghost) {
|
|
1410
|
+
const ghostHit = ghostBlock(toolName, args, securityCfg.ghost);
|
|
1411
|
+
if (ghostHit) {
|
|
1412
|
+
try { writeDenyFlag(toolName); } catch {}
|
|
1413
|
+
try {
|
|
1414
|
+
await fetch(API_URL + '/api/v1/audit-logs', {
|
|
1415
|
+
method: 'POST',
|
|
1416
|
+
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
1417
|
+
body: JSON.stringify({
|
|
1418
|
+
tool: toolName, arguments: args, decision: 'DENY',
|
|
1419
|
+
reason: 'ghost path (hidden from agent)',
|
|
1420
|
+
permission: guessPermission(toolName),
|
|
1421
|
+
source: `${AGENT_TYPE}-guard`, agent_id: AGENT_TYPE, agent_name: AGENT_NAME,
|
|
1422
|
+
evaluation_time_ms: Date.now() - _evalStart,
|
|
1423
|
+
}),
|
|
1424
|
+
signal: AbortSignal.timeout(3000),
|
|
1425
|
+
});
|
|
1426
|
+
} catch {}
|
|
1427
|
+
await maybeSelfUpdate();
|
|
1428
|
+
if (AGENT_TYPE === 'gemini-cli') { process.stdout.write(JSON.stringify({ decision: 'deny', reason: ghostHit })); process.exit(0); }
|
|
1429
|
+
process.stderr.write(ghostHit);
|
|
1430
|
+
process.exit(2);
|
|
1431
|
+
}
|
|
1432
|
+
// No direct hit: if this is a listing command, rewrite it so hidden
|
|
1433
|
+
// entries are filtered out of its output (Claude Code only).
|
|
1434
|
+
if (AGENT_TYPE !== 'gemini-cli' && toolName === 'Bash') {
|
|
1435
|
+
const rw = ghostListingRewrite(args, securityCfg.ghost);
|
|
1436
|
+
if (rw) { await maybeSelfUpdate(); rewriteTool({ command: rw }); }
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1241
1439
|
// Extra security layers run after tamper, before policy. Block reason wins
|
|
1242
1440
|
// immediately (BLACK). Fail-open by design.
|
|
1243
1441
|
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.
|
|
3
|
+
"version": "0.48.0",
|
|
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": {
|
|
@@ -23,13 +23,6 @@
|
|
|
23
23
|
"hooks",
|
|
24
24
|
"README.md"
|
|
25
25
|
],
|
|
26
|
-
"scripts": {
|
|
27
|
-
"build": "tsup && pnpm build:hooks",
|
|
28
|
-
"build:hooks": "node scripts/bundle-hooks.mjs",
|
|
29
|
-
"dev": "tsx src/index.ts",
|
|
30
|
-
"typecheck": "tsc --noEmit",
|
|
31
|
-
"clean": "rm -rf dist .turbo"
|
|
32
|
-
},
|
|
33
26
|
"keywords": [
|
|
34
27
|
"ai-tool-security",
|
|
35
28
|
"ai-tool-proxy",
|
|
@@ -65,12 +58,19 @@
|
|
|
65
58
|
},
|
|
66
59
|
"devDependencies": {
|
|
67
60
|
"@open-policy-agent/opa-wasm": "^1.10.0",
|
|
68
|
-
"@solongate/core": "workspace:*",
|
|
69
|
-
"@solongate/policy-engine": "workspace:*",
|
|
70
|
-
"@solongate/tsconfig": "workspace:*",
|
|
71
61
|
"esbuild": "^0.19.12",
|
|
72
62
|
"tsup": "^8.3.0",
|
|
73
63
|
"tsx": "^4.19.0",
|
|
74
|
-
"typescript": "^5.7.0"
|
|
64
|
+
"typescript": "^5.7.0",
|
|
65
|
+
"@solongate/core": "0.5.0",
|
|
66
|
+
"@solongate/policy-engine": "0.3.1",
|
|
67
|
+
"@solongate/tsconfig": "0.0.0"
|
|
68
|
+
},
|
|
69
|
+
"scripts": {
|
|
70
|
+
"build": "tsup && pnpm build:hooks",
|
|
71
|
+
"build:hooks": "node scripts/bundle-hooks.mjs",
|
|
72
|
+
"dev": "tsx src/index.ts",
|
|
73
|
+
"typecheck": "tsc --noEmit",
|
|
74
|
+
"clean": "rm -rf dist .turbo"
|
|
75
75
|
}
|
|
76
|
-
}
|
|
76
|
+
}
|