@intelli-1113/stratos-sdk 1.3.1 → 1.3.2
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/package.json +9 -1
- package/src/cli-inspect.js +1 -1
- package/src/enforcement.js +163 -0
- package/src/index.js +26 -1
- package/src/mcp-proxy.js +42 -2
- package/src/register.js +18 -2
- package/src/vercel-ai-loader.js +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intelli-1113/stratos-sdk",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.2",
|
|
4
4
|
"description": "Stratos AI agent telemetry (OpenLLMetry), Vercel AI auto-spans, LangChain inspect/fix, MCP proxy.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -37,5 +37,13 @@
|
|
|
37
37
|
"@traceloop/node-server-sdk": "^0.27.0",
|
|
38
38
|
"import-in-the-middle": "^1.15.0",
|
|
39
39
|
"require-in-the-middle": "^7.5.2"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@langchain/core": ">=1.0.0 <2.0.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"@langchain/core": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
40
48
|
}
|
|
41
49
|
}
|
package/src/cli-inspect.js
CHANGED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Network-level budget / lifecycle enforcement.
|
|
2
|
+
//
|
|
3
|
+
// WHY THE NETWORK LAYER, NOT THE SDK LAYER
|
|
4
|
+
// -----------------------------------------------------------------------------
|
|
5
|
+
// OpenAI, Anthropic, LangChain, Vercel AI, Google GenAI — every one of them
|
|
6
|
+
// ultimately does the same thing: send an outbound HTTP request to the
|
|
7
|
+
// provider's API. Traceloop's own instrumentation (which this SDK depends on
|
|
8
|
+
// but does not own — @traceloop/instrumentation-openai et al.) exposes no
|
|
9
|
+
// pre-call hook that can refuse a request; its config surface is
|
|
10
|
+
// traceContent / enrichTokens / exceptionLogger / uploadBase64Image only.
|
|
11
|
+
// Wrapping every provider SDK's call function individually is also a moving
|
|
12
|
+
// target — a "custom agent" built directly on a raw SDK gets no protection
|
|
13
|
+
// until someone ships a wrapper for that specific library.
|
|
14
|
+
//
|
|
15
|
+
// Patching global `fetch` (and `http`/`https` as a fallback for older
|
|
16
|
+
// transports) catches all of them in ONE place, before the request leaves the
|
|
17
|
+
// process, with no per-provider code and no dependency on Traceloop's
|
|
18
|
+
// internals. This module is installed from register.js, before any provider
|
|
19
|
+
// SDK is imported by the agent's own code — the same ordering guarantee the
|
|
20
|
+
// Vercel AI ESM loader already relies on.
|
|
21
|
+
//
|
|
22
|
+
// This does NOT cover the MCP proxy path (src/mcp-proxy.js): that spawns a
|
|
23
|
+
// separate child process and proxies its stdio, so patching fetch in THIS
|
|
24
|
+
// process has no effect on the child's own network calls. The proxy has its
|
|
25
|
+
// own, necessary check at the JSON-RPC `tools/call` boundary instead.
|
|
26
|
+
|
|
27
|
+
import http from "node:http";
|
|
28
|
+
import https from "node:https";
|
|
29
|
+
|
|
30
|
+
export class StratosBudgetBlockedError extends Error {
|
|
31
|
+
constructor(reason) {
|
|
32
|
+
super(`[stratos] request blocked: ${reason || "budget or lifecycle limit reached"}`);
|
|
33
|
+
this.name = "StratosBudgetBlockedError";
|
|
34
|
+
this.stratosBlocked = true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Cached from the heartbeat response (see index.js / mcp-proxy.js). Starts
|
|
39
|
+
// unblocked: an agent must never be frozen just because it hasn't heard back
|
|
40
|
+
// from Stratos yet — same fail-open philosophy as the server's own enforcement
|
|
41
|
+
// check (lib/quotaEnforce.ts: "fail open, loudly").
|
|
42
|
+
let _blocked = false;
|
|
43
|
+
let _reason = null;
|
|
44
|
+
|
|
45
|
+
export function setBlocked(blocked, reason) {
|
|
46
|
+
_blocked = !!blocked;
|
|
47
|
+
_reason = reason || null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isBlocked() {
|
|
51
|
+
return { blocked: _blocked, reason: _reason };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Hostnames of LLM/agent providers to guard. Extend without an SDK release via
|
|
55
|
+
// STRATOS_BLOCK_HOSTS (comma-separated substrings) — e.g. a private Azure
|
|
56
|
+
// OpenAI deployment hostname, or a self-hosted gateway.
|
|
57
|
+
const DEFAULT_PROVIDER_HOSTS = [
|
|
58
|
+
"api.openai.com",
|
|
59
|
+
"api.anthropic.com",
|
|
60
|
+
"generativelanguage.googleapis.com",
|
|
61
|
+
"aiplatform.googleapis.com",
|
|
62
|
+
"bedrock-runtime.", // *.amazonaws.com region-scoped, matched by substring
|
|
63
|
+
"api.cohere.ai",
|
|
64
|
+
"api.mistral.ai",
|
|
65
|
+
"openrouter.ai",
|
|
66
|
+
"api.together.xyz",
|
|
67
|
+
"api.groq.com",
|
|
68
|
+
"openai.azure.com",
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
function providerHosts() {
|
|
72
|
+
const extra = (process.env.STRATOS_BLOCK_HOSTS || "")
|
|
73
|
+
.split(",")
|
|
74
|
+
.map((s) => s.trim())
|
|
75
|
+
.filter(Boolean);
|
|
76
|
+
return [...DEFAULT_PROVIDER_HOSTS, ...extra];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Never guard Stratos's own host: the agent has to be able to reach Stratos to
|
|
80
|
+
// find out it has been unblocked. If this returned the wrong host the agent
|
|
81
|
+
// would deadlock itself — checked defensively, not just assumed.
|
|
82
|
+
function ownHost() {
|
|
83
|
+
try {
|
|
84
|
+
return new URL(process.env.STRATOS_URL || "http://localhost:4000").host;
|
|
85
|
+
} catch {
|
|
86
|
+
return "";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function hostFromInput(input) {
|
|
91
|
+
try {
|
|
92
|
+
if (typeof input === "string") return new URL(input).host;
|
|
93
|
+
if (input instanceof URL) return input.host;
|
|
94
|
+
if (input && typeof input === "object") {
|
|
95
|
+
if (input.url) return new URL(input.url).host; // Request-like
|
|
96
|
+
if (input.hostname || input.host) return String(input.hostname || input.host);
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// Not a parseable URL. Let it through — this guard only ever restricts
|
|
100
|
+
// requests to a known provider host, never anything it can't identify.
|
|
101
|
+
}
|
|
102
|
+
return "";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isGuardedHost(host) {
|
|
106
|
+
if (!host) return false;
|
|
107
|
+
if (host === ownHost()) return false;
|
|
108
|
+
return providerHosts().some((h) => host.includes(h));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let installed = false;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Patch global fetch and http(s).request so a blocked agent cannot reach any
|
|
115
|
+
* known LLM provider, regardless of which SDK it is using. Idempotent — safe
|
|
116
|
+
* to call more than once, and safe to call when no provider SDK is present.
|
|
117
|
+
*/
|
|
118
|
+
export function installNetworkGuard() {
|
|
119
|
+
if (installed) return;
|
|
120
|
+
installed = true;
|
|
121
|
+
|
|
122
|
+
if (typeof globalThis.fetch === "function" && !globalThis.fetch.__stratosGuarded) {
|
|
123
|
+
const realFetch = globalThis.fetch.bind(globalThis);
|
|
124
|
+
const guarded = function stratosGuardedFetch(input, init) {
|
|
125
|
+
if (isGuardedHost(hostFromInput(input))) {
|
|
126
|
+
const { blocked, reason } = isBlocked();
|
|
127
|
+
if (blocked) return Promise.reject(new StratosBudgetBlockedError(reason));
|
|
128
|
+
}
|
|
129
|
+
return realFetch(input, init);
|
|
130
|
+
};
|
|
131
|
+
guarded.__stratosGuarded = true;
|
|
132
|
+
globalThis.fetch = guarded;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
patchRequestModule(http);
|
|
136
|
+
patchRequestModule(https);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Secondary guard for legacy http(s)-based clients — the fetch guard above is
|
|
141
|
+
* the primary path and already covers current OpenAI / Anthropic / Google /
|
|
142
|
+
* Vercel AI SDKs, which all use fetch on Node 18+. A synchronous throw here is
|
|
143
|
+
* non-standard for a network failure (Node normally emits an async 'error' on
|
|
144
|
+
* the returned request), but building a spec-compliant fake ClientRequest for
|
|
145
|
+
* a fallback path most agents never exercise is not worth the complexity —
|
|
146
|
+
* callers of http(s).request that care will have it in a try/catch already,
|
|
147
|
+
* since the real function can also throw synchronously for invalid options.
|
|
148
|
+
*/
|
|
149
|
+
function patchRequestModule(mod) {
|
|
150
|
+
if (!mod || !mod.request || mod.request.__stratosGuarded) return;
|
|
151
|
+
const real = mod.request;
|
|
152
|
+
const guarded = function stratosGuardedRequest(...args) {
|
|
153
|
+
const opts = args.find((a) => a && typeof a === "object" && typeof a !== "function");
|
|
154
|
+
const host = opts ? String(opts.hostname || opts.host || "") : hostFromInput(args[0]);
|
|
155
|
+
if (isGuardedHost(host)) {
|
|
156
|
+
const { blocked, reason } = isBlocked();
|
|
157
|
+
if (blocked) throw new StratosBudgetBlockedError(reason);
|
|
158
|
+
}
|
|
159
|
+
return real.apply(mod, args);
|
|
160
|
+
};
|
|
161
|
+
guarded.__stratosGuarded = true;
|
|
162
|
+
mod.request = guarded;
|
|
163
|
+
}
|
package/src/index.js
CHANGED
|
@@ -16,6 +16,7 @@ import { readFileSync } from "node:fs";
|
|
|
16
16
|
import { join } from "node:path";
|
|
17
17
|
import { enableVercelAiTelemetry } from "./vercel-ai.js";
|
|
18
18
|
import { inspectLangChain } from "./inspect-langchain.js";
|
|
19
|
+
import { setBlocked } from "./enforcement.js";
|
|
19
20
|
|
|
20
21
|
export { inspectLangChain, fixLangChain } from "./inspect-langchain.js";
|
|
21
22
|
|
|
@@ -112,6 +113,7 @@ export function start(opts = {}) {
|
|
|
112
113
|
traceloop.initialize({
|
|
113
114
|
appName,
|
|
114
115
|
disableBatch: true,
|
|
116
|
+
logLevel: process.env.STRATOS_DEBUG ? "debug" : undefined,
|
|
115
117
|
exporter: new OTLPTraceExporter({
|
|
116
118
|
url: ingest,
|
|
117
119
|
headers: { "x-stratos-token": token },
|
|
@@ -156,6 +158,13 @@ export function start(opts = {}) {
|
|
|
156
158
|
tools: toolsEnv,
|
|
157
159
|
};
|
|
158
160
|
|
|
161
|
+
// Heartbeat is also how the network guard (enforcement.js) learns whether to
|
|
162
|
+
// freeze this agent — budget exhausted, period crossed, or the agent
|
|
163
|
+
// manually disabled from the Control Tower all collapse into the same
|
|
164
|
+
// { blocked, reason } flag on the server, so the SDK does not need to know
|
|
165
|
+
// *why*, only that it is. The response used to be discarded entirely
|
|
166
|
+
// (`.catch(() => {})`, no `.then()`); reading it is the only change here.
|
|
167
|
+
let warnedBlocked = false;
|
|
159
168
|
const ping = () =>
|
|
160
169
|
fetch(heartbeatUrl, {
|
|
161
170
|
method: "POST",
|
|
@@ -164,7 +173,23 @@ export function start(opts = {}) {
|
|
|
164
173
|
"Content-Type": "application/json",
|
|
165
174
|
},
|
|
166
175
|
body: JSON.stringify(meta),
|
|
167
|
-
})
|
|
176
|
+
})
|
|
177
|
+
.then((r) => r.json().catch(() => null))
|
|
178
|
+
.then((data) => {
|
|
179
|
+
if (!data || typeof data.blocked !== "boolean") return;
|
|
180
|
+
setBlocked(data.blocked, data.reason);
|
|
181
|
+
if (data.blocked && !warnedBlocked) {
|
|
182
|
+
warnedBlocked = true;
|
|
183
|
+
console.warn(
|
|
184
|
+
`[stratos] BLOCKED: ${data.reason || "budget or lifecycle limit reached"} — ` +
|
|
185
|
+
`outbound calls to LLM providers will be refused until this clears.`
|
|
186
|
+
);
|
|
187
|
+
} else if (!data.blocked && warnedBlocked) {
|
|
188
|
+
warnedBlocked = false;
|
|
189
|
+
console.log("[stratos] unblocked — outbound calls to LLM providers resumed.");
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
.catch(() => {});
|
|
168
193
|
ping();
|
|
169
194
|
if (heartbeatMs > 0) setInterval(ping, heartbeatMs).unref();
|
|
170
195
|
|
package/src/mcp-proxy.js
CHANGED
|
@@ -23,6 +23,12 @@ console.log = console.info = (...a) => process.stderr.write(a.map(String).join("
|
|
|
23
23
|
|
|
24
24
|
import { spawn } from "node:child_process";
|
|
25
25
|
import { randomBytes } from "node:crypto";
|
|
26
|
+
// The network guard installed by register.js (see enforcement.js) patches
|
|
27
|
+
// THIS process's fetch/http — it has no effect on the target MCP server, which
|
|
28
|
+
// runs as a separate child process spawned below, possibly in a different
|
|
29
|
+
// language/runtime entirely. The only place budget/lifecycle enforcement can
|
|
30
|
+
// act on that child is here, at the JSON-RPC boundary between the host and it.
|
|
31
|
+
import { setBlocked, isBlocked } from "./enforcement.js";
|
|
26
32
|
|
|
27
33
|
let argv = process.argv.slice(2);
|
|
28
34
|
const sep = argv.indexOf("--");
|
|
@@ -65,9 +71,26 @@ function reportTool(name, argsObj, out, isErr, startMs, endMs) {
|
|
|
65
71
|
fetch(`${URL}/api/ingest`, { method: "POST", headers: hdr, body: JSON.stringify(body) }).catch(() => {});
|
|
66
72
|
}
|
|
67
73
|
|
|
74
|
+
let warnedBlocked = false;
|
|
68
75
|
function heartbeat() {
|
|
69
76
|
if (!TOKEN) return;
|
|
70
|
-
fetch(`${URL}/api/heartbeat`, { method: "POST", headers: hdr })
|
|
77
|
+
fetch(`${URL}/api/heartbeat`, { method: "POST", headers: hdr })
|
|
78
|
+
.then((r) => r.json().catch(() => null))
|
|
79
|
+
.then((data) => {
|
|
80
|
+
if (!data || typeof data.blocked !== "boolean") return;
|
|
81
|
+
setBlocked(data.blocked, data.reason);
|
|
82
|
+
// Visible feedback in the proxy's own log (stderr — stdout is the
|
|
83
|
+
// protocol channel). Without this there is no way to see a block take
|
|
84
|
+
// effect other than a tool call failing with no explanation.
|
|
85
|
+
if (data.blocked && !warnedBlocked) {
|
|
86
|
+
warnedBlocked = true;
|
|
87
|
+
process.stderr.write(`[stratos-proxy] BLOCKED: ${data.reason || "budget or lifecycle limit reached"} — tools/call will be refused until this clears.\n`);
|
|
88
|
+
} else if (!data.blocked && warnedBlocked) {
|
|
89
|
+
warnedBlocked = false;
|
|
90
|
+
process.stderr.write("[stratos-proxy] unblocked — tool calls resumed.\n");
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
.catch(() => {});
|
|
71
94
|
}
|
|
72
95
|
heartbeat();
|
|
73
96
|
const hbTimer = setInterval(heartbeat, HEARTBEAT_MS);
|
|
@@ -83,7 +106,24 @@ linePump(process.stdin, (line) => {
|
|
|
83
106
|
let m; try { m = JSON.parse(line); } catch { child.stdin.write(line + "\n"); return; }
|
|
84
107
|
if (m && m.id != null && m.method === "tools/call") {
|
|
85
108
|
const name = (m.params && m.params.name) || "tool";
|
|
86
|
-
|
|
109
|
+
const args = (m.params && m.params.arguments) || {};
|
|
110
|
+
const { blocked, reason } = isBlocked();
|
|
111
|
+
if (blocked) {
|
|
112
|
+
// Refuse before the child ever sees this call — do NOT forward to
|
|
113
|
+
// child.stdin. The host gets a normal JSON-RPC error response (not a
|
|
114
|
+
// dropped connection), and the refusal itself is still reported so it is
|
|
115
|
+
// visible on Stratos rather than silently vanishing.
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
const errResp = {
|
|
118
|
+
jsonrpc: "2.0",
|
|
119
|
+
id: m.id,
|
|
120
|
+
error: { code: -32001, message: `Stratos: blocked — ${reason || "budget or lifecycle limit reached"}` },
|
|
121
|
+
};
|
|
122
|
+
process.stdout.write(JSON.stringify(errResp) + "\n");
|
|
123
|
+
reportTool(name, args, { blocked: true, reason: reason || null }, true, now, now);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
pendingCalls.set(m.id, { name, args, start: Date.now() });
|
|
87
127
|
}
|
|
88
128
|
child.stdin.write(line + "\n");
|
|
89
129
|
});
|
package/src/register.js
CHANGED
|
@@ -4,14 +4,19 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Order matters:
|
|
6
6
|
// 1) Load .env from cwd (so --import sees STRATOS_TOKEN before start)
|
|
7
|
-
// 2)
|
|
8
|
-
//
|
|
7
|
+
// 2) Install the network guard (see enforcement.js) — before ANY provider SDK
|
|
8
|
+
// (openai, @anthropic-ai/sdk, langchain, ai, …) has a chance to import and
|
|
9
|
+
// capture a reference to the unpatched global fetch/http/https.
|
|
10
|
+
// 3) Register Vercel AI ESM loader (intercepts `import … from "ai"`)
|
|
11
|
+
// 4) start() → Traceloop + OTLP export to Stratos, and the heartbeat that
|
|
12
|
+
// keeps the network guard's blocked/unblocked state up to date.
|
|
9
13
|
|
|
10
14
|
import { register as registerModuleHook } from "node:module";
|
|
11
15
|
import { pathToFileURL } from "node:url";
|
|
12
16
|
import { dirname, join } from "node:path";
|
|
13
17
|
import { fileURLToPath } from "node:url";
|
|
14
18
|
import { existsSync } from "node:fs";
|
|
19
|
+
import { installNetworkGuard } from "./enforcement.js";
|
|
15
20
|
|
|
16
21
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
22
|
|
|
@@ -25,6 +30,17 @@ try {
|
|
|
25
30
|
/* optional */
|
|
26
31
|
}
|
|
27
32
|
|
|
33
|
+
// Must run before any consumer imports of a provider SDK — it patches the
|
|
34
|
+
// shared, global fetch/http/https that every one of them ultimately calls
|
|
35
|
+
// through, so a single install here covers openai/anthropic/langchain/ai/…
|
|
36
|
+
// without a per-library wrapper. See enforcement.js for why this is the
|
|
37
|
+
// network layer and not the SDK layer.
|
|
38
|
+
try {
|
|
39
|
+
installNetworkGuard();
|
|
40
|
+
} catch (err) {
|
|
41
|
+
console.warn("[stratos] could not install network guard:", err?.message || err);
|
|
42
|
+
}
|
|
43
|
+
|
|
28
44
|
// Must run before any consumer imports of `ai`
|
|
29
45
|
try {
|
|
30
46
|
registerModuleHook(
|
package/src/vercel-ai-loader.js
CHANGED
|
@@ -14,6 +14,13 @@ import { TELEMETRY_CALL_NAMES } from "./vercel-ai-inject.js";
|
|
|
14
14
|
const SHIM_URL = "stratos-sdk:ai";
|
|
15
15
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
16
|
const INJECT_URL = pathToFileURL(join(__dirname, "vercel-ai-inject.js")).href;
|
|
17
|
+
// The network guard (enforcement.js, installed from register.js) already
|
|
18
|
+
// refuses these calls at the fetch layer once blocked — Vercel AI's own client
|
|
19
|
+
// uses fetch internally, same as everyone else. This second check is
|
|
20
|
+
// redundant with that but deliberately kept: it fails before Vercel AI builds
|
|
21
|
+
// its request/retry/streaming machinery, and the error reads as ours rather
|
|
22
|
+
// than as a generic fetch failure surfaced through their error wrapper.
|
|
23
|
+
const ENFORCEMENT_URL = pathToFileURL(join(__dirname, "enforcement.js")).href;
|
|
17
24
|
|
|
18
25
|
function flagEnabled(name, defaultOn = true) {
|
|
19
26
|
const v = process.env[name];
|
|
@@ -67,10 +74,15 @@ export async function load(url, context, nextLoad) {
|
|
|
67
74
|
let source = "";
|
|
68
75
|
source += `import * as __real from ${JSON.stringify(realUrl)};\n`;
|
|
69
76
|
source += `import { injectTelemetryOptions } from ${JSON.stringify(INJECT_URL)};\n`;
|
|
77
|
+
source += `import { isBlocked } from ${JSON.stringify(ENFORCEMENT_URL)};\n`;
|
|
70
78
|
source += `
|
|
71
79
|
function __stratosWrap(fn) {
|
|
72
80
|
if (typeof fn !== "function" || fn.__stratosWrapped) return fn;
|
|
73
81
|
const wrapped = function stratosVercelAiWrap(opts, ...rest) {
|
|
82
|
+
const __b = isBlocked();
|
|
83
|
+
if (__b.blocked) {
|
|
84
|
+
throw new Error("[stratos] request blocked: " + (__b.reason || "budget or lifecycle limit reached"));
|
|
85
|
+
}
|
|
74
86
|
return fn.call(this, injectTelemetryOptions(opts), ...rest);
|
|
75
87
|
};
|
|
76
88
|
wrapped.__stratosWrapped = true;
|