Skip to content
Merged
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
227 changes: 126 additions & 101 deletions docs/security-model.md

Large diffs are not rendered by default.

46 changes: 40 additions & 6 deletions packages/autonav/src/harness/claude-code-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { query, tool, createSdkMcpServer, type Query, type SDKMessage, type SDKR
import * as os from "node:os";
import type { Harness, HarnessSession, AgentConfig, AgentEvent } from "./types.js";
import type { ToolDefinition } from "./tool-server.js";
import { resolveSandboxProvider, createSdkWrapper, buildNonoFlags } from "./sandbox.js";
import { resolveSandboxProvider, createSdkWrapper, buildNonoFlags, writeNonoFlagsFile } from "./sandbox.js";

/**
* Flatten an SDK message into zero or more AgentEvents.
Expand Down Expand Up @@ -106,6 +106,42 @@ function flattenMessage(message: SDKMessage): AgentEvent[] {
return events;
}

/** Environment variables the Claude Code subprocess needs. Everything else is stripped. */
const ALLOWED_ENV_VARS = new Set([
// System
"PATH", "HOME", "USER", "SHELL", "TERM", "LANG", "LC_ALL", "LC_CTYPE",
"TMPDIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME",
// macOS
"DEVELOPER_DIR", "SDKROOT",
// Anthropic API (the subprocess needs this to authenticate)
"ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL",
// Claude Code
"CLAUDE_CODE_MAX_MEMORY",
// Autonav internals
"AUTONAV_DEBUG", "AUTONAV_SANDBOX", "AUTONAV_QUERY_DEPTH",
"AUTONAV_METRICS", "AUTONAV_HARNESS",
// Git
"GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL",
// Node
"NODE_PATH", "NODE_ENV",
]);

function buildCleanEnv(extra: Record<string, string> = {}): Record<string, string | undefined> {
const env: Record<string, string | undefined> = {};
for (const key of ALLOWED_ENV_VARS) {
if (process.env[key] !== undefined) {
env[key] = process.env[key];
}
}
// Forward AUTONAV_NAV_PATH_* vars (needed for related-nav resolution)
for (const [key, value] of Object.entries(process.env)) {
if (key.startsWith("AUTONAV_NAV_PATH_") && value !== undefined) {
env[key] = value;
}
}
return { ...env, ...extra };
}

/**
* Map AgentConfig to Claude Code SDK Options
*/
Expand All @@ -132,19 +168,17 @@ function configToSdkOptions(config: AgentConfig): Record<string, unknown> {

if (sandboxResolution.provider === "nono" && sandboxResolution.active && config.sandbox) {
// nono: kernel-enforced sandbox via wrapper script.
// Uses --profile claude-code as base + navigator paths via NONO_FLAGS.
// Uses --profile claude-code as base + navigator flags from a temp file.
const wrapperDir = os.tmpdir();
if (config.stderr) {
config.stderr(`[nono] SandboxConfig: ${JSON.stringify({ provider: "nono", readPaths: config.sandbox.readPaths, writePaths: config.sandbox.writePaths, allowedCommands: config.sandbox.allowedCommands })}\n`);
}
const wrapperPath = createSdkWrapper("", wrapperDir, config.sandbox);
const nonoFlags = buildNonoFlags(config.sandbox);
const flagsFilePath = writeNonoFlagsFile(nonoFlags, wrapperDir);

options.pathToClaudeCodeExecutable = wrapperPath;
options.env = {
...process.env,
NONO_FLAGS: nonoFlags,
};
options.env = buildCleanEnv({ NONO_FLAGS_FILE: flagsFilePath });
// Disable SDK sandbox — nono is the security boundary.
options.sandbox = { enabled: false };
} else if (sandboxResolution.provider === "claude-code" && sandboxResolution.active) {
Expand Down
3 changes: 3 additions & 0 deletions packages/autonav/src/harness/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export {
type ToolResult,
} from "./tool-server.js";

export { buildSandboxConfigForOperation } from "./sandbox-config-builder.js";

export {
createEphemeralHome,
type EphemeralHome,
Expand All @@ -60,6 +62,7 @@ export {
writeProfile,
createSdkWrapper,
buildNonoFlags,
writeNonoFlagsFile,
buildCapabilitySet,
querySandbox,
querySandboxNetwork,
Expand Down
70 changes: 70 additions & 0 deletions packages/autonav/src/harness/sandbox-config-builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Shared sandbox config builder.
*
* Extracts the per-operation sandbox config building logic that was
* duplicated across navigator-adapter.ts and nav-chat.ts into a
* single function. Also used by cross-nav and related-navs tools
* to ensure sub-sessions inherit the target navigator's sandbox.
*/

import type { NavigatorConfig } from "@autonav/communication-layer";
import type { SandboxConfig, SandboxProvider } from "./types.js";

type Operation = "query" | "update" | "chat" | "standup" | "memento";

/**
* Build a SandboxConfig for a specific operation on a navigator.
*
* Merges top-level permissions with per-operation profile settings.
* Returns undefined if sandbox is disabled for this operation/provider.
*/
export function buildSandboxConfigForOperation(
navConfig: NavigatorConfig,
navigatorPath: string,
knowledgeBasePath: string,
operation: Operation,
): SandboxConfig | undefined {
// Resolve provider
const sandboxSection = navConfig.sandbox;
const provider: SandboxProvider = sandboxSection?.dangerouslyDisableSandbox
? "none"
: (sandboxSection?.provider ?? "nono");

if (provider === "none") return undefined;

// Check per-operation enable flag
const profile = sandboxSection?.[operation] as
| { enabled?: boolean; accessLevel?: string; blockNetwork?: boolean; allowedCommands?: string[]; extraReadPaths?: string[]; extraWritePaths?: string[] }
| undefined;
if (profile?.enabled === false) return undefined;

// Merge top-level + per-operation permissions
const topCommands = navConfig.permissions?.allowedCommands ?? [];
const opCommands = profile?.allowedCommands ?? [];
const allCommands = [...topCommands, ...opCommands];

const topPaths = navConfig.permissions?.allowedPaths ?? [];
const opReadPaths = profile?.extraReadPaths ?? [];
const opWritePaths = profile?.extraWritePaths ?? [];

const readPaths = [
navigatorPath,
knowledgeBasePath,
...topPaths,
...opReadPaths,
];

// Only grant write if accessLevel is "readwrite"
const writePaths = profile?.accessLevel === "readwrite"
? [navigatorPath, ...opWritePaths]
: undefined;

return {
enabled: true,
provider,
readPaths,
writePaths,
allowedCommands: allCommands.length > 0 ? allCommands : undefined,
blockNetwork: profile?.blockNetwork,
};
}
69 changes: 41 additions & 28 deletions packages/autonav/src/harness/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
*/

import { createRequire } from "node:module";
import * as crypto from "node:crypto";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
Expand Down Expand Up @@ -217,14 +218,17 @@ export function resolveSandboxProvider(config?: SandboxConfig): { provider: Sand
/**
* Resolve whether sandboxing should be active for this session.
*
* For backward compatibility — wraps resolveSandboxProvider and catches
* errors (returns false if nono is required but missing). Prefer using
* resolveSandboxProvider directly for better error handling.
* @deprecated Use resolveSandboxProvider() directly for proper error handling.
* This function silently returns false when nono is required but missing,
* which can lead to undetected sandbox bypass. Will be removed in a future version.
*/
export function isSandboxEnabled(config?: SandboxConfig): boolean {
try {
return resolveSandboxProvider(config).active;
} catch {
} catch (e) {
process.stderr.write(
`[autonav] WARNING: Sandbox check failed, running without sandbox: ${e instanceof Error ? e.message : String(e)}\n`
);
return false;
}
}
Expand Down Expand Up @@ -394,18 +398,14 @@ function commandFlags(config?: SandboxConfig): string[] {
}

/**
* Build nono CLI flags string for env var passing (NONO_FLAGS).
* Build nono CLI flags as an array.
*
* Used by ClaudeCodeHarness where the wrapper script reads NONO_FLAGS
* from the environment (trellis pattern).
* Returns individual flag strings — each is a single argument to nono.
* Use writeNonoFlagsFile() to serialize safely for the wrapper script.
*/
export function buildNonoFlags(config: SandboxConfig): string {
export function buildNonoFlags(config: SandboxConfig): string[] {
const parts: string[] = ["--allow-cwd"];

// Navigator-specific paths as --read/--allow flags.
// These stack with --profile claude-code's base paths.
// We can't use --config/profile JSON because nono-ts 0.3.0 format
// is incompatible with nono CLI 0.15.0's profile parser.
if (config.readPaths) {
for (const p of config.readPaths) {
if (fs.existsSync(p)) parts.push("--read", p);
Expand All @@ -423,7 +423,20 @@ export function buildNonoFlags(config: SandboxConfig): string {
parts.push("--net-block");
}

return parts.join(" ");
return parts;
}

/**
* Write nono flags to a temp file (one per line) for safe shell consumption.
*
* Avoids shell injection from unquoted env var expansion — the wrapper
* script reads this file line-by-line instead of word-splitting a string.
*/
export function writeNonoFlagsFile(flags: string[], dir: string): string {
const id = crypto.randomUUID().slice(0, 8);
const flagsPath = path.join(dir, `nono-flags-${id}.txt`);
fs.writeFileSync(flagsPath, flags.join("\n") + "\n", "utf-8");
return flagsPath;
}

/**
Expand Down Expand Up @@ -477,36 +490,36 @@ export function wrapCommand(
* @returns Absolute path to the wrapper script.
*/
export function createSdkWrapper(_profilePath: string, dir: string, _config?: SandboxConfig): string {
const wrapperPath = path.join(dir, "nono-claude-wrapper.sh");
// Use nono's built-in claude-code profile as a base — it provides all
// the paths claude needs (config, keychain, tmp dirs, etc.) and is
// maintained by the nono team. Our custom --config adds navigator-specific
// paths on top, and NONO_FLAGS adds --allow-command flags.
//
// Use --profile claude-code as base, then NONO_FLAGS adds navigator
// paths as --read/--allow flags + --allow-command flags.
// We can't use a profile JSON file because nono-ts 0.3.0's JSON format
// is not compatible with nono CLI 0.15.0's profile parser (custom fs
// entries are silently ignored). --read/--allow flags DO stack with
// --profile, so we pass everything via NONO_FLAGS.
const id = crypto.randomUUID().slice(0, 12);
const wrapperPath = path.join(dir, `nono-claude-wrapper-${id}.sh`);
// Use nono's built-in claude-code profile as base + navigator flags
// from a temp file (NONO_FLAGS_FILE). Flags are read line-by-line to
// avoid shell injection from unquoted env var expansion.
//
// Pre-create optional dirs that claude-code profile references to
// suppress WARN messages on stdout (corrupts SDK JSON stream).
//
// Uses `while read` instead of `mapfile` for macOS system bash (3.2) compat.
const script = `#!/usr/bin/env bash
set -euo pipefail
mkdir -p "\${HOME}/.vscode" 2>/dev/null || true
mkdir -p "\${HOME}/Library/Application Support/Code" 2>/dev/null || true
touch "\${HOME}/.gitignore_global" 2>/dev/null || true
NONO_ARGS=()
if [[ -n "\${NONO_FLAGS_FILE:-}" && -f "\${NONO_FLAGS_FILE}" ]]; then
while IFS= read -r line; do
[[ -n "\$line" ]] && NONO_ARGS+=("\$line")
done < "\${NONO_FLAGS_FILE}"
fi
exec nono run \\
--silent \\
--no-diagnostics \\
--profile claude-code \\
--allow "\${HOME}/.claude-personal" \\
\${NONO_FLAGS:-} \\
"\${NONO_ARGS[@]}" \\
-- claude "$@"
`;
fs.writeFileSync(wrapperPath, script, "utf-8");
fs.chmodSync(wrapperPath, 0o755);
fs.writeFileSync(wrapperPath, script, { mode: 0o700, encoding: "utf-8" });
return wrapperPath;
}

Expand Down
11 changes: 9 additions & 2 deletions packages/autonav/src/tools/cross-nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

import { z } from "zod";
import { loadNavigator } from "../query-engine/navigator-loader.js";
import { type Harness, collectText, defineTool } from "../harness/index.js";
import { type Harness, collectText, defineTool, buildSandboxConfigForOperation } from "../harness/index.js";

const MAX_QUERY_DEPTH = 3;

Expand Down Expand Up @@ -64,13 +64,20 @@ Specify the navigator by its directory path (relative or absolute).`,
// Load target navigator
const nav = loadNavigator(args.navigator);

// Run query via harness
// Build sandbox config from target navigator's config
const sandboxConfig = buildSandboxConfigForOperation(
nav.config, nav.navigatorPath, nav.knowledgeBasePath, "query"
);

// Run query via harness — inherit target nav's sandbox
const session = harness.run(
{
model: "claude-haiku-4-5",
maxTurns: 10,
systemPrompt: nav.systemPrompt,
cwd: nav.navigatorPath,
disallowedTools: ["Write", "Edit", "NotebookEdit"],
...(sandboxConfig ? { sandbox: sandboxConfig } : {}),
},
args.question
);
Expand Down
9 changes: 8 additions & 1 deletion packages/autonav/src/tools/related-navs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import { z } from "zod";
import { loadNavigator } from "../query-engine/navigator-loader.js";
import { resolveNavigatorPath } from "../registry.js";
import { type Harness, collectText, defineTool, type ToolDefinition } from "../harness/index.js";
import { type Harness, collectText, defineTool, type ToolDefinition, buildSandboxConfigForOperation } from "../harness/index.js";

const MAX_QUERY_DEPTH = 3;

Expand Down Expand Up @@ -86,12 +86,19 @@ export function createRelatedNavsMcpServer(
try {
const target = loadNavigator(navPath);

// Build sandbox config from target navigator's config
const sandboxConfig = buildSandboxConfigForOperation(
target.config, target.navigatorPath, target.knowledgeBasePath, "query"
);

const session = harness.run(
{
model: "claude-haiku-4-5",
maxTurns: 10,
systemPrompt: target.systemPrompt,
cwd: target.navigatorPath,
disallowedTools: ["Write", "Edit", "NotebookEdit"],
...(sandboxConfig ? { sandbox: sandboxConfig } : {}),
},
args.question
);
Expand Down
1 change: 1 addition & 0 deletions packages/communication-layer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export {
export {
NavigatorConfigSchema,
KnowledgePackMetadataSchema,
DENIED_SANDBOX_COMMANDS,
type NavigatorConfig,
type KnowledgePackMetadata,
createNavigatorConfig,
Expand Down
Loading
Loading