@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 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 ||
@@ -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 = 14;
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 = 14;
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.47.6",
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
+ }