Skip to content
Draft
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
2 changes: 1 addition & 1 deletion examples/shield.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
68 changes: 54 additions & 14 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 <path>", "Workspace directory")
.option("-c, --config <path>", "Path to shield.yaml");

program
.command("init")
Expand All @@ -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 <path>", "Workspace directory", process.cwd())
.passThroughOptions()
.option("-d, --dir <path>", "Workspace directory")
.option("-c, --config <path>", "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 <ms>", "Execution timeout in milliseconds", (val) => parseInt(val, 10))
.option("-t, --timeout <ms>", "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;
Expand Down Expand Up @@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function readJsonObject(inline?: string, filePath?: string): Record<string, unknown> {
let parsed: unknown;
if (filePath) {
return JSON.parse(fs.readFileSync(path.resolve(filePath), "utf8")) as Record<string, 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 (inline) {
return JSON.parse(inline) as Record<string, unknown>;
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;
}
12 changes: 10 additions & 2 deletions packages/core/src/solvers/interval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}

Expand Down Expand Up @@ -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);
Expand Down
79 changes: 78 additions & 1 deletion packages/core/src/solvers/solvers.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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", () => {
Expand Down
21 changes: 18 additions & 3 deletions packages/core/src/static-verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion website/public/install
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
Expand Down
2 changes: 1 addition & 1 deletion website/public/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
Expand Down
4 changes: 2 additions & 2 deletions website/src/content/docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
Loading