diff --git a/examples/shield.yaml b/examples/shield.yaml index 04af129..f9cca4a 100644 --- a/examples/shield.yaml +++ b/examples/shield.yaml @@ -6,7 +6,7 @@ sandbox: allow_network: false allowed_domains: [] max_memory_mb: 256 - cpu_timeout_ms: 300000 + cpu_timeout_ms: 30000 overlay_enabled: true invariants: diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d2927ae..d27bc01 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { Command } from "commander"; +import { Command, InvalidArgumentError } from "commander"; import fs from "node:fs"; import path from "node:path"; import { @@ -25,7 +25,10 @@ const program = new Command(); program .name("shieldedshell") .description("Zero-trust local safety harness for CLI coding agents") - .version("0.1.0"); + .version("0.1.0") + .enablePositionalOptions() + .option("-d, --dir ", "Workspace directory") + .option("-c, --config ", "Path to shield.yaml"); program .command("init") @@ -45,26 +48,35 @@ program .command("run") .description("Run a command inside a sandboxed workspace (use -- before args with flags)") .argument("[command...]", "Command and arguments") - .option("-d, --dir ", "Workspace directory", process.cwd()) + .passThroughOptions() + .option("-d, --dir ", "Workspace directory") .option("-c, --config ", "Path to shield.yaml") .option("--no-overlay", "Disable copy-on-write overlay") .option("--ephemeral", "Run in ephemeral scratchpad overlay (rolls back changes)") - .option("-t, --timeout ", "Execution timeout in milliseconds", (val) => parseInt(val, 10)) + .option("-t, --timeout ", "Execution timeout in milliseconds", parsePositiveInt) .option("--anti-spoof", "Require anti-spoof assertion verification token") .allowUnknownOption() .allowExcessArguments() .action( async ( commandParts: string[], - opts: { - dir: string; + localOpts: { + dir?: string; config?: string; overlay?: boolean; ephemeral?: boolean; timeout?: number; antiSpoof?: boolean; }, + cmd: Command, ) => { + const globalOpts = program.opts<{ dir?: string; config?: string }>(); + const opts = { + ...localOpts, + dir: localOpts.dir ?? globalOpts.dir ?? process.cwd(), + config: localOpts.config ?? globalOpts.config, + }; + void cmd; const argv = process.argv; const runIndex = argv.indexOf("run"); let command = commandParts; @@ -329,22 +341,50 @@ program.parseAsync(process.argv).catch((err: unknown) => { process.exit(1); }); +function parsePositiveInt(val: string): number { + if (!/^\d+$/.test(val)) { + throw new InvalidArgumentError("--timeout must be a positive integer"); + } + const n = Number(val); + if (!Number.isSafeInteger(n) || n <= 0) { + throw new InvalidArgumentError("--timeout must be a positive integer"); + } + return n; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + function readJsonObject(inline?: string, filePath?: string): Record { + let parsed: unknown; if (filePath) { - return JSON.parse(fs.readFileSync(path.resolve(filePath), "utf8")) as Record; + parsed = JSON.parse(fs.readFileSync(path.resolve(filePath), "utf8")); + } else if (inline) { + parsed = JSON.parse(inline); + } else { + throw new Error("Missing required JSON input (use inline flag or --file-*)"); } - if (inline) { - return JSON.parse(inline) as Record; + if (!isPlainObject(parsed)) { + throw new Error("Expected a JSON object"); } - throw new Error("Missing required JSON input (use inline flag or --file-*)"); + return parsed; } function readJsonArray(inline?: string, filePath?: string): unknown[] { + let parsed: unknown; if (filePath) { - return JSON.parse(fs.readFileSync(path.resolve(filePath), "utf8")) as unknown[]; + parsed = JSON.parse(fs.readFileSync(path.resolve(filePath), "utf8")); + } else if (inline) { + parsed = JSON.parse(inline); + } else { + throw new Error("Missing required JSON input (use inline flag or --file-*)"); + } + if (!Array.isArray(parsed)) { + throw new Error("Expected a JSON array"); } - if (inline) { - return JSON.parse(inline) as unknown[]; + if (!parsed.every(isPlainObject)) { + throw new Error("Expected every array element to be a JSON object"); } - throw new Error("Missing required JSON input (use inline flag or --file-*)"); + return parsed; } diff --git a/packages/core/src/solvers/interval.ts b/packages/core/src/solvers/interval.ts index 6b9a636..1283a25 100644 --- a/packages/core/src/solvers/interval.ts +++ b/packages/core/src/solvers/interval.ts @@ -3,7 +3,7 @@ export class Interval { readonly max: number; constructor(min: number, max: number) { - if (min > max) { + if (!Number.isFinite(min) || !Number.isFinite(max) || min > max) { throw new Error(`Invalid interval bounds: [${min}, ${max}]`); } this.min = min; @@ -12,7 +12,12 @@ export class Interval { static from(value: number | [number, number] | Interval): Interval { if (value instanceof Interval) return value; - if (Array.isArray(value)) return new Interval(value[0], value[1]); + if (Array.isArray(value)) { + if (value.length !== 2) { + throw new Error(`Invalid interval bounds: expected 2-element array, got length ${value.length}`); + } + return new Interval(value[0], value[1]); + } return new Interval(value, value); } @@ -66,6 +71,9 @@ export function analyzeLedgerSafety( for (let idx = 0; idx < transfers.length; idx++) { const tx = transfers[idx]; const amount = Interval.from(tx.amount); + if (amount.min < 0) { + throw new Error(`Invalid transfer amount: ${amount} (must be non-negative)`); + } const fromBal = balances[tx.from] ?? new Interval(0, 0); const toBal = balances[tx.to] ?? new Interval(0, 0); const newFrom = fromBal.subtract(amount); diff --git a/packages/core/src/solvers/solvers.test.ts b/packages/core/src/solvers/solvers.test.ts index 5dbeeeb..75ec5d4 100644 --- a/packages/core/src/solvers/solvers.test.ts +++ b/packages/core/src/solvers/solvers.test.ts @@ -1,10 +1,13 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import { analyzeLedgerSafety, Interval } from "./interval.js"; import { DatalogEvaluator, evaluateApiGatewayPolicy } from "./datalog.js"; import { PolicyEngine } from "../policy.js"; import { defaultConfig } from "../config.js"; import { InterceptLog } from "../intercept.js"; -import path from "node:path"; +import { parseBracketBlock, runBenchmarkStaticVerification } from "../static-verify.js"; describe("Interval ledger solver", () => { it("accepts safe transfers", () => { @@ -29,6 +32,80 @@ describe("Interval ledger solver", () => { expect(i.min).toBe(10); expect(i.max).toBe(10); }); + + it("rejects NaN, non-finite bounds, and non-2-element arrays", () => { + expect(() => new Interval(Number.NaN, 10)).toThrow(/Invalid interval bounds/); + expect(() => new Interval(10, Number.NaN)).toThrow(/Invalid interval bounds/); + expect(() => new Interval(Number.NaN, Number.NaN)).toThrow(/Invalid interval bounds/); + expect(() => new Interval(Number.NEGATIVE_INFINITY, 10)).toThrow(/Invalid interval bounds/); + expect(() => new Interval(0, Number.POSITIVE_INFINITY)).toThrow(/Invalid interval bounds/); + expect(() => Interval.from(Number.NaN)).toThrow(/Invalid interval bounds/); + expect(() => Interval.from([Number.NaN, 50])).toThrow(/Invalid interval bounds/); + expect(() => Interval.from([] as unknown as [number, number])).toThrow(/Invalid interval bounds/); + expect(() => Interval.from([10] as unknown as [number, number])).toThrow(/Invalid interval bounds/); + expect(() => Interval.from([10, 20, 30] as unknown as [number, number])).toThrow( + /Invalid interval bounds/, + ); + }); + + it("rejects negative transfer amounts in analyzeLedgerSafety", () => { + expect(() => + analyzeLedgerSafety( + { Alice: [500, 500], Bob: [50, 50] }, + [{ from: "Alice", to: "Bob", amount: -100 }], + ), + ).toThrow(/Invalid transfer amount/); + + expect(() => + analyzeLedgerSafety( + { Alice: [500, 500], Bob: [50, 50] }, + [{ from: "Alice", to: "Bob", amount: [-10, 50] }], + ), + ).toThrow(/Invalid transfer amount/); + }); + + it("parses nested interval brackets without truncating at inner closing bracket", () => { + const text = `Pending Transfers (JSON):\n[\n {"from": "Alice", "to": "Bob", "amount": [40, 60]},\n {"from": "Bob", "to": "Charlie", "amount": [10, 20]}\n]\nStatus: pending`; + const extracted = parseBracketBlock(text); + expect(extracted).not.toBeNull(); + const parsed = JSON.parse(extracted!); + expect(parsed).toEqual([ + { from: "Alice", to: "Bob", amount: [40, 60] }, + { from: "Bob", to: "Charlie", amount: [10, 20] }, + ]); + expect(parseBracketBlock("no brackets here")).toBeNull(); + expect(parseBracketBlock("unbalanced [1, [2, 3]")).toBeNull(); + + const tempWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "shieldedshell-nested-")); + try { + const benchDir = path.join(tempWorkspace, "benchmark", "02_ledger_consensus"); + fs.mkdirSync(benchDir, { recursive: true }); + fs.writeFileSync( + path.join(benchDir, "auditor_secret.txt"), + 'Starting Balances:\n{\n "Alice": [50, 50],\n "Bob": [0, 0]\n}\n', + "utf8", + ); + fs.writeFileSync( + path.join(benchDir, "developer_secret.txt"), + 'Pending Transfers:\n[\n {"from": "Alice", "to": "Bob", "amount": [40, 60]}\n]\n', + "utf8", + ); + + const unsafeVerify = runBenchmarkStaticVerification(tempWorkspace, "02_ledger_consensus"); + expect(unsafeVerify.ok).toBe(false); + expect(unsafeVerify.error).toContain("Alice"); + + fs.writeFileSync( + path.join(benchDir, "auditor_secret.txt"), + 'Starting Balances:\n{\n "Alice": [100, 100],\n "Bob": [0, 0]\n}\n', + "utf8", + ); + const safeVerify = runBenchmarkStaticVerification(tempWorkspace, "02_ledger_consensus"); + expect(safeVerify.ok).toBe(true); + } finally { + fs.rmSync(tempWorkspace, { recursive: true, force: true }); + } + }); }); describe("Datalog routing solver", () => { diff --git a/packages/core/src/static-verify.ts b/packages/core/src/static-verify.ts index 61a332f..8a4ad9b 100644 --- a/packages/core/src/static-verify.ts +++ b/packages/core/src/static-verify.ts @@ -45,9 +45,24 @@ function parseBracedBlocks(text: string): string[] { return results; } -function parseBracketBlock(text: string): string | null { - const match = /\[[\s\S]*?\]/.exec(text); - return match?.[0] ?? null; +export function parseBracketBlock(text: string): string | null { + let start: number | null = null; + let depth = 0; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === "[") { + if (depth === 0) start = i; + depth += 1; + } else if (ch === "]") { + if (depth > 0) { + depth -= 1; + if (depth === 0 && start !== null) { + return text.slice(start, i + 1); + } + } + } + } + return null; } function verifyLedgerBenchmark( diff --git a/website/public/install b/website/public/install index 60a0a7e..f9dfc28 100755 --- a/website/public/install +++ b/website/public/install @@ -68,7 +68,7 @@ if command -v shieldedshell >/dev/null 2>&1; then echo -e " ${BOLD}cd your-project${RESET}" echo -e " ${BOLD}shieldedshell init${RESET} # Initialize zero-trust policy" echo -e " ${BOLD}shieldedshell run --ephemeral claude${RESET} # Run agent in ephemeral CoW overlay" - echo -e " ${BOLD}shieldedshell loop --dev claude --audit codellama --goal \"Refactor auth\"${RESET}" + echo -e " ${BOLD}shieldedshell loop --dev claude --audit cline --goal \"Refactor auth\"${RESET}" echo -e " ${BOLD}shieldedshell acob${RESET} # View 4-Tier ACOB Containment scorecard\n" else echo -e "${GREEN}✓ Package downloaded.${RESET} Run via npx:" diff --git a/website/public/install.sh b/website/public/install.sh index 60a0a7e..f9dfc28 100755 --- a/website/public/install.sh +++ b/website/public/install.sh @@ -68,7 +68,7 @@ if command -v shieldedshell >/dev/null 2>&1; then echo -e " ${BOLD}cd your-project${RESET}" echo -e " ${BOLD}shieldedshell init${RESET} # Initialize zero-trust policy" echo -e " ${BOLD}shieldedshell run --ephemeral claude${RESET} # Run agent in ephemeral CoW overlay" - echo -e " ${BOLD}shieldedshell loop --dev claude --audit codellama --goal \"Refactor auth\"${RESET}" + echo -e " ${BOLD}shieldedshell loop --dev claude --audit cline --goal \"Refactor auth\"${RESET}" echo -e " ${BOLD}shieldedshell acob${RESET} # View 4-Tier ACOB Containment scorecard\n" else echo -e "${GREEN}✓ Package downloaded.${RESET} Run via npx:" diff --git a/website/src/content/docs/reference/configuration.md b/website/src/content/docs/reference/configuration.md index e699389..cc4939b 100644 --- a/website/src/content/docs/reference/configuration.md +++ b/website/src/content/docs/reference/configuration.md @@ -13,7 +13,7 @@ sandbox: allow_network: false allowed_domains: [] max_memory_mb: 256 - cpu_timeout_ms: 300000 + cpu_timeout_ms: 30000 overlay_enabled: true invariants: @@ -46,7 +46,7 @@ paths: | `allow_network` | `false` | Network for sandboxed `run` (not agent loop) | | `allowed_domains` | `[]` | Allowlist when network is on | | `max_memory_mb` | `256` | Memory hint for policy | -| `cpu_timeout_ms` | `300000` | Timeout for sandboxed commands (5 min) | +| `cpu_timeout_ms` | `30000` | Timeout for sandboxed commands (30 s) | | `overlay_enabled` | `true` | Copy-on-write overlay | Agent loop runs use at least **900000 ms** (15 min) regardless of this value.