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
11 changes: 4 additions & 7 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
## 2026-05-16 - Command Injection via JSON.stringify in spawnSync
**Vulnerability:** A command injection vulnerability existed in `defaultCommandPath` where `JSON.stringify(command)` was directly interpolated into a shell command string: `spawnSync("sh", ["-c", \`command -v \${JSON.stringify(command)} 2>/dev/null\`])`.
**Learning:** `JSON.stringify` produces double-quoted strings. In bash, double-quoted strings are subject to variable expansion and command substitution (e.g., `$(...)` or backticks). An attacker controlling the `command` input could inject arbitrary shell commands, which would be executed when the shell evaluates the double-quoted string.
**Prevention:** Never interpolate unsanitized user input directly into a shell command string. Instead, use shell positional parameters (`$1`, `$2`, etc.) and pass the input as arguments to the shell interpreter: `spawnSync("sh", ["-c", 'command -v "$1" 2>/dev/null', "sh", command])`.
**Vulnerability:** The PR description lacked a Developer Certificate of Origin (DCO) sign-off line, which is required by the repository's CI pipeline to ensure contributions are properly authorized.
**Learning:** The DCO check validates the PR description text directly, rather than just inspecting git commit messages. It strictly requires the pattern `Signed-off-by: Your Name <your-email@example.com>`. Even if the commit message itself contains a sign-off, the automated system evaluating the PR body expects the explicit line to be present in the markdown body.
**Prevention:** Ensure that the DCO sign-off line is included in the PR description whenever submitting code. It's often necessary to provide it in both the commit message and the PR body depending on the repository's automation setup.
## 2026-05-17 - [Command Injection]
**Vulnerability:** Shell Command Injection in SSH Remote Execution
**Learning:** `child_process.exec` passes commands to a shell (`/bin/sh -c` on Unix), meaning shell metacharacters in constructed command strings are evaluated by the local host running the agent/cli. This allowed an attacker or malicious config to inject commands (e.g. `; touch /tmp/pwned`) into the remote execution SSH string, causing local command execution.
**Prevention:** Use `child_process.execFile` (or `spawn`) and pass arguments as an array instead of a concatenated string. This skips local shell evaluation entirely, ensuring arguments are sent directly to the `ssh` binary.
114 changes: 114 additions & 0 deletions src/lib/control-plane/remote-execution.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { createHash } from "crypto";
import { exec, execFile } from "child_process";
import { promisify } from "util";
import { evaluatePolicy, type PolicyBundle } from "./governance";
import type { DeviceRegistry } from "./device-registry";
import { type OperationalEvent, type OperationalMemoryLog, buildEventsFromReceipt } from "./operational-memory";
Expand All @@ -24,6 +27,9 @@
validateRemoteUrl,
} from "../security/security-policy";

const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);

export type RemoteExecutionStatus = "disabled" | "policy_denied" | "approval_required" | "authorization_denied" | "unavailable" | "degraded" | "failed" | "succeeded" | "not_supported";
export interface RemoteExecutionRequest { requestId: string; nowIso: string; action: string; command: string; commandDescriptor?: CommandDescriptor; nodeId?: string; targetEndpoint?: string; auth?: { headerName?: string; token?: string }; approved?: boolean; timeoutMs?: number; executionPlanRequired?: boolean; commandPolicy?: CommandExecutionPolicy; }
export interface RemoteExecutionResult { status: RemoteExecutionStatus; output?: string; degradedReason?: string; errorCode?: string; receipt: ExecutionReceipt; events: OperationalEvent[]; replayRef: ExecutionReceipt["provenance"]; }
Expand Down Expand Up @@ -51,6 +57,114 @@
return redactSecurityPayload({ [auth.headerName]: auth.token ?? "<present>" }) as Record<string, string>;
}

export function computeOutputHash(output: string): string {
return createHash("sha256").update(output).digest("hex");
}

export function verifyResultHash(output: string, expectedHash: string): boolean {
if (!expectedHash) return true;
return computeOutputHash(output) === expectedHash;
}

export async function executeSshCommand(credentials: SshCredentials, command: string, timeoutMs: number): Promise<{ stdout: string; stderr: string; exitCode: number }> {

Check failure on line 69 in src/lib/control-plane/remote-execution.ts

View workflow job for this annotation

GitHub Actions / cli-parity

Cannot find name 'SshCredentials'.

Check failure on line 69 in src/lib/control-plane/remote-execution.ts

View workflow job for this annotation

GitHub Actions / verify

Cannot find name 'SshCredentials'.

Check failure on line 69 in src/lib/control-plane/remote-execution.ts

View workflow job for this annotation

GitHub Actions / checks

Cannot find name 'SshCredentials'.

Check failure on line 69 in src/lib/control-plane/remote-execution.ts

View workflow job for this annotation

GitHub Actions / macos-e2e

Cannot find name 'SshCredentials'.
const sshArgs = [
"-o", "StrictHostKeyChecking=no",
"-o", `ConnectTimeout=${Math.max(1, Math.floor(timeoutMs / 1000))}`,
"-o", "BatchMode=yes",
"-p", String(credentials.port),
];
if (credentials.privateKeyPath) {
sshArgs.push("-i", credentials.privateKeyPath);
}
sshArgs.push(`${credentials.user}@${credentials.host}`);
sshArgs.push(command);

try {
const { stdout, stderr } = await execFileAsync("ssh", sshArgs, { timeout: timeoutMs });
return { stdout, stderr, exitCode: 0 };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
const isTimeout = msg.toLowerCase().includes("timeout") || msg.toLowerCase().includes("timed out");
const codeMatch = msg.match(/exit code (\d+)/);
const exitCode = codeMatch ? parseInt(codeMatch[1], 10) : (isTimeout ? 124 : 1);
const stderr = msg;
const stdout = "";
return { stdout, stderr, exitCode };
}
}

export async function executeSignedHttps(endpoint: string, command: string, timeoutMs: number, signingKey: string): Promise<{ status: number; body: string }> {
const payload = JSON.stringify({ command, timestamp: Date.now() });
const signature = createHash("sha256").update(signingKey + payload).digest("hex");

let url: URL;
try {
url = new URL(endpoint);
} catch {
return { status: 500, body: JSON.stringify({ error: "invalid_endpoint_url" }) };
}
url.searchParams.set("sig", signature);

try {
const response = await fetch(url.toString(), {
method: "POST",
headers: { "Content-Type": "application/json", "X-Signature": signature },
body: payload,
signal: AbortSignal.timeout(timeoutMs),
});
const body = await response.text();
return { status: response.status, body };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
const isTimeout = msg.toLowerCase().includes("timeout") || msg.toLowerCase().includes("timed out") || msg.toLowerCase().includes("abort");
return { status: isTimeout ? 408 : 500, body: JSON.stringify({ error: msg }) };
}
}

export async function executeRemoteWorkerProof(config: RemoteWorkerProofConfig, command: string): Promise<RemoteWorkerProofResult> {

Check failure on line 124 in src/lib/control-plane/remote-execution.ts

View workflow job for this annotation

GitHub Actions / cli-parity

Cannot find name 'RemoteWorkerProofResult'.

Check failure on line 124 in src/lib/control-plane/remote-execution.ts

View workflow job for this annotation

GitHub Actions / cli-parity

Cannot find name 'RemoteWorkerProofConfig'.

Check failure on line 124 in src/lib/control-plane/remote-execution.ts

View workflow job for this annotation

GitHub Actions / verify

Cannot find name 'RemoteWorkerProofResult'.

Check failure on line 124 in src/lib/control-plane/remote-execution.ts

View workflow job for this annotation

GitHub Actions / verify

Cannot find name 'RemoteWorkerProofConfig'.

Check failure on line 124 in src/lib/control-plane/remote-execution.ts

View workflow job for this annotation

GitHub Actions / checks

Cannot find name 'RemoteWorkerProofResult'.

Check failure on line 124 in src/lib/control-plane/remote-execution.ts

View workflow job for this annotation

GitHub Actions / checks

Cannot find name 'RemoteWorkerProofConfig'.

Check failure on line 124 in src/lib/control-plane/remote-execution.ts

View workflow job for this annotation

GitHub Actions / macos-e2e

Cannot find name 'RemoteWorkerProofResult'.

Check failure on line 124 in src/lib/control-plane/remote-execution.ts

View workflow job for this annotation

GitHub Actions / macos-e2e

Cannot find name 'RemoteWorkerProofConfig'.
const start = Date.now();

if (config.transportType === "ssh") {
const creds = config.credentials as SshCredentials;

Check failure on line 128 in src/lib/control-plane/remote-execution.ts

View workflow job for this annotation

GitHub Actions / cli-parity

Cannot find name 'SshCredentials'.

Check failure on line 128 in src/lib/control-plane/remote-execution.ts

View workflow job for this annotation

GitHub Actions / verify

Cannot find name 'SshCredentials'.

Check failure on line 128 in src/lib/control-plane/remote-execution.ts

View workflow job for this annotation

GitHub Actions / checks

Cannot find name 'SshCredentials'.

Check failure on line 128 in src/lib/control-plane/remote-execution.ts

View workflow job for this annotation

GitHub Actions / macos-e2e

Cannot find name 'SshCredentials'.
if (!creds.host || !creds.user) {
return { success: false, output: "", outputHash: "", hashMatches: false, durationMs: Date.now() - start, error: "missing_ssh_credentials" };
}
const result = await executeSshCommand(creds, command, config.timeoutMs);
const output = result.exitCode === 0 ? result.stdout : result.stderr;
const outputHash = computeOutputHash(output);
const hashMatches = config.expectedOutputHash ? outputHash === config.expectedOutputHash : true;
return {
success: result.exitCode === 0 && hashMatches,
output,
outputHash,
hashMatches,
durationMs: Date.now() - start,
error: result.exitCode !== 0 ? `exit_code_${result.exitCode}` : !hashMatches ? "hash_mismatch" : undefined,
};
}

if (config.transportType === "https-signed") {
const signedCreds = config.credentials as { endpoint: string; signingKey: string };
if (!signedCreds.endpoint || !signedCreds.signingKey) {
return { success: false, output: "", outputHash: "", hashMatches: false, durationMs: Date.now() - start, error: "missing_signed_https_credentials" };
}
const response = await executeSignedHttps(signedCreds.endpoint, command, config.timeoutMs, signedCreds.signingKey);
const output = response.body;
const outputHash = computeOutputHash(output);
const hashMatches = config.expectedOutputHash ? outputHash === config.expectedOutputHash : true;
return {
success: response.status >= 200 && response.status < 300 && hashMatches,
output,
outputHash,
hashMatches,
durationMs: Date.now() - start,
error: response.status < 200 || response.status >= 300 ? `http_${response.status}` : !hashMatches ? "hash_mismatch" : undefined,
};
}

return { success: false, output: "", outputHash: "", hashMatches: false, durationMs: Date.now() - start, error: "unsupported_transport" };
}

export async function runRemoteExecution(input: { request: RemoteExecutionRequest; config: RemoteExecutionConfig; transport: RemoteExecutionTransport; policyBundle: PolicyBundle; registry: DeviceRegistry; operationalMemory?: OperationalMemoryLog; executionPlan?: ExecutionPlan; executionApproval?: ExecutionApproval }): Promise<RemoteExecutionResult> {
const { request } = input;
const target = request.nodeId ?? request.targetEndpoint ?? "unresolved";
Expand Down
Loading