Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.env
.env.*
*.bak
data/
.git/
.github/
*.db
*.db-shm
*.db-wal
node_modules/
coverage/
.claude-private.md
Empty file modified .github/ISSUE_TEMPLATE/bug_report.md
100644 → 100755
Empty file.
Empty file modified .github/ISSUE_TEMPLATE/feature_request.md
100644 → 100755
Empty file.
Empty file modified .github/pull_request_template.md
100644 → 100755
Empty file.
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,11 @@ jobs:

- name: Test
run: pnpm test

- name: Audit dependencies
run: pnpm audit --audit-level=moderate || true

- name: Secret scanning
uses: trufflesecurity/trufflehog@main
with:
extra_args: --only-verified
9 changes: 9 additions & 0 deletions .gitignore
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,12 @@ data/
coverage/
.claude-private.md
MULTI_ACCOUNT_PLAN.md

# Security: prevent accidental secret/credential commits
*.bak
*.pem
*.key
*.cert
*.p12
credentials.json
service-account.json
Empty file modified CLAUDE.md
100644 → 100755
Empty file.
Empty file modified CODE_OF_CONDUCT.md
100644 → 100755
Empty file.
Empty file modified CONTRIBUTING.md
100644 → 100755
Empty file.
Empty file modified LICENSE
100644 → 100755
Empty file.
Empty file modified SECURITY.md
100644 → 100755
Empty file.
Empty file modified deploy/cloudflare/wrangler.toml
100644 → 100755
Empty file.
6 changes: 6 additions & 0 deletions deploy/docker/Dockerfile
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ RUN pnpm build

# Production
FROM base AS production
RUN groupadd -r agentcloak && useradd -r -g agentcloak -d /app agentcloak
COPY --from=deps /app/node_modules node_modules
COPY --from=deps /app/packages/server/node_modules packages/server/node_modules
COPY --from=build /app/packages/server/dist packages/server/dist
Expand All @@ -31,10 +32,15 @@ COPY --from=build /app/packages/web/dist packages/web/dist
COPY --from=build /app/packages/cli/dist packages/cli/dist
COPY --from=build /app/packages/cli/package.json packages/cli/
COPY --from=deps /app/packages/cli/node_modules packages/cli/node_modules
RUN chown -R agentcloak:agentcloak /app

ENV NODE_ENV=production
ENV DATABASE_PATH=/app/data/agentcloak.db

EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD node -e "fetch('http://localhost:3000/health').then(r=>r.ok?process.exit(0):process.exit(1)).catch(()=>process.exit(1))"

USER agentcloak
CMD ["node", "packages/server/dist/index.js"]
9 changes: 8 additions & 1 deletion deploy/docker/docker-compose.yml
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ services:
context: ../..
dockerfile: deploy/docker/Dockerfile
ports:
- "3000:3000"
- "127.0.0.1:3000:3000"
volumes:
- agentcloak-data:/app/data
env_file:
Expand All @@ -13,6 +13,13 @@ services:
- PORT=3000
- DATABASE_PATH=/app/data/agentcloak.db
restart: unless-stopped
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
read_only: true
tmpfs:
- /tmp

volumes:
agentcloak-data:
Empty file modified package.json
100644 → 100755
Empty file.
Empty file modified packages/cli/package.json
100644 → 100755
Empty file.
Empty file modified packages/cli/src/commands/accounts.ts
100644 → 100755
Empty file.
Empty file modified packages/cli/src/commands/connect.ts
100644 → 100755
Empty file.
Empty file modified packages/cli/src/commands/filters.ts
100644 → 100755
Empty file.
Empty file modified packages/cli/src/commands/keys.ts
100644 → 100755
Empty file.
Empty file modified packages/cli/src/commands/reset-password.ts
100644 → 100755
Empty file.
Empty file modified packages/cli/src/commands/setup.ts
100644 → 100755
Empty file.
Empty file modified packages/cli/src/commands/status.ts
100644 → 100755
Empty file.
Empty file modified packages/cli/src/index.ts
100644 → 100755
Empty file.
Empty file modified packages/cli/tsconfig.json
100644 → 100755
Empty file.
Empty file modified packages/mcp-stdio/package.json
100644 → 100755
Empty file.
Empty file modified packages/mcp-stdio/src/index.ts
100644 → 100755
Empty file.
Empty file modified packages/mcp-stdio/tsconfig.json
100644 → 100755
Empty file.
Empty file modified packages/server/package.json
100644 → 100755
Empty file.
54 changes: 54 additions & 0 deletions packages/server/src/auth/__tests__/key-derivation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, it, expect } from "vitest";
import { deriveKey, KEY_CONTEXTS } from "../key-derivation.js";

describe("Key derivation (HKDF)", () => {
it("produces different keys for different contexts", () => {
const secret = "test-master-secret-32-bytes-long!";
const key1 = deriveKey(secret, KEY_CONTEXTS.CREDENTIAL_ENCRYPTION);
const key2 = deriveKey(secret, KEY_CONTEXTS.SESSION);
const key3 = deriveKey(secret, KEY_CONTEXTS.OAUTH_STATE);

expect(key1.equals(key2)).toBe(false);
expect(key1.equals(key3)).toBe(false);
expect(key2.equals(key3)).toBe(false);
});

it("produces identical keys for same context and secret (deterministic)", () => {
const secret = "test-master-secret-32-bytes-long!";
const key1 = deriveKey(secret, KEY_CONTEXTS.CREDENTIAL_ENCRYPTION);
const key2 = deriveKey(secret, KEY_CONTEXTS.CREDENTIAL_ENCRYPTION);

expect(key1.equals(key2)).toBe(true);
});

it("defaults to 32-byte key length", () => {
const secret = "test-master-secret-32-bytes-long!";
const key = deriveKey(secret, KEY_CONTEXTS.SESSION);

expect(key.length).toBe(32);
});

it("supports custom key length", () => {
const secret = "test-master-secret-32-bytes-long!";
const key16 = deriveKey(secret, "custom-context", 16);
const key64 = deriveKey(secret, "custom-context", 64);

expect(key16.length).toBe(16);
expect(key64.length).toBe(64);
});

it("has all KEY_CONTEXTS defined and distinct", () => {
const values = Object.values(KEY_CONTEXTS);
expect(values.length).toBe(3);

// All values are unique
const unique = new Set(values);
expect(unique.size).toBe(values.length);

// All values are non-empty strings
for (const v of values) {
expect(typeof v).toBe("string");
expect(v.length).toBeGreaterThan(0);
}
});
});
11 changes: 11 additions & 0 deletions packages/server/src/auth/audit-log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Structured audit logging for security-relevant events.
* Outputs JSON lines to stdout for easy ingestion by log aggregators.
*/
export function auditLog(event: string, meta: Record<string, unknown> = {}): void {
console.log(JSON.stringify({
timestamp: new Date().toISOString(),
event,
...meta,
}));
}
23 changes: 23 additions & 0 deletions packages/server/src/auth/key-derivation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { hkdfSync } from "node:crypto";

/** Pre-defined HKDF contexts for purpose-specific key derivation */
export const KEY_CONTEXTS = {
CREDENTIAL_ENCRYPTION: "agentcloak-credential-encryption-v1",
OAUTH_STATE: "agentcloak-oauth-state-v1",
SESSION: "agentcloak-session-v1",
} as const;

/**
* Derive a purpose-specific key from a master secret using HKDF-SHA256.
* This ensures different subsystems use different keys even when sharing
* a single SESSION_SECRET.
*/
export function deriveKey(
masterSecret: string,
context: string,
length = 32,
): Buffer {
return Buffer.from(
hkdfSync("sha256", masterSecret, "", context, length),
);
}
Empty file modified packages/server/src/auth/keys.ts
100644 → 100755
Empty file.
Empty file modified packages/server/src/auth/password.ts
100644 → 100755
Empty file.
101 changes: 80 additions & 21 deletions packages/server/src/auth/rate-limit.ts
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -3,49 +3,108 @@ import type { Context, Next } from "hono";
interface RateLimiterOptions {
windowMs: number;
maxAttempts: number;
trustProxy?: boolean;
}

export function createRateLimiter({ windowMs, maxAttempts }: RateLimiterOptions) {
const attempts = new Map<string, number[]>();
interface DualKeyRateLimiterOptions extends RateLimiterOptions {
/** Max attempts per email address (should be lower than maxAttempts per IP) */
maxAttemptsPerEmail?: number;
/** Function to extract email from request body */
getEmail?: (c: Context) => Promise<string | undefined>;
}

function getClientIp(c: Context, trustProxy: boolean): string {
if (trustProxy) {
return (
c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
c.req.header("x-real-ip") ??
"unknown"
);
}
// Use socket remote address when not behind a trusted proxy
const connInfo = c.req.raw.headers.get("x-real-ip");
return connInfo ?? "unknown";
}

function checkLimit(
map: Map<string, number[]>,
key: string,
windowMs: number,
maxAttempts: number,
): { allowed: boolean; retryAfter?: number } {
const now = Date.now();
const timestamps = map.get(key) ?? [];
const recent = timestamps.filter((t) => now - t < windowMs);

if (recent.length >= maxAttempts) {
const retryAfter = Math.ceil((recent[0]! + windowMs - now) / 1000);
return { allowed: false, retryAfter };
}

recent.push(now);
map.set(key, recent);
return { allowed: true };
}

export function createRateLimiter({
windowMs,
maxAttempts,
trustProxy = false,
}: RateLimiterOptions) {
const ipAttempts = new Map<string, number[]>();

// Periodically clean up old entries to prevent memory leaks
const cleanup = setInterval(() => {
const now = Date.now();
for (const [key, timestamps] of attempts) {
for (const [key, timestamps] of ipAttempts) {
const valid = timestamps.filter((t) => now - t < windowMs);
if (valid.length === 0) {
attempts.delete(key);
ipAttempts.delete(key);
} else {
attempts.set(key, valid);
ipAttempts.set(key, valid);
}
}
}, windowMs);
cleanup.unref();

return async (c: Context, next: Next) => {
const ip =
c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
c.req.header("x-real-ip") ??
"unknown";
const ip = getClientIp(c, trustProxy);

const now = Date.now();
const timestamps = attempts.get(ip) ?? [];
const recent = timestamps.filter((t) => now - t < windowMs);

if (recent.length >= maxAttempts) {
const retryAfter = Math.ceil(
(recent[0]! + windowMs - now) / 1000,
);
c.header("Retry-After", String(retryAfter));
const result = checkLimit(ipAttempts, ip, windowMs, maxAttempts);
if (!result.allowed) {
c.header("Retry-After", String(result.retryAfter));
return c.json(
{ error: "Too many attempts. Please try again later." },
429,
);
}

recent.push(now);
attempts.set(ip, recent);

await next();
};
}

/**
* Rate limiter for MCP endpoint — keyed by API key ID.
* Returns null if allowed, or an error message if rate limited.
*/
export function createMcpRateLimiter(maxRequests: number, windowMs: number) {
const keyAttempts = new Map<string, number[]>();

const cleanup = setInterval(() => {
const now = Date.now();
for (const [key, timestamps] of keyAttempts) {
const valid = timestamps.filter((t) => now - t < windowMs);
if (valid.length === 0) {
keyAttempts.delete(key);
} else {
keyAttempts.set(key, valid);
}
}
}, windowMs);
cleanup.unref();

return (keyId: string): boolean => {
const result = checkLimit(keyAttempts, keyId, windowMs, maxRequests);
return result.allowed;
};
}
Empty file modified packages/server/src/auth/session.ts
100644 → 100755
Empty file.
9 changes: 9 additions & 0 deletions packages/server/src/config.ts
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ const configSchema = z.object({
baseUrl: z.string().url(),
sessionSecret: z.string().min(32),
sessionMaxAge: z.coerce.number().default(7 * 24 * 60 * 60 * 1000), // 7 days
/** Optional dedicated key for credential encryption (falls back to derived key from sessionSecret) */
credentialEncryptionKey: z.string().min(32).optional(),
/** Optional dedicated key for OAuth state HMAC (falls back to derived key from sessionSecret) */
oauthStateSecret: z.string().min(32).optional(),
/** Whether to trust X-Forwarded-For for rate limiting (only enable behind a trusted proxy) */
trustProxy: z.coerce.boolean().default(false),
});

export type Config = z.infer<typeof configSchema>;
Expand All @@ -25,6 +31,9 @@ export function loadConfig(): Config {
baseUrl: process.env.BASE_URL,
sessionSecret: process.env.SESSION_SECRET,
sessionMaxAge: process.env.SESSION_MAX_AGE,
credentialEncryptionKey: process.env.CREDENTIAL_ENCRYPTION_KEY,
oauthStateSecret: process.env.OAUTH_STATE_SECRET,
trustProxy: process.env.TRUST_PROXY,
});
}

Expand Down
Empty file modified packages/server/src/filters/__tests__/blocklist.test.ts
100644 → 100755
Empty file.
Loading
Loading