@solongate/proxy 0.47.5 → 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/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # @solongate/proxy
2
2
 
3
- > **Don't `npm i` this.** There's nothing to install. Run the one-liner below to pair your machine:
3
+ > **Don't `npm i` this.** There's nothing to install. Run the two commands below to pair your machine:
4
4
  > ```bash
5
+ > npx -y @solongate/proxy@latest
5
6
  > npx -y @solongate/proxy@latest login
6
7
  > ```
7
8
 
@@ -26,9 +27,10 @@ SolonGate comes in two editions that share the same policy model and dashboard:
26
27
 
27
28
  **You need:** a free [SolonGate account](https://auth.solongate.com), Node.js 18+ on the machine you want to protect, and an AI tool that makes tool calls (Claude Code; Gemini CLI is also supported).
28
29
 
29
- Pair the machine and turn on the guard — one command, no API keys to copy:
30
+ Pair the machine and turn on the guard — no API keys to copy. First fetch SolonGate, then log in:
30
31
 
31
32
  ```bash
33
+ npx -y @solongate/proxy@latest
32
34
  npx -y @solongate/proxy@latest login
33
35
  ```
34
36
 
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 ||
@@ -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 = 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 = 14;
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.5",
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": {