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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**/node_modules
23 changes: 23 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ RUN apk add --no-cache \
ca-certificates \
git \
openssh-client \
ripgrep \
socat \
bubblewrap \
tmux

# Install mise (GPG-verified via mise-release.asc).
Expand Down Expand Up @@ -78,6 +81,26 @@ if ! grep -q "^[^:]*:[^:]*:$(id -u):" /etc/passwd; then
"$(id -u)" "$(id -g)" "${HOME}" >> /etc/passwd
fi

# Copy host extensions (mounted read-only at /host-extensions) into the
# tmpfs at /pi-agent/extensions, then install node_modules. Native deps
# compile in the container; host extensions are never modified. The source
# is outside /pi-agent so Docker's mount-point mkdir doesn't leak an empty
# host-extensions directory back to the host via the /pi-agent bind.
mkdir -p /pi-agent/extensions
if [ -d "/host-extensions" ]; then
cp -r /host-extensions/. /pi-agent/extensions/
fi
# --loglevel=error silences builtin-config deprecation and transitive-dep
# deprecation warnings that come from pi's deps / the base image's npmrc
# and are out of our hands. --no-audit / --no-fund drop the per-startup
# vulnerability and funding summaries. Real install failures still fail
# the entrypoint under `set -e`.
for ext in /pi-agent/extensions/*/; do
[ -d "${ext}node_modules" ] && rm -rf "${ext}node_modules"
[ -f "${ext}package.json" ] && \
(cd "${ext}" && npm install --loglevel=error --no-audit --no-fund)
done

# Pass through to a shell when invoked via `pi:shell`; otherwise run pi.
case "${1:-}" in
bash|sh) exec "$@" ;;
Expand Down
35 changes: 35 additions & 0 deletions extensions/security.jsonc.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
// Security extension config. Copy to ~/.pi/agent/extensions/security.jsonc
// and edit. The file is read in two places:
// 1. Inside the container by the security extension β†’ enforces denyRead,
// denyWrite, bash.deny on the agent's tool calls.
// 2. On the host by tasks/pi/_docker_flags before the container starts β†’
// applies mountMask and isolateDirs as Docker bind mounts.
"enabled": true,

"filesystem": {
// In-container rules. Matched against the path argument of read-ish and
// write-ish tools. Globs (*, **) and "re:<regex>" are supported.
"denyRead": ["~/.ssh/**", "~/.aws/**", "~/.gnupg/**", ".env", ".env.*"],
"denyWrite": [".env", ".env.*", "*.pem", "*.key", "id_rsa", "id_ed25519"],

// Host-enforced: bind /dev/null over each listed path (relative to the
// project root) so nothing inside the container β€” bash, scripts, child
// processes β€” can read the real file. Use this for secrets that bash
// would otherwise bypass denyRead for. To allow a specific env file,
// keep it outside the mask list (e.g., commit an ".env.safe" with only
// non-sensitive values and reference that from your scripts).
"mountMask": [".env", ".env.local", ".env.production"],

// Host-enforced: per-project overlay dirs. The $(pwd) bind mount would
// otherwise leak host-built native binaries (macOS β†’ Linux) into the
// container. Each entry is shadowed by a fresh Linux tree under
// ~/.pi/agent/overlays/<project-hash>/<entry>. Leave empty to disable.
"isolateDirs": ["node_modules", ".venv"]
},

"bash": {
// Matched against the full command string. Globs and "re:<regex>" work.
"deny": ["sudo", "re:>\\s*/dev/sd[a-z]", "mkfs"]
}
}
162 changes: 162 additions & 0 deletions extensions/security/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { existsSync, readFileSync } from "node:fs";
import { join as joinPath } from "node:path";

// mountMask and isolateDirs are host-side only: the container runtime reads
// them before pi starts. The extension parses them so the schema stays
// accurate and writers get feedback, but never acts on them at runtime.
export interface SecurityConfig {
enabled: boolean;
filesystem: {
denyRead: string[];
denyWrite: string[];
mountMask: string[];
isolateDirs: string[];
};
bash: { deny: string[] };
}

export interface PartialConfig {
enabled: boolean | undefined;
filesystem: {
denyRead: string[];
denyWrite: string[];
mountMask: string[];
isolateDirs: string[];
};
bash: { deny: string[] };
}

const EMPTY_PARTIAL: PartialConfig = {
enabled: undefined,
filesystem: { denyRead: [], denyWrite: [], mountMask: [], isolateDirs: [] },
bash: { deny: [] },
};

function clone(p: PartialConfig): PartialConfig {
return {
enabled: p.enabled,
filesystem: {
denyRead: [...p.filesystem.denyRead],
denyWrite: [...p.filesystem.denyWrite],
mountMask: [...p.filesystem.mountMask],
isolateDirs: [...p.filesystem.isolateDirs],
},
bash: { deny: [...p.bash.deny] },
};
}

function asStringArray(v: unknown): string[] {
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
}

// Missing file β†’ empty partial. Parse error β†’ warn + empty. Never throws.
export function loadFile(path: string): PartialConfig {
if (!existsSync(path)) return clone(EMPTY_PARTIAL);
let raw: string;
try {
raw = readFileSync(path, "utf-8");
} catch (err) {
console.warn(`[security] failed to read ${path}: ${(err as Error).message}`);
return clone(EMPTY_PARTIAL);
}
const src = path.endsWith(".jsonc") ? stripJsonComments(raw) : raw;
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(src) as Record<string, unknown>;
} catch (err) {
console.warn(`[security] failed to parse ${path}: ${(err as Error).message}`);
return clone(EMPTY_PARTIAL);
}
const fs = (parsed.filesystem ?? {}) as Record<string, unknown>;
const bash = (parsed.bash ?? {}) as Record<string, unknown>;
return {
enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : undefined,
filesystem: {
denyRead: asStringArray(fs.denyRead),
denyWrite: asStringArray(fs.denyWrite),
mountMask: asStringArray(fs.mountMask),
isolateDirs: asStringArray(fs.isolateDirs),
},
bash: { deny: asStringArray(bash.deny) },
};
}

export interface LoadOptions {
// Directory that holds security.jsonc / security.json. Typically the
// extensions root (the parent of this extension's package folder).
dir: string;
}

// Tries security.jsonc first, falls back to security.json. Returns the
// first candidate (even if absent) so callers can show the expected path.
export function resolveConfigPath(dir: string): string {
const candidates = [joinPath(dir, "security.jsonc"), joinPath(dir, "security.json")];
return candidates.find(existsSync) ?? candidates[0];
}

// Loads a single security.{jsonc,json} from `dir`. Missing file β†’ EMPTY defaults.
export function loadConfig(opts: LoadOptions): SecurityConfig {
const p = loadFile(resolveConfigPath(opts.dir));
return {
enabled: p.enabled ?? true,
filesystem: {
denyRead: [...p.filesystem.denyRead],
denyWrite: [...p.filesystem.denyWrite],
mountMask: [...p.filesystem.mountMask],
isolateDirs: [...p.filesystem.isolateDirs],
},
bash: { deny: [...p.bash.deny] },
};
}

export const EMPTY: SecurityConfig = {
enabled: true,
filesystem: { denyRead: [], denyWrite: [], mountMask: [], isolateDirs: [] },
bash: { deny: [] },
};

// Walks the input char by char, toggling an in-string flag so // and /* */ inside JSON strings stay intact.
export function stripJsonComments(src: string): string {
let out = "";
let i = 0;
let inStr = false;
let strQuote = "";
while (i < src.length) {
const c = src[i];
const n = src[i + 1];
if (inStr) {
out += c;
if (c === "\\" && i + 1 < src.length) {
out += src[i + 1];
i += 2;
continue;
}
if (c === strQuote) {
inStr = false;
strQuote = "";
}
i++;
continue;
}
if (c === '"' || c === "'") {
inStr = true;
strQuote = c;
out += c;
i++;
continue;
}
if (c === "/" && n === "/") {
while (i < src.length && src[i] !== "\n") i++;
continue;
}
if (c === "/" && n === "*") {
i += 2;
while (i < src.length && !(src[i] === "*" && src[i + 1] === "/")) i++;
i += 2;
continue;
}
out += c;
i++;
}
return out;
}
123 changes: 123 additions & 0 deletions extensions/security/match.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { basename, isAbsolute, join as joinPath, resolve } from "node:path";
import { homedir } from "node:os";

export interface PathMatch {
matched: boolean;
rule?: string;
}

// Supports *, **, ?. All other regex metachars are escaped.
export function globToRegex(glob: string): RegExp {
const escaped = glob
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*\*/g, "\x00")
.replace(/\*/g, "[^/]*")
.replace(/\?/g, "[^/]")
.replace(/\x00/g, ".*");
return new RegExp(`^${escaped}$`);
}

function expandTilde(p: string): string {
return p === "~" ? homedir() : p.startsWith("~/") ? joinPath(homedir(), p.slice(2)) : p;
}

export function matchesPath(
target: string,
patterns: string[],
opts: { cwd?: string } = {},
): PathMatch {
const cwd = opts.cwd ?? process.cwd();
const absTarget = isAbsolute(target) ? target : resolve(cwd, target);
const base = basename(absTarget);

for (const pattern of patterns) {
const expanded = expandTilde(pattern);
const isBasenameOnly = !expanded.includes("/") || /^[^/]+\/$/.test(expanded);

if (isBasenameOnly) {
const trimmed = expanded.replace(/\/$/, "");
const re = globToRegex(trimmed);
// Trailing-slash basename pattern (e.g. "node_modules/"): gitignore semantics β€”
// match the dir and everything under it. Check every path segment.
const isDirOnly = expanded.endsWith("/");
const segments = isDirOnly ? absTarget.split("/").filter(Boolean) : [base];
let matched = false;
for (const seg of segments) {
if (re.test(seg)) {
matched = true;
break;
}
}
if (matched) return { matched: true, rule: pattern };
continue;
}

const isDirPattern = expanded.endsWith("/");
const abs = isAbsolute(expanded) ? expanded : resolve(cwd, expanded);

if (isDirPattern) {
const literal = abs.replace(/\/$/, "").replace(/[.+^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`^${literal}(/.*)?$`);
if (re.test(absTarget)) return { matched: true, rule: pattern };
} else {
if (globToRegex(abs).test(absTarget)) return { matched: true, rule: pattern };
}
}
return { matched: false };
}

export interface BashMatch {
matched: boolean;
rule?: string;
}

function tokens(s: string): string[] {
return s.trim().split(/\s+/).filter(Boolean);
}

function tokenPrefixMatch(segment: string, patternTokens: string[]): boolean {
const cmd = tokens(segment);
if (patternTokens.length === 0 || cmd.length < patternTokens.length) return false;
for (let i = 0; i < patternTokens.length; i++) {
if (cmd[i] !== patternTokens[i]) return false;
}
return true;
}

// Best-effort splitter. Splits on |, ||, &&, ;, and extracts $(...) contents as extra segments.
// Not a real shell parser β€” quoted strings may tokenize imperfectly; v1 limitation.
export function splitCommand(cmd: string): string[] {
const subshells: string[] = [];
const withoutSubshells = cmd.replace(/\$\(([^)]*)\)/g, (_, inner: string) => {
subshells.push(inner);
return " ";
});
const segments: string[] = [];
segments.push(...withoutSubshells.split(/\s*(?:\|{1,2}|&&|;)\s*/));
segments.push(...subshells);
return segments.map((s) => s.trim()).filter(Boolean);
}

export function matchesBash(command: string, patterns: string[]): BashMatch {
const segments = splitCommand(command);
for (const pattern of patterns) {
if (pattern.startsWith("re:")) {
const src = pattern.slice(3);
let re: RegExp;
try {
re = new RegExp(src);
} catch (err) {
console.warn(`[security] invalid regex in bash rule ${JSON.stringify(pattern)}: ${(err as Error).message}`);
continue;
}
if (re.test(command)) return { matched: true, rule: pattern };
continue;
}
const patTokens = tokens(pattern);
if (patTokens.length === 0) continue;
for (const seg of segments) {
if (tokenPrefixMatch(seg, patTokens)) return { matched: true, rule: pattern };
}
}
return { matched: false };
}
Loading
Loading