@solongate/proxy 0.47.7 → 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
File without changes
package/dist/init.js CHANGED
File without changes
package/hooks/audit.mjs CHANGED
@@ -234,8 +234,10 @@ process.stdin.on('end', async () => {
234
234
  try {
235
235
  const ghostText = buildGhostOutput(toolName, toolInput, toolResponse, toolOutput, loadGhostPatterns());
236
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.
237
239
  process.stdout.write(JSON.stringify({
238
- hookSpecificOutput: { hookEventName: 'PostToolUse', updatedToolOutput: { type: 'text', text: ghostText } },
240
+ hookSpecificOutput: { hookEventName: 'PostToolUse', updatedToolOutput: ghostText },
239
241
  }));
240
242
  }
241
243
  } catch {}
@@ -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 = 15;
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");
@@ -7107,7 +7113,6 @@ function ghostCleanToken(tok) {
7107
7113
  t = t.replace(/^\d*>>?/, "");
7108
7114
  return t.trim();
7109
7115
  }
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
7116
  function ghostBlock(toolName, args, ghostCfg) {
7112
7117
  if (!ghostCfg || !Array.isArray(ghostCfg.patterns) || ghostCfg.patterns.length === 0)
7113
7118
  return null;
@@ -7115,20 +7120,26 @@ function ghostBlock(toolName, args, ghostCfg) {
7115
7120
  const name = toolName || "";
7116
7121
  const notFound = (p) => p + ": No such file or directory";
7117
7122
  try {
7118
- if (name === "Write" || name === "Edit" || name === "MultiEdit" || name === "NotebookEdit") {
7123
+ if (name === "Write" || name === "Edit" || name === "MultiEdit" || name === "NotebookEdit" || name === "Read" || name === "NotebookRead" || name === "LS") {
7119
7124
  const p = args?.file_path || args?.notebook_path || args?.path || "";
7120
7125
  if (p && ghostMatch(p, pats))
7121
7126
  return notFound(p);
7122
7127
  return null;
7123
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
+ }
7124
7138
  if (name === "Bash" || name === "BashOutput" || guessPermission(name) === "EXECUTE") {
7125
7139
  const cmd = String(args?.command || "");
7126
7140
  if (!cmd)
7127
7141
  return null;
7128
- if (!GHOST_MUTATORS.test(cmd))
7129
- return null;
7130
- const tokens = cmd.split(/\s+/);
7131
- for (const raw of tokens) {
7142
+ for (const raw of cmd.split(/\s+/)) {
7132
7143
  const tok = ghostCleanToken(raw);
7133
7144
  if (tok && tok.indexOf("-") !== 0 && ghostMatch(tok, pats))
7134
7145
  return notFound(tok);
@@ -7138,6 +7149,44 @@ function ghostBlock(toolName, args, ghostCfg) {
7138
7149
  }
7139
7150
  return null;
7140
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
+ }
7141
7190
  var RL_WINDOWS = [
7142
7191
  { key: "perDay", ms: 864e5, label: "day" },
7143
7192
  { key: "perHour", ms: 36e5, label: "hour" },
@@ -7621,6 +7670,13 @@ process.stdin.on("end", async () => {
7621
7670
  process.stderr.write(ghostHit);
7622
7671
  process.exit(2);
7623
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
+ }
7624
7680
  }
7625
7681
  if (!reason)
7626
7682
  reason = securityLayerCheck(toolName, args, securityCfg, agentKey);
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 = 15;
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 {
@@ -745,32 +755,39 @@ function ghostCleanToken(tok) {
745
755
  return t.trim();
746
756
  }
747
757
 
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.
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.
755
764
  function ghostBlock(toolName, args, ghostCfg) {
756
765
  if (!ghostCfg || !Array.isArray(ghostCfg.patterns) || ghostCfg.patterns.length === 0) return null;
757
766
  const pats = ghostCfg.patterns;
758
767
  const name = (toolName || '');
759
768
  const notFound = (p) => p + ': No such file or directory';
760
769
  try {
761
- // File-mutating tools carry an explicit path.
762
- if (name === 'Write' || name === 'Edit' || name === 'MultiEdit' || name === 'NotebookEdit') {
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') {
763
773
  const p = args?.file_path || args?.notebook_path || args?.path || '';
764
774
  if (p && ghostMatch(p, pats)) return notFound(p);
765
775
  return null;
766
776
  }
767
- // Bash: block only when a ghost path is the target of a mutating verb.
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.
768
787
  if (name === 'Bash' || name === 'BashOutput' || guessPermission(name) === 'EXECUTE') {
769
788
  const cmd = String(args?.command || '');
770
789
  if (!cmd) return null;
771
- if (!GHOST_MUTATORS.test(cmd)) return null;
772
- const tokens = cmd.split(/\s+/);
773
- for (const raw of tokens) {
790
+ for (const raw of cmd.split(/\s+/)) {
774
791
  const tok = ghostCleanToken(raw);
775
792
  if (tok && tok.indexOf('-') !== 0 && ghostMatch(tok, pats)) return notFound(tok);
776
793
  }
@@ -779,6 +796,44 @@ function ghostBlock(toolName, args, ghostCfg) {
779
796
  return null;
780
797
  }
781
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
+
782
837
  // Multi-window sliding rate limit, persisted under ~/.solongate (tamper-protected
783
838
  // from the agent, writable by the guard). One timestamps file per agent, pruned
784
839
  // to the last 24h and capped for performance; counts this agent's calls within
@@ -1374,6 +1429,12 @@ process.stdin.on('end', async () => {
1374
1429
  process.stderr.write(ghostHit);
1375
1430
  process.exit(2);
1376
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
+ }
1377
1438
  }
1378
1439
  // Extra security layers run after tamper, before policy. Block reason wins
1379
1440
  // immediately (BLACK). Fail-open by design.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solongate/proxy",
3
- "version": "0.47.7",
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
+ }