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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ dist
*.tgz
.automaton/wallet.json
.automaton/state.db

.automaton-safe/
.automaton-live/
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,13 @@
"scripts": {
"build": "tsc && pnpm -r build",
"dev": "tsx watch src/index.ts",
"test": "vitest run",
"test": "pnpm test:offline",
"test:offline-legacy": "AUTOMATON_OFFLINE_TEST=true vitest run --config vitest.offline.config.ts",
"test:safe-mode": "AUTOMATON_SAFE_MODE=true AUTOMATON_OFFLINE_TEST=true vitest run --config vitest.safe-mode.config.ts",
"test:restricted-live": "AUTOMATON_RESTRICTED_LIVE=true AUTOMATON_LIVE_DRY_RUN=true AUTOMATON_OFFLINE_TEST=true vitest run --config vitest.restricted-live.config.ts",
"test:offline": "pnpm test:offline-legacy && pnpm test:safe-mode",
"test:integration-local": "AUTOMATON_OFFLINE_TEST=true vitest run src/__tests__/integration",
"test:live": "AUTOMATON_LIVE_TEST=true vitest run --config vitest.config.ts",
"typecheck": "tsc --noEmit",
"test:coverage": "vitest run --coverage",
"test:security": "vitest run --grep 'security|injection|policy'",
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ const args = process.argv.slice(2);
const command = args[0];

async function main(): Promise<void> {
const safeMode = process.env.AUTOMATON_SAFE_MODE;
if (safeMode !== undefined && safeMode !== "true" && safeMode !== "false") {
throw new Error('AUTOMATON_SAFE_MODE must be exactly "true" or "false" when set');
}
if (safeMode === "true" && (command === "fund" || command === "send")) {
throw new Error(`LOCAL_SAFE_MODE denies CLI command: ${command}`);
}
switch (command) {
case "status":
await import("./commands/status.js");
Expand Down
10 changes: 9 additions & 1 deletion src/__tests__/agent/general-harness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ describe("agent/GeneralHarness", () => {
wisdom: { conventions: [], successes: [], failures: [], gotchas: [] },
abortSignal: new AbortController().signal,
goalId: "goal-1",
policyEngine: new PolicyEngine(appDb.raw, []),
toolCatalog,
toolContext: {
identity,
Expand Down Expand Up @@ -120,7 +121,14 @@ describe("agent/GeneralHarness", () => {
});

it("routes the web_fetch SPEC alias through the current x402_fetch surface", async () => {
const { harness, appDb } = await createHarness();
const identity = createTestIdentity();
const fakeFetch: AutomatonTool = {
name: "x402_fetch", description: "offline fetch fake",
parameters: { type: "object", properties: { url: { type: "string" } } },
riskLevel: "safe", category: "conway", execute: async () => "offline-result",
};
const toolCatalog = [...createBuiltinTools(identity.sandboxId).filter((tool) => tool.name !== "x402_fetch"), fakeFetch];
const { harness, appDb } = await createHarness({ toolCatalog });
const aliasTool = harness.getToolDefs().find((tool) => tool.name === "web_fetch");
const wrappedTool = harness.getToolDefs().find((tool) => tool.name === "x402_fetch");

Expand Down
20 changes: 13 additions & 7 deletions src/__tests__/integration/compression-cascade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,19 @@ import type { ContextUtilization } from "../../memory/context-manager.js";
// ---------------------------------------------------------------------------
// Mock node:fs so Stage 4 checkpoint writes are no-ops
// ---------------------------------------------------------------------------
vi.mock("node:fs", () => ({
promises: {
mkdir: vi.fn().mockResolvedValue(undefined),
writeFile: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn().mockResolvedValue("{}"),
},
}));
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
default: actual,
promises: {
...actual.promises,
mkdir: vi.fn().mockResolvedValue(undefined),
writeFile: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn().mockResolvedValue("{}"),
},
};
});

// ---------------------------------------------------------------------------
// Helpers
Expand Down
14 changes: 7 additions & 7 deletions src/__tests__/loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ describe("Agent Loop", () => {
db.close();
});

it("exec tool runs and is persisted", async () => {
it("exec tool fails closed when policy context is absent", async () => {
const inference = new MockInferenceClient([
toolCallResponse([
{ name: "exec", arguments: { command: "echo hello" } },
Expand All @@ -63,14 +63,13 @@ describe("Agent Loop", () => {
);
expect(execTurn).toBeDefined();
expect(execTurn!.toolCalls[0].name).toBe("exec");
expect(execTurn!.toolCalls[0].error).toBeUndefined();
expect(execTurn!.toolCalls[0].error).toContain("POLICY_CONTEXT_REQUIRED");

// Verify conway.exec was called
expect(conway.execCalls.length).toBeGreaterThanOrEqual(1);
expect(conway.execCalls[0].command).toBe("echo hello");
expect(conway.execCalls.length).toBe(0);
});

it("forbidden patterns blocked", async () => {
it("forbidden patterns fail closed before execution when policy context is absent", async () => {
const inference = new MockInferenceClient([
toolCallResponse([
{ name: "exec", arguments: { command: "rm -rf ~/.automaton" } },
Expand All @@ -89,13 +88,14 @@ describe("Agent Loop", () => {
onTurnComplete: (turn) => turns.push(turn),
});

// The tool result should contain a blocked message, not an error
// Missing policy context takes precedence over inline command filtering.
const execTurn = turns.find((t) =>
t.toolCalls.some((tc) => tc.name === "exec"),
);
expect(execTurn).toBeDefined();
const execCall = execTurn!.toolCalls.find((tc) => tc.name === "exec");
expect(execCall!.result).toContain("Blocked");
expect(execCall!.error).toContain("POLICY_CONTEXT_REQUIRED");
expect(execCall!.result).toBe("");

// conway.exec should NOT have been called
expect(conway.execCalls.length).toBe(0);
Expand Down
61 changes: 61 additions & 0 deletions src/__tests__/offline-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { afterEach, beforeEach, vi } from "vitest";
import net from "node:net";
import tls from "node:tls";
import dns from "node:dns";
import http from "node:http";
import https from "node:https";
import childProcess from "node:child_process";
import fs from "node:fs";
import path from "node:path";

// Restricted-live tests use this shared setup and must exercise the guarded
// proposal paths without ever enabling real payment execution.
if (process.env.VITEST_RESTRICTED_LIVE === "true") {
process.env.AUTOMATON_RESTRICTED_LIVE = "true";
process.env.AUTOMATON_LIVE_DRY_RUN = "true";
}

const offline = process.env.AUTOMATON_OFFLINE_TEST === "true";
const denyFetch = vi.fn(async (input: string | URL | Request) => {
throw new Error(`AUTOMATON_OFFLINE_TEST denied fetch: ${String(input)}`);
});

const deny = (kind: string) => { throw new Error(`AUTOMATON_OFFLINE_TEST denied ${kind}`); };
let testSequence = 0;

if (offline) {
vi.stubGlobal("fetch", denyFetch);
vi.spyOn(net, "connect").mockImplementation((() => deny("net.connect")) as any);
vi.spyOn(net, "createConnection").mockImplementation((() => deny("net.createConnection")) as any);
vi.spyOn(tls, "connect").mockImplementation((() => deny("tls.connect")) as any);
vi.spyOn(dns, "lookup").mockImplementation((() => deny("dns.lookup")) as any);
vi.spyOn(dns, "resolve").mockImplementation((() => deny("dns.resolve")) as any);
vi.spyOn(http, "request").mockImplementation((() => deny("http.request")) as any);
vi.spyOn(http, "get").mockImplementation((() => deny("http.get")) as any);
vi.spyOn(https, "request").mockImplementation((() => deny("https.request")) as any);
vi.spyOn(https, "get").mockImplementation((() => deny("https.get")) as any);
vi.spyOn(childProcess, "exec").mockImplementation((() => deny("child_process.exec")) as any);
vi.spyOn(childProcess, "execFile").mockImplementation((() => deny("child_process.execFile")) as any);
vi.spyOn(childProcess, "execSync").mockImplementation((() => deny("child_process.execSync")) as any);
vi.spyOn(childProcess, "execFileSync").mockImplementation((() => deny("child_process.execFileSync")) as any);
vi.spyOn(childProcess, "fork").mockImplementation((() => deny("child_process.fork")) as any);
vi.spyOn(childProcess, "spawn").mockImplementation((() => deny("child_process.spawn")) as any);
vi.spyOn(childProcess, "spawnSync").mockImplementation((() => deny("child_process.spawnSync")) as any);
}

beforeEach(() => {
if (offline) {
vi.stubGlobal("fetch", denyFetch);
testSequence += 1;
const isolatedHome = path.join(process.cwd(), ".automaton-safe", "tests", `${process.pid}-${testSequence}`);
fs.mkdirSync(isolatedHome, { recursive: true, mode: 0o700 });
process.env.HOME = isolatedHome;
}
});

afterEach(() => {
if (offline) {
vi.unstubAllGlobals();
vi.stubGlobal("fetch", denyFetch);
}
});
60 changes: 60 additions & 0 deletions src/__tests__/payment-proposals.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createPaymentProposal, ProposalStore, reviewAndExecuteProposal } from "../restricted-live/payment-proposals.js";
import { X402SCAN_ROUTE } from "../restricted-live/x402scan-once.js";

describe("restricted-live payment proposals", () => {
const dirs: string[] = [];
afterEach(() => { for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true }); });
const setup = () => { const d = fs.mkdtempSync(path.join(os.tmpdir(), "payment-proposal-")); dirs.push(d); return { proposalFile: path.join(d, "proposals.db"), intentFile: path.join(d, "intent.db") }; };
const balance = async () => 4_990_000n;

it("creates only a pinned proposal and returns its ID", async () => {
const files = setup();
const id = await createPaymentProposal("Inspect buyer statistics if useful", { ...files, balanceBaseUnits: undefined, getBalanceBaseUnits: balance, id: () => "proposal-test-1", now: () => 1000 } as any);
const store = new ProposalStore(files.proposalFile); const p = store.get(id, 1000)!; store.close();
expect(id).toBe("proposal-test-1");
expect(p).toMatchObject({ state: "PROPOSED", resourceUrl: X402SCAN_ROUTE.url, method: "GET", expectedPriceBaseUnits: "10000", expectedRecipient: X402SCAN_ROUTE.payTo, expectedChainId: 8453, expectedToken: X402SCAN_ROUTE.asset, expectedScheme: "exact", maxTimeoutSeconds: 300 });
});

it("rejects empty rationale and budget failures", async () => {
const files = setup();
await expect(createPaymentProposal("", { ...files, getBalanceBaseUnits: balance })).rejects.toMatchObject({ code: "PROPOSAL_INVALID" });
await expect(createPaymentProposal("too little reserve", { ...files, getBalanceBaseUnits: async () => 4_000_000n })).rejects.toMatchObject({ code: "BUDGET_DENIED" });
});

it("review requires live mode and exact human approval; it never accepts yes", async () => {
const files = setup();
const id = await createPaymentProposal("Inspect buyer statistics", { ...files, getBalanceBaseUnits: balance, id: () => "proposal-test-2" } as any);
await expect(reviewAndExecuteProposal(id, { ...files, getBalanceBaseUnits: balance, fetch: vi.fn(), executionDeps: {} as any, confirm: async () => "yes" })).rejects.toMatchObject({ code: "MODE_REQUIRED" });
});

it("proposal data cannot define a second route", async () => {
const files = setup();
const id = await createPaymentProposal("Read stats", { ...files, getBalanceBaseUnits: balance, id: () => "proposal-test-3" } as any);
const store = new ProposalStore(files.proposalFile); store.close();
const db = await import("better-sqlite3"); const raw = new db.default(files.proposalFile); raw.prepare("UPDATE payment_proposals SET expected_recipient=? WHERE proposal_id=?").run("0x0000000000000000000000000000000000000001", id); raw.close();
const check = new ProposalStore(files.proposalFile); const p = check.get(id)!; check.close(); expect(p.expectedRecipient).not.toBe(X402SCAN_ROUTE.payTo);
});

it("normalizes expired proposed rows to EXPIRED when read", async () => {
const files = setup();
const id = await createPaymentProposal("Expired proposal", { ...files, getBalanceBaseUnits: balance, id: () => "proposal-expired", now: () => 1000 } as any);
const store = new ProposalStore(files.proposalFile);
expect(store.get(id, 1000 + 10 * 60 * 1000 + 1)!.state).toBe("EXPIRED");
expect(store.get(id, 1000 + 10 * 60 * 1000 + 2)!.state).toBe("EXPIRED");
store.close();
});

it("leaves non-expired and terminal proposal states unchanged", async () => {
const files = setup();
const id = await createPaymentProposal("Active proposal", { ...files, getBalanceBaseUnits: balance, id: () => "proposal-active", now: () => 1000 } as any);
const store = new ProposalStore(files.proposalFile);
expect(store.get(id, 1000 + 10 * 60 * 1000 - 1)!.state).toBe("PROPOSED");
store.transition(id, "PROPOSED", "REJECTED", 1001);
expect(store.get(id, 1000 + 20 * 60 * 1000)!.state).toBe("REJECTED");
store.close();
});
});
8 changes: 4 additions & 4 deletions src/__tests__/policy-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -674,7 +674,7 @@ describe("executeTool with PolicyEngine", () => {
expect(result.result).toBe("");
});

it("allows tool execution when no policy engine is provided", async () => {
it("denies tool execution when no policy engine is provided", async () => {
const tools = createBuiltinTools("test-sandbox-id");
const identity = createTestIdentity();
const config = createTestConfig();
Expand All @@ -689,11 +689,11 @@ describe("executeTool with PolicyEngine", () => {
inference,
};

// No policyEngine or turnContext - backward compatible
// Policy and turn context are mandatory: fail closed.
const result = await executeTool("check_credits", {}, tools, context);

expect(result.error).toBeUndefined();
expect(result.result).toContain("Credit balance");
expect(result.error).toContain("POLICY_CONTEXT_REQUIRED");
expect(result.result).toBe("");
});

it("allows tool execution when policy allows", async () => {
Expand Down
10 changes: 10 additions & 0 deletions src/__tests__/restricted-live-cli-output.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
import { formatReviewPaymentOutput } from "../restricted-live/cli-output.js";

describe("restricted-live review CLI output", () => {
it("serializes internal bigint balances as decimal strings", () => {
const output = formatReviewPaymentOutput({ status: 200, balanceAfterBaseUnits: 4_990_000n });
expect(() => JSON.stringify(output)).not.toThrow();
expect(output).toEqual({ mode: "restricted-live-review-payment", status: 200, balanceAfterBaseUnits: "4990000" });
});
});
Loading