From 0073c0515131859cc9d47eade03443c061c8de33 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 14 Sep 2026 15:29:09 -0400 Subject: [PATCH] fix: preserve trusted jq path for PTC --- packages/code/src/native-process.test.ts | 14 ++++++++++-- packages/code/src/native-process.ts | 11 +++++----- packages/code/src/native-sandbox.ts | 5 +++++ service/src/preamble-bash.test.ts | 8 +++++++ service/src/preamble-bash.ts | 27 ++++++++++++------------ 5 files changed, 45 insertions(+), 20 deletions(-) diff --git a/packages/code/src/native-process.test.ts b/packages/code/src/native-process.test.ts index e96f1e65..2f38d642 100644 --- a/packages/code/src/native-process.test.ts +++ b/packages/code/src/native-process.test.ts @@ -3,7 +3,7 @@ import { EventEmitter } from 'node:events'; import test from 'node:test'; import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import type { ChildProcess, ForkOptions } from 'node:child_process'; import { NativeProcessWorkspaceCommandSandbox, @@ -199,6 +199,7 @@ test('programmatic executor resolves and scopes credentials to its command', asy { workspaceRoot: tmpdir(), programmaticFileUpstream: 'http://127.0.0.1:3190', + environment: { PATH: '/sandbox-only' }, maskedEnvironment: { variables: [{ name: 'TOKEN', injectHosts: ['github.com'] }], async resolve() { @@ -225,9 +226,18 @@ test('programmatic executor resolves and scopes credentials to its command', asy JSON.stringify(fake.options).includes('per-programmatic-secret'), false, ); - const message = fake.messages.find( + const message = fake.messages.find( candidate => candidate.type === 'programmatic', )!; + const prepareMessage = fake.messages.find( + candidate => candidate.type === 'prepare', + )!; + assert.equal(typeof prepareMessage.options.jqPath, 'string'); + assert.equal(prepareMessage.options.jqPath.startsWith('/'), true); + assert.equal( + '/sandbox-only'.split(':').includes(dirname(prepareMessage.options.jqPath)), + false, + ); assert.deepEqual(message.credentials, { TOKEN: 'per-programmatic-secret' }); assert.equal( message.wrappedCommand, diff --git a/packages/code/src/native-process.ts b/packages/code/src/native-process.ts index d2ea0d72..4ed3ffec 100644 --- a/packages/code/src/native-process.ts +++ b/packages/code/src/native-process.ts @@ -61,7 +61,7 @@ export async function trustedProgrammaticExecutable(candidate: string, workspace async function resolveProgrammaticShell( options: NativeProcessSandboxOptions, -): Promise { +): Promise<{ shellPath: string; jqPath: string }> { const environment = options.environment ?? process.env; const shellPath = options.shellPath != null @@ -99,7 +99,7 @@ async function resolveProgrammaticShell( 'COMMAND_UNAVAILABLE', ); } - return shellPath; + return { shellPath, jqPath }; } /** Only OS discovery and conventional proxy settings cross into the executor. @@ -211,9 +211,9 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan } private async start(): Promise { - const programmaticShellPath = this.options.programmaticFileUpstream + const programmaticExecutables = this.options.programmaticFileUpstream ? await resolveProgrammaticShell(this.options) - : this.options.shellPath; + : undefined; const child = this.forkExecutor( new URL('./native-process-child.js', import.meta.url), [], @@ -299,7 +299,8 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan protectedPaths, allowedDomains, homeDirectory, - shellPath: programmaticShellPath ?? shellPath, + shellPath: programmaticExecutables?.shellPath ?? shellPath, + jqPath: programmaticExecutables?.jqPath, programmaticFileUpstream, variables: this.options.maskedEnvironment?.variables, }, diff --git a/packages/code/src/native-sandbox.ts b/packages/code/src/native-sandbox.ts index 8d150d9f..91e9832d 100644 --- a/packages/code/src/native-sandbox.ts +++ b/packages/code/src/native-sandbox.ts @@ -169,6 +169,8 @@ export interface NativeSrtWorkspaceCommandSandboxOptions { platform?: NodeJS.Platform; /** Trusted shell path used by SRT on POSIX hosts. */ shellPath?: string; + /** Trusted jq path used by generated programmatic scripts. */ + jqPath?: string; /** Host-owned credentials exposed only as SRT sentinels inside the sandbox. */ maskedEnvironment?: { variables: Array<{ @@ -702,6 +704,9 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox '_ptc_pending_result.json', ), LIBRECHAT_CODE_BASH_PATH: this.options.shellPath ?? '/bin/bash', + ...(this.options.jqPath + ? { LIBRECHAT_CODE_JQ_PATH: this.options.jqPath } + : {}), PTC_HISTORY_PATH: join( canonicalDataDirectory, '_ptc_history.json', diff --git a/service/src/preamble-bash.test.ts b/service/src/preamble-bash.test.ts index e35a77f5..e110abdc 100644 --- a/service/src/preamble-bash.test.ts +++ b/service/src/preamble-bash.test.ts @@ -186,6 +186,14 @@ printf '%s\\n' "$_PTC_PENDING_FILE" "$_PTC_ERROR_FILE" "$_PTC_COUNTER_FILE" rmSync(dir, { recursive: true, force: true }); } }); + + test('uses the trusted jq path instead of resolving jq through PATH', () => { + const preamble = generateBashReplayPreamble({ executionId, tools }); + expect(preamble).toContain( + '_PTC_JQ_PATH="${LIBRECHAT_CODE_JQ_PATH:-jq}"', + ); + expect(preamble).not.toMatch(/(^|[|;(]\s*)jq\s/m); + }); }); describe('generateBashReplayPreamble - command substitution pending emission', () => { diff --git a/service/src/preamble-bash.ts b/service/src/preamble-bash.ts index c442b495..31ed4c6b 100644 --- a/service/src/preamble-bash.ts +++ b/service/src/preamble-bash.ts @@ -150,6 +150,7 @@ _PTC_SENTINEL_START="${scopedStart}" _PTC_SENTINEL_END="${scopedEnd}" _PTC_HISTORY_PATH="\${PTC_HISTORY_PATH:-${PTC_HISTORY_SANDBOX_PATH}}" _PTC_CONTROL_PATH="\${LIBRECHAT_CODE_CONTROL_PATH:-}" +_PTC_JQ_PATH="\${LIBRECHAT_CODE_JQ_PATH:-jq}" _PTC_RUNTIME_DIR="\${TMPDIR:-/tmp}" _ptc_mktemp() { mktemp "\${_PTC_RUNTIME_DIR%/}/$1.XXXXXX" @@ -222,7 +223,7 @@ _ptc_sha256() { _ptc_hash_input() { local _ptc_canonical - _ptc_canonical=$(printf '%s' "$1" | jq -cS . 2>/dev/null) || return 1 + _ptc_canonical=$(printf '%s' "$1" | "$_PTC_JQ_PATH" -cS . 2>/dev/null) || return 1 printf '%s' "$_ptc_canonical" | _ptc_sha256 } @@ -369,7 +370,7 @@ _ptc_maybe_emit_pending() { return 0 fi local _ptc_payload - if ! _ptc_payload=$(jq -c -s '{pending:.}' "$_PTC_PENDING_FILE" 2>/dev/null); then + if ! _ptc_payload=$("$_PTC_JQ_PATH" -c -s '{pending:.}' "$_PTC_PENDING_FILE" 2>/dev/null); then printf 'failed to serialize pending PTC tool calls\\n' >&2 _ptc_cleanup_tempfiles trap - DEBUG EXIT @@ -480,7 +481,7 @@ _ptc_history_matches_by_signature() { return 0 fi # Path, not inline: large input can exceed ARG_MAX via --argjson. - jq -c \\ + "$_PTC_JQ_PATH" -c \\ --arg nm "$_ptc_name" \\ --arg site "$_ptc_call_site" \\ --arg hash "$_ptc_input_hash" \\ @@ -504,7 +505,7 @@ _ptc_first_unconsumed_history_match() { local _ptc_key while IFS= read -r _ptc_match; do [ -n "$_ptc_match" ] || continue - _ptc_key=$(printf '%s' "$_ptc_match" | jq -r '.key // empty' 2>/dev/null) + _ptc_key=$(printf '%s' "$_ptc_match" | "$_PTC_JQ_PATH" -r '.key // empty' 2>/dev/null) if [ -n "$_ptc_key" ] && ! grep -Fxq "$_ptc_key" "$_PTC_CONSUMED_FILE" 2>/dev/null; then printf '%s' "$_ptc_match" return 0 @@ -516,15 +517,15 @@ _ptc_first_unconsumed_history_match() { _ptc_print_history_entry() { local _ptc_entry="$1" local _ptc_is_err - _ptc_is_err=$(printf '%s' "$_ptc_entry" | jq -r 'if type == "object" then (.is_error // false) else false end' 2>/dev/null) + _ptc_is_err=$(printf '%s' "$_ptc_entry" | "$_PTC_JQ_PATH" -r 'if type == "object" then (.is_error // false) else false end' 2>/dev/null) if [ "$_ptc_is_err" = "true" ]; then local _ptc_msg - _ptc_msg=$(printf '%s' "$_ptc_entry" | jq -r '.error_message // "tool execution failed"' 2>/dev/null) + _ptc_msg=$(printf '%s' "$_ptc_entry" | "$_PTC_JQ_PATH" -r '.error_message // "tool execution failed"' 2>/dev/null) _ptc_write_error "$_ptc_msg" exit 1 fi local _ptc_result - _ptc_result=$(printf '%s' "$_ptc_entry" | jq -c 'if type == "object" and has("result") then .result else . end' 2>/dev/null || printf 'null') + _ptc_result=$(printf '%s' "$_ptc_entry" | "$_PTC_JQ_PATH" -c 'if type == "object" and has("result") then .result else . end' 2>/dev/null || printf 'null') printf '%s' "$_ptc_result" return 0 } @@ -535,7 +536,7 @@ _ptc_history_entry_matches_current_call() { local _ptc_input_file="$3" local _ptc_input_hash="$4" # Path, same ARG_MAX reason as above. - printf '%s' "$_ptc_entry" | jq -e \\ + printf '%s' "$_ptc_entry" | "$_PTC_JQ_PATH" -e \\ --arg nm "$_ptc_name" \\ --arg hash "$_ptc_input_hash" \\ --slurpfile inp_arr "$_ptc_input_file" \\ @@ -555,7 +556,7 @@ _ptc_call_tool() { local _ptc_call_site="\${BASH_LINENO[1]:-\${BASH_LINENO[0]:-0}}" # Reject extra trailing JSON values instead of silently dropping them. - if ! printf '%s' "$_ptc_input" | jq -e -n '[inputs] as $docs | ($docs | length) == 1 and ($docs[0] | type) == "object"' >/dev/null 2>&1; then + if ! printf '%s' "$_ptc_input" | "$_PTC_JQ_PATH" -e -n '[inputs] as $docs | ($docs | length) == 1 and ($docs[0] | type) == "object"' >/dev/null 2>&1; then _ptc_write_error "tool input for $_ptc_name must be a single JSON object, got: $_ptc_input" exit 1 fi @@ -583,8 +584,8 @@ _ptc_call_tool() { if [ -n "$_ptc_match" ] && [ "$_ptc_match" != "null" ]; then local _ptc_matched_call_id local _ptc_matched_entry - _ptc_matched_call_id=$(printf '%s' "$_ptc_match" | jq -r '.key' 2>/dev/null) - _ptc_matched_entry=$(printf '%s' "$_ptc_match" | jq -c '.value' 2>/dev/null) + _ptc_matched_call_id=$(printf '%s' "$_ptc_match" | "$_PTC_JQ_PATH" -r '.key' 2>/dev/null) + _ptc_matched_entry=$(printf '%s' "$_ptc_match" | "$_PTC_JQ_PATH" -c '.value' 2>/dev/null) printf '%s\\n' "$_ptc_matched_call_id" >> "$_PTC_CONSUMED_FILE" _ptc_mark_counter_at_least "$_ptc_matched_call_id" _ptc_release_lock @@ -598,7 +599,7 @@ _ptc_call_tool() { while :; do _ptc_call_id=$(_ptc_next_call_id) if [ -r "$_PTC_HISTORY_PATH" ]; then - _ptc_entry=$(jq -c --arg id "$_ptc_call_id" '.[$id] // empty' "$_PTC_HISTORY_PATH" 2>/dev/null || printf '') + _ptc_entry=$("$_PTC_JQ_PATH" -c --arg id "$_ptc_call_id" '.[$id] // empty' "$_PTC_HISTORY_PATH" 2>/dev/null || printf '') else _ptc_entry="" fi @@ -614,7 +615,7 @@ _ptc_call_tool() { fi done - if ! printf '%s' "$_ptc_input" | jq -c -n \\ + if ! printf '%s' "$_ptc_input" | "$_PTC_JQ_PATH" -c -n \\ --arg cid "$_ptc_call_id" \\ --arg nm "$_ptc_name" \\ --arg hash "$_ptc_input_hash" \\