@vellumai/vellum-gateway 0.8.7 → 0.8.8
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/Dockerfile +10 -0
- package/package.json +1 -1
- package/src/__tests__/actor-token-revocation.test.ts +267 -0
- package/src/__tests__/auth-fallback-count-tracker.test.ts +92 -0
- package/src/__tests__/auth-fallback-log-throttle.test.ts +36 -0
- package/src/__tests__/auth-fallback-reporter.test.ts +101 -0
- package/src/__tests__/bash-risk-classifier.test.ts +56 -0
- package/src/__tests__/config.test.ts +20 -0
- package/src/__tests__/devices.test.ts +265 -0
- package/src/__tests__/edge-auth.test.ts +161 -1
- package/src/__tests__/edge-guardian-auth.test.ts +100 -1
- package/src/__tests__/feature-flag-resolver.test.ts +62 -1
- package/src/__tests__/feature-flags-route.test.ts +107 -12
- package/src/__tests__/guardian-init-lockfile.test.ts +103 -8
- package/src/__tests__/ipc-feature-flag-routes.test.ts +30 -0
- package/src/__tests__/live-voice-websocket.test.ts +212 -1
- package/src/__tests__/nonbash-trust-rule-overrides.test.ts +222 -0
- package/src/__tests__/pair-device-bound.test.ts +255 -0
- package/src/__tests__/pair-origin-allowlist.test.ts +51 -24
- package/src/__tests__/refresh-device-binding.test.ts +136 -0
- package/src/__tests__/remote-feature-flag-sync.test.ts +28 -2
- package/src/__tests__/route-schema-guard.test.ts +3 -0
- package/src/__tests__/trust-rule-cache.test.ts +177 -0
- package/src/auth/actor-token-revocation.ts +105 -0
- package/src/auth/guardian-bootstrap.ts +65 -27
- package/src/auth/guardian-refresh.ts +18 -4
- package/src/auth-fallback-count-tracker.ts +113 -0
- package/src/auth-fallback-log-throttle.ts +54 -0
- package/src/auth-fallback-reporter.ts +135 -0
- package/src/config.ts +16 -2
- package/src/db/data-migrations/index.ts +2 -0
- package/src/db/data-migrations/m0004-actor-token-hash-index-unfiltered.ts +43 -0
- package/src/db/schema.ts +3 -3
- package/src/feature-flag-defaults.ts +2 -2
- package/src/feature-flag-registry.json +35 -3
- package/src/feature-flag-remote-store.ts +8 -8
- package/src/feature-flag-resolver.ts +13 -2
- package/src/feature-flag-store.ts +8 -8
- package/src/http/edge-forwarded-header.ts +39 -0
- package/src/http/loopback-guard.ts +140 -0
- package/src/http/middleware/auth.ts +257 -45
- package/src/http/routes/auth-token.ts +19 -3
- package/src/http/routes/channel-verification-session-proxy.ts +66 -14
- package/src/http/routes/devices.ts +132 -0
- package/src/http/routes/feature-flags.ts +4 -5
- package/src/http/routes/ipc-runtime-proxy.ts +8 -0
- package/src/http/routes/live-voice-websocket.ts +78 -0
- package/src/http/routes/pair.ts +130 -68
- package/src/http/routes/runtime-proxy.ts +8 -0
- package/src/http/routes/stt-stream-websocket.ts +6 -0
- package/src/index.ts +105 -1
- package/src/ipc/feature-flag-handlers.ts +4 -4
- package/src/log-redact.ts +1 -0
- package/src/remote-feature-flag-sync.ts +11 -11
- package/src/risk/bash-risk-classifier.ts +32 -5
- package/src/risk/skill-risk-classifier.ts +29 -15
- package/src/risk/trust-rule-cache.ts +46 -10
- package/src/schema.ts +29 -7
- package/src/velay/bridge-auth.ts +32 -0
- package/src/velay/bridge-utils.ts +22 -3
- package/src/velay/websocket-bridge.test.ts +25 -0
- package/src/velay/websocket-bridge.ts +4 -1
package/Dockerfile
CHANGED
|
@@ -34,8 +34,11 @@ WORKDIR /app
|
|
|
34
34
|
|
|
35
35
|
RUN apt-get update && apt-get upgrade -y && apt-get install -y \
|
|
36
36
|
ca-certificates \
|
|
37
|
+
e2fsprogs \
|
|
37
38
|
iproute2 \
|
|
39
|
+
mount \
|
|
38
40
|
procps \
|
|
41
|
+
util-linux \
|
|
39
42
|
&& rm -rf /var/lib/apt/lists/*
|
|
40
43
|
|
|
41
44
|
# Copy bun binary from builder
|
|
@@ -52,6 +55,13 @@ COPY --from=builder --chown=gateway:gateway /app/packages /app/packages
|
|
|
52
55
|
|
|
53
56
|
RUN mkdir -p /gateway-security && chown gateway:gateway /gateway-security
|
|
54
57
|
|
|
58
|
+
COPY packages/block-volume-bootstrap/scripts/*.sh /usr/local/bin/
|
|
59
|
+
RUN chmod +x \
|
|
60
|
+
/usr/local/bin/vellum-block-volume-common.sh \
|
|
61
|
+
/usr/local/bin/vellum-block-volume-init.sh \
|
|
62
|
+
/usr/local/bin/vellum-block-volume-mount.sh \
|
|
63
|
+
/usr/local/bin/vellum-block-volume-resize.sh
|
|
64
|
+
|
|
55
65
|
USER gateway
|
|
56
66
|
|
|
57
67
|
EXPOSE 7830
|
package/package.json
CHANGED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the hot-path actor-token revocation check: a revoked actor token
|
|
3
|
+
* is rejected on live requests, with fail-open semantics for non-actor,
|
|
4
|
+
* unrecorded, and DB-error cases.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
7
|
+
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
import { initSigningKey, mintToken } from "../auth/token-service.js";
|
|
12
|
+
import { CURRENT_POLICY_EPOCH } from "../auth/policy.js";
|
|
13
|
+
import type { TokenClaims } from "../auth/types.js";
|
|
14
|
+
|
|
15
|
+
initSigningKey(Buffer.from("test-signing-key-at-least-32-bytes-long-xx"));
|
|
16
|
+
|
|
17
|
+
const { initGatewayDb, resetGatewayDb, getGatewayDb } =
|
|
18
|
+
await import("../db/connection.js");
|
|
19
|
+
const { actorTokenRecords } = await import("../db/schema.js");
|
|
20
|
+
const { hashToken } = await import("../auth/guardian-bootstrap.js");
|
|
21
|
+
const { isActorTokenRevoked } =
|
|
22
|
+
await import("../auth/actor-token-revocation.js");
|
|
23
|
+
const { createRuntimeProxyHandler } =
|
|
24
|
+
await import("../http/routes/runtime-proxy.js");
|
|
25
|
+
|
|
26
|
+
const ACTOR_SUB = "actor:self:guardian-001";
|
|
27
|
+
const actorClaims = { sub: ACTOR_SUB } as TokenClaims;
|
|
28
|
+
|
|
29
|
+
let testRoot: string;
|
|
30
|
+
|
|
31
|
+
function insertTokenRecord(rawToken: string, status: "active" | "revoked") {
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
getGatewayDb()
|
|
34
|
+
.insert(actorTokenRecords)
|
|
35
|
+
.values({
|
|
36
|
+
id: `id-${rawToken}`,
|
|
37
|
+
tokenHash: hashToken(rawToken),
|
|
38
|
+
guardianPrincipalId: "guardian-001",
|
|
39
|
+
hashedDeviceId: hashToken("device-A"),
|
|
40
|
+
platform: "web",
|
|
41
|
+
status,
|
|
42
|
+
issuedAt: now,
|
|
43
|
+
expiresAt: now + 86_400_000,
|
|
44
|
+
createdAt: now,
|
|
45
|
+
updatedAt: now,
|
|
46
|
+
})
|
|
47
|
+
.run();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
beforeEach(async () => {
|
|
51
|
+
testRoot = mkdtempSync(join(tmpdir(), "revocation-test-"));
|
|
52
|
+
const securityDir = join(testRoot, "protected");
|
|
53
|
+
mkdirSync(securityDir, { recursive: true });
|
|
54
|
+
process.env.GATEWAY_SECURITY_DIR = securityDir;
|
|
55
|
+
await initGatewayDb();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
resetGatewayDb();
|
|
60
|
+
delete process.env.GATEWAY_SECURITY_DIR;
|
|
61
|
+
try {
|
|
62
|
+
rmSync(testRoot, { recursive: true, force: true });
|
|
63
|
+
} catch {
|
|
64
|
+
/* best effort */
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("isActorTokenRevoked", () => {
|
|
69
|
+
test("returns true for an actor token whose record is revoked", () => {
|
|
70
|
+
insertTokenRecord("token-revoked", "revoked");
|
|
71
|
+
expect(isActorTokenRevoked("token-revoked", actorClaims)).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("returns false for an actor token whose record is active", () => {
|
|
75
|
+
insertTokenRecord("token-active", "active");
|
|
76
|
+
expect(isActorTokenRevoked("token-active", actorClaims)).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("returns false (fail-open) for an actor token with no record", () => {
|
|
80
|
+
expect(isActorTokenRevoked("token-unknown", actorClaims)).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("never checks non-actor tokens (svc)", () => {
|
|
84
|
+
// Even if a row with this hash were revoked, a svc sub must be ignored.
|
|
85
|
+
insertTokenRecord("svc-token", "revoked");
|
|
86
|
+
const svcClaims = { sub: "svc:gateway:self" } as TokenClaims;
|
|
87
|
+
expect(isActorTokenRevoked("svc-token", svcClaims)).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("returns false (fail-open) when the gateway DB is unavailable", () => {
|
|
91
|
+
resetGatewayDb();
|
|
92
|
+
expect(isActorTokenRevoked("token-anything", actorClaims)).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("still detects revocation when the token has surrounding whitespace", () => {
|
|
96
|
+
// The record is stored under the canonical (trimmed) token hash; a token
|
|
97
|
+
// supplied with trailing whitespace (e.g. a `?token=<jwt>%20` WS param)
|
|
98
|
+
// must still resolve to the revoked record.
|
|
99
|
+
insertTokenRecord("token-revoked", "revoked");
|
|
100
|
+
expect(isActorTokenRevoked("token-revoked ", actorClaims)).toBe(true);
|
|
101
|
+
expect(isActorTokenRevoked(" token-revoked\n", actorClaims)).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("signature-encoding canonicalization (revocation bypass)", () => {
|
|
106
|
+
function mintActorJwt(): string {
|
|
107
|
+
return mintToken({
|
|
108
|
+
aud: "vellum-gateway",
|
|
109
|
+
sub: ACTOR_SUB,
|
|
110
|
+
scope_profile: "actor_client_v1",
|
|
111
|
+
policy_epoch: CURRENT_POLICY_EPOCH,
|
|
112
|
+
ttlSeconds: 3600,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Append base64 padding to the signature segment. Buffer.from(.., "base64url")
|
|
117
|
+
// decodes it to the SAME bytes, so the JWT still verifies — but the raw string
|
|
118
|
+
// differs, which (pre-fix) made the revocation hash miss the stored record.
|
|
119
|
+
function padSignature(jwt: string): string {
|
|
120
|
+
const [h, p, sig] = jwt.split(".");
|
|
121
|
+
return `${h}.${p}.${sig}=`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
test("detects a revoked token whose signature segment is re-encoded with padding", () => {
|
|
125
|
+
const jwt = mintActorJwt();
|
|
126
|
+
insertTokenRecord(jwt, "revoked"); // stored under the canonical token hash
|
|
127
|
+
|
|
128
|
+
// Baseline: the canonical token is detected as revoked.
|
|
129
|
+
expect(isActorTokenRevoked(jwt, actorClaims)).toBe(true);
|
|
130
|
+
|
|
131
|
+
// Bypass attempt: same token, signature re-encoded (different string, same
|
|
132
|
+
// bytes). Must still resolve to the revoked record.
|
|
133
|
+
const padded = padSignature(jwt);
|
|
134
|
+
expect(padded).not.toBe(jwt);
|
|
135
|
+
expect(isActorTokenRevoked(padded, actorClaims)).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("does not falsely revoke an active token re-encoded with padding", () => {
|
|
139
|
+
const jwt = mintActorJwt();
|
|
140
|
+
insertTokenRecord(jwt, "active");
|
|
141
|
+
expect(isActorTokenRevoked(padSignature(jwt), actorClaims)).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("runtime proxy enforcement", () => {
|
|
146
|
+
function makeConfig() {
|
|
147
|
+
return {
|
|
148
|
+
assistantRuntimeBaseUrl: "http://localhost:7821",
|
|
149
|
+
routingEntries: [],
|
|
150
|
+
defaultAssistantId: undefined,
|
|
151
|
+
unmappedPolicy: "reject" as const,
|
|
152
|
+
port: 7830,
|
|
153
|
+
runtimeProxyRequireAuth: true,
|
|
154
|
+
shutdownDrainMs: 5000,
|
|
155
|
+
runtimeTimeoutMs: 30000,
|
|
156
|
+
runtimeMaxRetries: 2,
|
|
157
|
+
runtimeInitialBackoffMs: 500,
|
|
158
|
+
maxWebhookPayloadBytes: 1048576,
|
|
159
|
+
logFile: { dir: undefined, retentionDays: 30 },
|
|
160
|
+
maxAttachmentBytes: {
|
|
161
|
+
telegram: 50 * 1024 * 1024,
|
|
162
|
+
slack: 100 * 1024 * 1024,
|
|
163
|
+
whatsapp: 16 * 1024 * 1024,
|
|
164
|
+
default: 50 * 1024 * 1024,
|
|
165
|
+
},
|
|
166
|
+
maxAttachmentConcurrency: 3,
|
|
167
|
+
gatewayInternalBaseUrl: "http://127.0.0.1:7830",
|
|
168
|
+
trustProxy: false,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function mintActorJwt(): string {
|
|
173
|
+
return mintToken({
|
|
174
|
+
aud: "vellum-gateway",
|
|
175
|
+
sub: ACTOR_SUB,
|
|
176
|
+
scope_profile: "actor_client_v1",
|
|
177
|
+
policy_epoch: CURRENT_POLICY_EPOCH,
|
|
178
|
+
ttlSeconds: 3600,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
test("rejects a revoked actor token with 401 on the chat path", async () => {
|
|
183
|
+
const jwt = mintActorJwt();
|
|
184
|
+
insertTokenRecord(jwt, "revoked");
|
|
185
|
+
|
|
186
|
+
const handler = createRuntimeProxyHandler(makeConfig());
|
|
187
|
+
const res = await handler(
|
|
188
|
+
new Request("http://127.0.0.1:7830/v1/assistants/self/messages", {
|
|
189
|
+
method: "POST",
|
|
190
|
+
headers: {
|
|
191
|
+
authorization: `Bearer ${jwt}`,
|
|
192
|
+
"content-type": "application/json",
|
|
193
|
+
},
|
|
194
|
+
body: JSON.stringify({ content: "hi" }),
|
|
195
|
+
}),
|
|
196
|
+
"127.0.0.1",
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
expect(res.status).toBe(401);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("/auth/token revocation", () => {
|
|
204
|
+
function makeLoopbackServer() {
|
|
205
|
+
return {
|
|
206
|
+
requestIP: () => ({ address: "127.0.0.1", family: "IPv4", port: 5000 }),
|
|
207
|
+
} as unknown as import("bun").Server<unknown>;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
test("rejects re-minting a token from a revoked source token", async () => {
|
|
211
|
+
const { handleCreateToken } = await import("../http/routes/auth-token.js");
|
|
212
|
+
const jwt = mintToken({
|
|
213
|
+
aud: "vellum-gateway",
|
|
214
|
+
sub: ACTOR_SUB,
|
|
215
|
+
scope_profile: "actor_client_v1",
|
|
216
|
+
policy_epoch: CURRENT_POLICY_EPOCH,
|
|
217
|
+
ttlSeconds: 3600,
|
|
218
|
+
});
|
|
219
|
+
insertTokenRecord(jwt, "revoked");
|
|
220
|
+
|
|
221
|
+
const res = await handleCreateToken(
|
|
222
|
+
new Request("http://127.0.0.1:7830/auth/token", {
|
|
223
|
+
method: "POST",
|
|
224
|
+
headers: {
|
|
225
|
+
authorization: `Bearer ${jwt}`,
|
|
226
|
+
origin: "http://localhost:3000",
|
|
227
|
+
},
|
|
228
|
+
}),
|
|
229
|
+
makeLoopbackServer(),
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
expect(res.status).toBe(401);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe("m0004 token-hash index migration", () => {
|
|
237
|
+
function rawDb() {
|
|
238
|
+
return (
|
|
239
|
+
getGatewayDb() as unknown as { $client: import("bun:sqlite").Database }
|
|
240
|
+
).$client;
|
|
241
|
+
}
|
|
242
|
+
function indexSql(): string {
|
|
243
|
+
const row = rawDb()
|
|
244
|
+
.prepare(
|
|
245
|
+
"SELECT sql FROM sqlite_master WHERE type='index' AND name='idx_actor_tokens_hash'",
|
|
246
|
+
)
|
|
247
|
+
.get() as { sql: string } | null;
|
|
248
|
+
return row?.sql ?? "";
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
test("recreates a pre-existing partial index as unfiltered", async () => {
|
|
252
|
+
// Simulate an upgraded gateway: replace the index with the OLD partial form.
|
|
253
|
+
rawDb().exec("DROP INDEX IF EXISTS idx_actor_tokens_hash");
|
|
254
|
+
rawDb().exec(
|
|
255
|
+
"CREATE INDEX idx_actor_tokens_hash ON actor_token_records (token_hash) WHERE status = 'active'",
|
|
256
|
+
);
|
|
257
|
+
expect(indexSql().toLowerCase()).toContain("where");
|
|
258
|
+
|
|
259
|
+
const m0004 =
|
|
260
|
+
await import("../db/data-migrations/m0004-actor-token-hash-index-unfiltered.js");
|
|
261
|
+
expect(m0004.up()).toBe("done");
|
|
262
|
+
|
|
263
|
+
// The index now exists and no longer filters on status.
|
|
264
|
+
expect(indexSql()).not.toBe("");
|
|
265
|
+
expect(indexSql().toLowerCase()).not.toContain("where");
|
|
266
|
+
});
|
|
267
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { AuthFallbackCountTracker } from "../auth-fallback-count-tracker.js";
|
|
4
|
+
|
|
5
|
+
describe("AuthFallbackCountTracker", () => {
|
|
6
|
+
test("increments per (guard, path, failureKind) key", () => {
|
|
7
|
+
const t = new AuthFallbackCountTracker(0);
|
|
8
|
+
t.increment("edge", "/v1/chat", "missing_authorization");
|
|
9
|
+
t.increment("edge", "/v1/chat", "missing_authorization");
|
|
10
|
+
t.increment("edge", "/v1/chat", "token_validation_failed");
|
|
11
|
+
t.increment("edge-guardian", "/v1/chat", "missing_authorization");
|
|
12
|
+
|
|
13
|
+
const snap = t.snapshot();
|
|
14
|
+
expect(snap.length).toBe(3);
|
|
15
|
+
const find = (g: string, p: string, f: string) =>
|
|
16
|
+
snap.find((c) => c.guard === g && c.path === p && c.failureKind === f)
|
|
17
|
+
?.count;
|
|
18
|
+
expect(find("edge", "/v1/chat", "missing_authorization")).toBe(2);
|
|
19
|
+
expect(find("edge", "/v1/chat", "token_validation_failed")).toBe(1);
|
|
20
|
+
expect(find("edge-guardian", "/v1/chat", "missing_authorization")).toBe(1);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("drain returns the window + counts and resets", () => {
|
|
24
|
+
const t = new AuthFallbackCountTracker(1000);
|
|
25
|
+
t.increment("edge", "/v1/a", "missing_authorization");
|
|
26
|
+
t.increment("edge", "/v1/a", "missing_authorization");
|
|
27
|
+
|
|
28
|
+
const batch = t.drain(2000);
|
|
29
|
+
expect(batch.windowStart).toBe(1000);
|
|
30
|
+
expect(batch.windowEnd).toBe(2000);
|
|
31
|
+
expect(batch.counts).toEqual([
|
|
32
|
+
{
|
|
33
|
+
guard: "edge",
|
|
34
|
+
path: "/v1/a",
|
|
35
|
+
failureKind: "missing_authorization",
|
|
36
|
+
count: 2,
|
|
37
|
+
},
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
// Drained — tracker is empty and the window is re-anchored to the drain.
|
|
41
|
+
expect(t.snapshot()).toEqual([]);
|
|
42
|
+
const empty = t.drain(3000);
|
|
43
|
+
expect(empty.counts).toEqual([]);
|
|
44
|
+
expect(empty.windowStart).toBe(2000);
|
|
45
|
+
expect(empty.windowEnd).toBe(3000);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("an empty drain leaves the window start anchored", () => {
|
|
49
|
+
const t = new AuthFallbackCountTracker(1000);
|
|
50
|
+
// Nothing recorded yet — draining must not shift windowStart forward.
|
|
51
|
+
expect(t.drain(2000)).toMatchObject({ windowStart: 1000, windowEnd: 2000 });
|
|
52
|
+
t.increment("edge", "/v1/a", "missing_authorization");
|
|
53
|
+
expect(t.drain(3000).windowStart).toBe(1000);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("merge folds a drained batch back in", () => {
|
|
57
|
+
const t = new AuthFallbackCountTracker(0);
|
|
58
|
+
t.increment("edge", "/v1/a", "missing_authorization");
|
|
59
|
+
const batch = t.drain(100);
|
|
60
|
+
|
|
61
|
+
// Simulate a failed flush: counts come back, plus a newer count for the
|
|
62
|
+
// same key that accumulated after the drain.
|
|
63
|
+
t.increment("edge", "/v1/a", "missing_authorization");
|
|
64
|
+
t.merge(batch.counts);
|
|
65
|
+
|
|
66
|
+
const snap = t.snapshot();
|
|
67
|
+
expect(snap.length).toBe(1);
|
|
68
|
+
expect(snap[0].count).toBe(2);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("caps distinct keys but keeps counting existing ones", () => {
|
|
72
|
+
const t = new AuthFallbackCountTracker(0);
|
|
73
|
+
// MAX_TRACKED_KEYS is 10_000; exceed it with distinct paths.
|
|
74
|
+
for (let i = 0; i < 10_050; i++) {
|
|
75
|
+
t.increment("edge", `/v1/p${i}`, "missing_authorization");
|
|
76
|
+
}
|
|
77
|
+
const snap = t.snapshot();
|
|
78
|
+
expect(snap.length).toBe(10_000);
|
|
79
|
+
|
|
80
|
+
// An already-tracked key still increments past the cap.
|
|
81
|
+
t.increment("edge", "/v1/p0", "missing_authorization");
|
|
82
|
+
expect(t.snapshot().find((c) => c.path === "/v1/p0")?.count).toBe(2);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("reset clears counts", () => {
|
|
86
|
+
const t = new AuthFallbackCountTracker(0);
|
|
87
|
+
t.increment("edge", "/v1/a", "missing_authorization");
|
|
88
|
+
t.reset(500);
|
|
89
|
+
expect(t.snapshot()).toEqual([]);
|
|
90
|
+
expect(t.drain(600).windowStart).toBe(500);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { AuthFallbackLogThrottle } from "../auth-fallback-log-throttle.js";
|
|
4
|
+
|
|
5
|
+
describe("AuthFallbackLogThrottle", () => {
|
|
6
|
+
test("logs the first time a key is seen", () => {
|
|
7
|
+
const throttle = new AuthFallbackLogThrottle();
|
|
8
|
+
expect(throttle.shouldLog("edge /v1/chat", 0)).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("suppresses repeats within the cooldown window", () => {
|
|
12
|
+
const throttle = new AuthFallbackLogThrottle(1000);
|
|
13
|
+
expect(throttle.shouldLog("edge /v1/chat", 0)).toBe(true);
|
|
14
|
+
expect(throttle.shouldLog("edge /v1/chat", 500)).toBe(false);
|
|
15
|
+
expect(throttle.shouldLog("edge /v1/chat", 999)).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("logs again once the cooldown elapses", () => {
|
|
19
|
+
const throttle = new AuthFallbackLogThrottle(1000);
|
|
20
|
+
expect(throttle.shouldLog("edge /v1/chat", 0)).toBe(true);
|
|
21
|
+
// now - last === cooldownMs is not < cooldownMs, so it logs again.
|
|
22
|
+
expect(throttle.shouldLog("edge /v1/chat", 1000)).toBe(true);
|
|
23
|
+
expect(throttle.shouldLog("edge /v1/chat", 1500)).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("tracks distinct endpoints independently", () => {
|
|
27
|
+
const throttle = new AuthFallbackLogThrottle(1000);
|
|
28
|
+
expect(throttle.shouldLog("edge /v1/chat", 0)).toBe(true);
|
|
29
|
+
expect(throttle.shouldLog("edge-guardian /v1/guardian/sync", 0)).toBe(true);
|
|
30
|
+
// Each key has its own window — neither suppresses the other.
|
|
31
|
+
expect(throttle.shouldLog("edge /v1/chat", 100)).toBe(false);
|
|
32
|
+
expect(throttle.shouldLog("edge-guardian /v1/guardian/sync", 100)).toBe(
|
|
33
|
+
false,
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { AuthFallbackCountTracker } from "../auth-fallback-count-tracker.js";
|
|
4
|
+
import { AuthFallbackReporter } from "../auth-fallback-reporter.js";
|
|
5
|
+
|
|
6
|
+
const BASE_URL = "http://127.0.0.1:7821";
|
|
7
|
+
|
|
8
|
+
function makeReporter(
|
|
9
|
+
tracker: AuthFallbackCountTracker,
|
|
10
|
+
fetchImpl: typeof import("../fetch.js").fetchImpl,
|
|
11
|
+
) {
|
|
12
|
+
return new AuthFallbackReporter({
|
|
13
|
+
tracker,
|
|
14
|
+
baseUrl: BASE_URL,
|
|
15
|
+
intervalMs: 60_000,
|
|
16
|
+
fetchImpl,
|
|
17
|
+
mintToken: () => "test-service-token",
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("AuthFallbackReporter", () => {
|
|
22
|
+
test("does nothing when there is nothing to flush", async () => {
|
|
23
|
+
const tracker = new AuthFallbackCountTracker(0);
|
|
24
|
+
const fetchMock = mock(async () => new Response("{}", { status: 200 }));
|
|
25
|
+
await makeReporter(tracker, fetchMock as never).flush();
|
|
26
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("drains and POSTs the counts to the daemon route with a service token", async () => {
|
|
30
|
+
const tracker = new AuthFallbackCountTracker(0);
|
|
31
|
+
tracker.increment("edge", "/v1/chat", "missing_authorization");
|
|
32
|
+
tracker.increment("edge", "/v1/chat", "missing_authorization");
|
|
33
|
+
tracker.increment("edge-guardian", "/v1/sync", "guardian_mismatch");
|
|
34
|
+
|
|
35
|
+
const fetchMock = mock(async () => new Response("{}", { status: 200 }));
|
|
36
|
+
await makeReporter(tracker, fetchMock as never).flush();
|
|
37
|
+
|
|
38
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
39
|
+
const [url, init] = fetchMock.mock.calls[0] as unknown as [
|
|
40
|
+
string,
|
|
41
|
+
RequestInit,
|
|
42
|
+
];
|
|
43
|
+
expect(url).toBe(`${BASE_URL}/v1/internal/telemetry/auth-fallback`);
|
|
44
|
+
expect(init.method).toBe("POST");
|
|
45
|
+
const headers = init.headers as Record<string, string>;
|
|
46
|
+
expect(headers["Content-Type"]).toBe("application/json");
|
|
47
|
+
expect(headers["Authorization"]).toBe("Bearer test-service-token");
|
|
48
|
+
|
|
49
|
+
const body = JSON.parse(init.body as string);
|
|
50
|
+
expect(typeof body.window_start).toBe("number");
|
|
51
|
+
expect(typeof body.window_end).toBe("number");
|
|
52
|
+
expect(body.counts).toEqual(
|
|
53
|
+
expect.arrayContaining([
|
|
54
|
+
{
|
|
55
|
+
guard: "edge",
|
|
56
|
+
path: "/v1/chat",
|
|
57
|
+
failure_kind: "missing_authorization",
|
|
58
|
+
count: 2,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
guard: "edge-guardian",
|
|
62
|
+
path: "/v1/sync",
|
|
63
|
+
failure_kind: "guardian_mismatch",
|
|
64
|
+
count: 1,
|
|
65
|
+
},
|
|
66
|
+
]),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// Successful flush drains the tracker.
|
|
70
|
+
expect(tracker.snapshot()).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("re-queues counts when the daemon returns a non-OK status", async () => {
|
|
74
|
+
const tracker = new AuthFallbackCountTracker(0);
|
|
75
|
+
tracker.increment("edge", "/v1/chat", "missing_authorization");
|
|
76
|
+
|
|
77
|
+
const fetchMock = mock(async () => new Response("nope", { status: 404 }));
|
|
78
|
+
await makeReporter(tracker, fetchMock as never).flush();
|
|
79
|
+
|
|
80
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
81
|
+
// Counts merged back so the next flush retries them.
|
|
82
|
+
const snap = tracker.snapshot();
|
|
83
|
+
expect(snap.length).toBe(1);
|
|
84
|
+
expect(snap[0].count).toBe(1);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("re-queues counts when the POST throws", async () => {
|
|
88
|
+
const tracker = new AuthFallbackCountTracker(0);
|
|
89
|
+
tracker.increment("edge", "/v1/chat", "missing_authorization");
|
|
90
|
+
tracker.increment("edge", "/v1/chat", "missing_authorization");
|
|
91
|
+
|
|
92
|
+
const fetchMock = mock(async () => {
|
|
93
|
+
throw new Error("connection refused");
|
|
94
|
+
});
|
|
95
|
+
await makeReporter(tracker, fetchMock as never).flush();
|
|
96
|
+
|
|
97
|
+
const snap = tracker.snapshot();
|
|
98
|
+
expect(snap.length).toBe(1);
|
|
99
|
+
expect(snap[0].count).toBe(2);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -218,4 +218,60 @@ describe("risk rule cache integration", () => {
|
|
|
218
218
|
expect(result.risk).toBe("high");
|
|
219
219
|
expect(result.matchType).toBe("registry");
|
|
220
220
|
});
|
|
221
|
+
|
|
222
|
+
test("generalized action: rule with positional tokens applies (beyond registry subcommands)", () => {
|
|
223
|
+
// `ls` has no registry subcommands, but the rule editor offers positional
|
|
224
|
+
// action patterns such as `action:ls vellumtestfile` (for `ls vellumtestfile *`).
|
|
225
|
+
// The matcher must probe positional-derived action keys, not just the
|
|
226
|
+
// registry subcommand pattern (which would only see `ls`), or the saved
|
|
227
|
+
// rule is silently ignored.
|
|
228
|
+
store.create({
|
|
229
|
+
tool: "bash",
|
|
230
|
+
pattern: "action:ls vellumtestfile",
|
|
231
|
+
risk: "high",
|
|
232
|
+
description: "Generalized ls rule",
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
initTrustRuleCache(store);
|
|
236
|
+
|
|
237
|
+
const result = classifySegment(
|
|
238
|
+
segment("ls vellumtestfile extra"),
|
|
239
|
+
[],
|
|
240
|
+
DEFAULT_COMMAND_REGISTRY,
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
// The more specific positional action rule wins over the seeded `ls`
|
|
244
|
+
// program-level default.
|
|
245
|
+
expect(result.risk).toBe("high");
|
|
246
|
+
expect(result.matchType).toBe("user_rule");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("generalized action: rule with a path positional applies", () => {
|
|
250
|
+
// Regression guard for the suspected save/match divergence on path-like
|
|
251
|
+
// positionals. `generateScopeOptions` drops positionals from the RIGHT and
|
|
252
|
+
// keeps order, so for `cat /etc/passwd notes.txt` the saveable ladder
|
|
253
|
+
// includes `action:cat /etc/passwd` (a leading-prefix action key that
|
|
254
|
+
// retains the path). The matcher builds the positional pattern
|
|
255
|
+
// `cat /etc/passwd notes.txt` and `findBaseRisk` walks leading prefixes, so
|
|
256
|
+
// `action:cat /etc/passwd` must resolve. Paths are NOT stripped on either
|
|
257
|
+
// side (that exclusion lives only in the dead `deriveShellActionKeys`
|
|
258
|
+
// path), so this matches.
|
|
259
|
+
store.create({
|
|
260
|
+
tool: "bash",
|
|
261
|
+
pattern: "action:cat /etc/passwd",
|
|
262
|
+
risk: "high",
|
|
263
|
+
description: "Generalized cat rule with a path token",
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
initTrustRuleCache(store);
|
|
267
|
+
|
|
268
|
+
const result = classifySegment(
|
|
269
|
+
segment("cat /etc/passwd notes.txt"),
|
|
270
|
+
[],
|
|
271
|
+
DEFAULT_COMMAND_REGISTRY,
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
expect(result.risk).toBe("high");
|
|
275
|
+
expect(result.matchType).toBe("user_rule");
|
|
276
|
+
});
|
|
221
277
|
});
|
|
@@ -54,6 +54,26 @@ describe("config: hardcoded defaults", () => {
|
|
|
54
54
|
}
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
+
test("trustProxy is opt-in via GATEWAY_TRUST_PROXY", () => {
|
|
58
|
+
const saved = process.env.GATEWAY_TRUST_PROXY;
|
|
59
|
+
try {
|
|
60
|
+
process.env.GATEWAY_TRUST_PROXY = "true";
|
|
61
|
+
expect(loadConfig().trustProxy).toBe(true);
|
|
62
|
+
|
|
63
|
+
process.env.GATEWAY_TRUST_PROXY = "1";
|
|
64
|
+
expect(loadConfig().trustProxy).toBe(true);
|
|
65
|
+
|
|
66
|
+
process.env.GATEWAY_TRUST_PROXY = "false";
|
|
67
|
+
expect(loadConfig().trustProxy).toBe(false);
|
|
68
|
+
|
|
69
|
+
process.env.GATEWAY_TRUST_PROXY = "anything-else";
|
|
70
|
+
expect(loadConfig().trustProxy).toBe(false);
|
|
71
|
+
} finally {
|
|
72
|
+
if (saved !== undefined) process.env.GATEWAY_TRUST_PROXY = saved;
|
|
73
|
+
else delete process.env.GATEWAY_TRUST_PROXY;
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
57
77
|
test("assistantRuntimeBaseUrl derives from RUNTIME_HTTP_PORT", () => {
|
|
58
78
|
const saved = process.env.RUNTIME_HTTP_PORT;
|
|
59
79
|
process.env.RUNTIME_HTTP_PORT = "9999";
|