@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 +0 -0
- package/dist/init.js +0 -0
- package/hooks/audit.mjs +3 -1
- package/hooks/guard.bundled.mjs +63 -7
- package/hooks/guard.mjs +75 -14
- package/package.json +13 -13
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:
|
|
240
|
+
hookSpecificOutput: { hookEventName: 'PostToolUse', updatedToolOutput: ghostText },
|
|
239
241
|
}));
|
|
240
242
|
}
|
|
241
243
|
} catch {}
|
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");
|
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
//
|
|
749
|
-
//
|
|
750
|
-
//
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|