Skip to content
534 changes: 531 additions & 3 deletions js/bin/agentseal.ts

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions js/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion js/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agentseal",
"version": "0.6.1",
"version": "0.8.1",
"description": "Security validator for AI agents — 225+ attack probes to test prompt injection and extraction defenses",
"type": "module",
"main": "./dist/index.cjs",
Expand Down
44 changes: 44 additions & 0 deletions js/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir } from "node:os";

export const CONFIG_DIR = join(homedir(), ".agentseal");
export const DEFAULT_CONFIG_PATH = join(CONFIG_DIR, "config.json");

export const CONFIG_KEYS = [
"model", "api-key", "ollama-url", "litellm-url", "dashboard-url", "dashboard-key",
] as const;

export type ConfigKey = (typeof CONFIG_KEYS)[number];

export function loadConfig(path: string = DEFAULT_CONFIG_PATH): Record<string, string> {
if (!existsSync(path)) return {};
return JSON.parse(readFileSync(path, "utf-8"));
}

export function saveConfigKey(key: string, value: string, path: string = DEFAULT_CONFIG_PATH): void {
const dir = dirname(path);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
const cfg = loadConfig(path);
cfg[key] = value;
writeFileSync(path, JSON.stringify(cfg, null, 2), { mode: 0o600 });
chmodSync(path, 0o600);
}

export function removeConfigKey(key: string, path: string = DEFAULT_CONFIG_PATH): void {
const cfg = loadConfig(path);
delete cfg[key];
writeFileSync(path, JSON.stringify(cfg, null, 2), { mode: 0o600 });
chmodSync(path, 0o600);
}

export function showConfig(path: string = DEFAULT_CONFIG_PATH): string {
const cfg = loadConfig(path);
if (Object.keys(cfg).length === 0) return "No configuration set.";
return Object.entries(cfg)
.map(([k, v]) => {
const display = k.includes("key") ? v.slice(0, 8) + "..." : v;
return ` ${k}: ${display}`;
})
.join("\n");
}
12 changes: 12 additions & 0 deletions js/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,3 +188,15 @@ export {
Shield, DebouncedHandler, classifyPath, collectWatchPaths,
type ShieldCallback, type ShieldOptions,
} from "./shield.js";

// Config
export { loadConfig, saveConfigKey, removeConfigKey, showConfig, CONFIG_KEYS } from "./config.js";

// Login / credentials
export { saveCredentials, loadCredentials, saveLicense, loadLicense } from "./login.js";

// Watch
export { selectCanaryProbes, checkRegression } from "./watch.js";

// MCP CLI renderer
export { renderMCPResults } from "./scan-mcp-cli.js";
35 changes: 35 additions & 0 deletions js/src/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
import { saveConfigKey, loadConfig, DEFAULT_CONFIG_PATH } from "./config.js";

const CONFIG_DIR = join(homedir(), ".agentseal");

export interface Credentials {
apiUrl: string;
apiKey: string;
}

export function saveCredentials(apiUrl: string, apiKey: string, path?: string): void {
saveConfigKey("dashboard-url", apiUrl, path ?? DEFAULT_CONFIG_PATH);
saveConfigKey("dashboard-key", apiKey, path ?? DEFAULT_CONFIG_PATH);
}

export function loadCredentials(path?: string): Credentials | null {
const cfg = loadConfig(path ?? DEFAULT_CONFIG_PATH);
if (!cfg["dashboard-url"] || !cfg["dashboard-key"]) return null;
return { apiUrl: cfg["dashboard-url"], apiKey: cfg["dashboard-key"] };
}

export function saveLicense(key: string, path: string = join(CONFIG_DIR, "license.json")): void {
const dir = dirname(path);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
writeFileSync(path, JSON.stringify({ key, activated: new Date().toISOString() }, null, 2), { mode: 0o600 });
chmodSync(path, 0o600);
}

export function loadLicense(path: string = join(CONFIG_DIR, "license.json")): string | null {
if (!existsSync(path)) return null;
const data = JSON.parse(readFileSync(path, "utf-8"));
return data.key ?? null;
}
44 changes: 44 additions & 0 deletions js/src/scan-mcp-cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
export interface MCPScanResult {
server_name: string;
verdict: string;
findings: Array<{ code: string; severity: string; title: string; detail?: string }>;
trust_score?: number;
tools_count: number;
}

export function renderMCPResults(results: MCPScanResult[], verbose: boolean): void {
const R = "\x1b[0m";
const B = "\x1b[1m";
const C = "\x1b[36m";
const G = "\x1b[32m";
const Y = "\x1b[33m";
const RED = "\x1b[31m";
const D = "\x1b[90m";

console.log(`\n ${C}${B}MCP Server Scan Results${R}\n`);

for (const r of results) {
const color = r.verdict === "safe" ? G : r.verdict === "warning" ? Y : RED;
const score = r.trust_score !== undefined ? ` (${r.trust_score}/100)` : "";
console.log(` ${color}${r.verdict.toUpperCase()}${R} ${r.server_name}${score} — ${r.tools_count} tools`);

if (verbose || r.verdict !== "safe") {
for (const f of r.findings) {
const sevColor = f.severity === "critical" || f.severity === "high" ? RED : f.severity === "medium" ? Y : D;
console.log(` ${sevColor}${f.severity}${R} ${f.code}: ${f.title}`);
}
}
}

const dangers = results.filter((r) => r.verdict === "danger").length;
const warnings = results.filter((r) => r.verdict === "warning").length;
const safe = results.filter((r) => r.verdict === "safe").length;

console.log(`\n ${D}${"─".repeat(50)}${R}`);
const parts: string[] = [];
if (dangers > 0) parts.push(`${RED}${B}${dangers} DANGER${R}`);
if (warnings > 0) parts.push(`${Y}${B}${warnings} WARNING${R}`);
parts.push(`${G}${B}${safe} SAFE${R}`);
console.log(` ${parts.join(" ")}`);
console.log();
}
1 change: 1 addition & 0 deletions js/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,4 +173,5 @@ export interface ValidatorOptions {
onProgress?: ProgressFn;
adaptive?: boolean;
semantic?: { embed: EmbedFn };
probes?: Probe[];
}
10 changes: 8 additions & 2 deletions js/src/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export class AgentValidator {
private onProgress: ProgressFn | undefined;
private adaptive: boolean;
private embed: EmbedFn | undefined;
private customProbes: Probe[] | undefined;

constructor(options: ValidatorOptions) {
this.agentFn = options.agentFn;
Expand All @@ -60,6 +61,7 @@ export class AgentValidator {
this.onProgress = options.onProgress;
this.adaptive = options.adaptive ?? false;
this.embed = options.semantic?.embed;
this.customProbes = options.probes;
}

// ── Factory methods ──────────────────────────────────────────────
Expand Down Expand Up @@ -116,8 +118,12 @@ export class AgentValidator {
const startTime = performance.now();
const allResults: ProbeResult[] = [];

const extractionProbes = buildExtractionProbes();
const injectionProbes = buildInjectionProbes();
const extractionProbes = this.customProbes
? this.customProbes.filter((p) => !p.canary)
: buildExtractionProbes();
const injectionProbes = this.customProbes
? this.customProbes.filter((p) => !!p.canary)
: buildInjectionProbes();
const sem = semaphore(this.concurrency);

const icon: Record<string, string> = { blocked: "✓", leaked: "✗", partial: "◐", error: "⚠" };
Expand Down
29 changes: 29 additions & 0 deletions js/src/watch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { buildExtractionProbes } from "./probes/extraction.js";
import { buildInjectionProbes } from "./probes/injection.js";

const DEFAULT_CANARY_IDS = [
"ext_direct_1",
"ext_roleplay_1",
"inj_override_1",
"inj_delim_1",
"inj_indirect_1",
];

export function selectCanaryProbes(csv?: string): Array<Record<string, any>> {
const allProbes = [...buildExtractionProbes(), ...buildInjectionProbes()];
if (csv) {
const ids = csv.split(",").map((s) => s.trim());
return allProbes.filter((p) => ids.includes(p.probe_id));
}
return allProbes.filter((p) => DEFAULT_CANARY_IDS.includes(p.probe_id));
}

export function checkRegression(
currentScore: number,
baselineScore: number | null,
threshold: number = 5.0,
): { score: number; baseline: number | null; regression: boolean; delta: number } {
if (baselineScore === null) return { score: currentScore, baseline: null, regression: false, delta: 0 };
const delta = baselineScore - currentScore;
return { score: currentScore, baseline: baselineScore, regression: delta > threshold, delta };
}
42 changes: 42 additions & 0 deletions js/test/cli-commands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, it, expect, beforeAll } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { execSync } from "node:child_process";

describe("CLI version", () => {
it("VERSION constant matches package.json", () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "../package.json"), "utf-8")
);
const cli = readFileSync(
join(__dirname, "../bin/agentseal.ts"), "utf-8"
);
const match = cli.match(/const VERSION = "([^"]+)"/);
// After the fix, VERSION is read dynamically, so this test just checks package.json version
expect(pkg.version).toBe("0.8.1");
});
});

describe("CLI command registration", () => {
let helpOutput: string;

beforeAll(() => {
helpOutput = execSync("node dist/agentseal.js --help", {
cwd: join(__dirname, ".."),
encoding: "utf-8",
timeout: 15000,
});
});

const expectedCommands = [
"scan", "compare", "guard", "scan-mcp", "shield",
"fix", "watch", "login", "activate", "profiles",
"registry", "config",
];

for (const cmd of expectedCommands) {
it(`registers '${cmd}' command`, () => {
expect(helpOutput).toContain(cmd);
});
}
});
35 changes: 35 additions & 0 deletions js/test/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { loadConfig, saveConfigKey, removeConfigKey, CONFIG_KEYS } from "../src/config.js";

describe("config", () => {
let tempDir: string;
beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), "agentseal-config-")); });
afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); });

it("returns empty config when file does not exist", () => {
expect(loadConfig(join(tempDir, "config.json"))).toEqual({});
});

it("saves and loads a key", () => {
const path = join(tempDir, "config.json");
saveConfigKey("model", "gpt-4o", path);
expect(loadConfig(path).model).toBe("gpt-4o");
});

it("removes a key", () => {
const path = join(tempDir, "config.json");
saveConfigKey("model", "gpt-4o", path);
removeConfigKey("model", path);
expect(loadConfig(path).model).toBeUndefined();
});

it("CONFIG_KEYS contains expected keys", () => {
expect(CONFIG_KEYS).toContain("model");
expect(CONFIG_KEYS).toContain("api-key");
expect(CONFIG_KEYS).toContain("ollama-url");
expect(CONFIG_KEYS).toContain("dashboard-url");
});
});
30 changes: 30 additions & 0 deletions js/test/login.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { saveCredentials, loadCredentials, saveLicense, loadLicense } from "../src/login.js";

describe("login", () => {
let tempDir: string;
beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), "agentseal-login-")); });
afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); });

it("saves and loads dashboard credentials", () => {
const path = join(tempDir, "config.json");
saveCredentials("https://agentseal.org/api/v1", "test-key-123", path);
const creds = loadCredentials(path);
expect(creds).not.toBeNull();
expect(creds!.apiUrl).toBe("https://agentseal.org/api/v1");
expect(creds!.apiKey).toBe("test-key-123");
});

it("returns null when no credentials saved", () => {
expect(loadCredentials(join(tempDir, "nonexistent.json"))).toBeNull();
});

it("saves and loads license key", () => {
const path = join(tempDir, "license.json");
saveLicense("SEAL-PRO-XXXX-YYYY", path);
expect(loadLicense(path)).toBe("SEAL-PRO-XXXX-YYYY");
});
});
20 changes: 20 additions & 0 deletions js/test/watch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, it, expect } from "vitest";
import { selectCanaryProbes, checkRegression } from "../src/watch.js";

describe("watch", () => {
it("selects 5 canary probes by default", () => {
const probes = selectCanaryProbes();
expect(probes).toHaveLength(5);
});

it("detects regression when score drops", () => {
const result = checkRegression(70, 80, 5);
expect(result.regression).toBe(true);
expect(result.delta).toBe(10);
});

it("no regression when score is stable", () => {
const result = checkRegression(78, 80, 5);
expect(result.regression).toBe(false);
});
});
Loading