This is an automated email from the ASF dual-hosted git repository.

mattcasters pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 9df444598b Invoke GnuPG with an argument list instead of a shell 
command string,… (#8316)
9df444598b is described below

commit 9df444598b373d6e7aff5755c0ae66359bee7c2e
Author: Bart Maertens <[email protected]>
AuthorDate: Mon Sep 14 13:26:00 2026 +0200

    Invoke GnuPG with an argument list instead of a shell command string,… 
(#8316)
    
    * Invoke GnuPG with an argument list instead of a shell command string, 
fixes #8311
    
    GPG.execGnuPG() built a single command string and ran it through a shell
    (/bin/sh -c on POSIX, Runtime.exec(String) on Windows). Callers concatenated
    filenames, key IDs and the passphrase into that string wrapped in double
    quotes, which do not prevent shell expansion. Filenames come from a 
directory
    scan, so their content was interpreted rather than passed to GnuPG 
literally.
    
    execGnuPG now takes a List<String> and uses ProcessBuilder directly, with no
    shell involved. The ten call sites in the same file build argument lists.
    
    Also on the same code path: the passphrase moves from the command line, 
where
    it was readable through the process table, to stdin. GnuPG 2.1 and later
    ignore a passphrase given that way unless the loopback pinentry is 
requested,
    so that option is now passed as well - without it, decrypting with a
    passphrase and the PGP decrypt stream transform have been failing on any
    current GnuPG.
    
    Tests: an argument-recording harness asserts what GnuPG actually receives 
for
    every converted method, including that a command substitution in a filename 
or
    key ID is never executed. Six integration tests cover folder scanning with
    hostile filenames, binary encrypt, detached verify, decrypt with a 
passphrase,
    the PGP stream transforms, and a command-injection proof. Against the 
previous
    implementation 16 of these 25 tests fail.
    
    * Address review on #8316: fail closed without a recipient, end GnuPG 
option parsing
    
    Empty user ID no longer falls through to GnuPG's default-recipient. 
addRecipient()
    refuses it on encryptFile, signAndEncryptFile, encrypt and signAndEncrypt, 
so data is
    never sealed to a key the workflow did not name. signFile keeps omitting 
-r, where it
    was already omitted and is the wrong flag anyway.
    
    Every file operand is now preceded by --, so a scanned name such as -o or 
--status-fd
    is opened rather than option-parsed. The --output value is left alone: it 
is an option
    argument, which GnuPG already takes literally.
    
    execGnuPG() closes the child's standard input whether or not there is data 
for it,
    instead of leaving the pipe open on calls that send nothing.
    
    Tests: the recorder captures stdin as well as argv, and every passphrase 
path now
    asserts the passphrase is absent from argv, present on stdin, and 
accompanied by both
    --passphrase-fd 0 and --pinentry-mode loopback. Added coverage for the 
recipient on the
    encrypt paths, for an empty user ID being refused before GnuPG is started, 
and for
    dash-leading filenames arriving as operands. Dropped the bug-history 
narration from the
    comments and the unreferenced CONST_BATCH_YES.
---
 .../pgp/0007-pgp-stream-roundtrip.hpl              | 205 ++++++++++
 ...0003-pgp-sign-folder-with-hostile-filenames.hwf | 278 ++++++++++++++
 .../pgp/main-0004-pgp-encrypt-binary-roundtrip.hwf | 327 ++++++++++++++++
 .../main-0005-pgp-verify-detached-signature.hwf    | 176 +++++++++
 .../pgp/main-0006-pgp-decrypt-with-passphrase.hwf  | 234 ++++++++++++
 .../pgp/main-0007-pgp-stream-transforms.hwf        | 177 +++++++++
 .../main-0008-pgp-filename-command-injection.hwf   | 254 +++++++++++++
 .../hop/workflow/actions/pgpencryptfiles/GPG.java  | 290 ++++++++------
 .../messages/messages_en_US.properties             |   1 +
 .../ActionPGPEncryptFilesSignTest.java             |  38 ++
 .../pgpencryptfiles/GpgArgumentPassingTest.java    | 417 +++++++++++++++++++++
 11 files changed, 2290 insertions(+), 107 deletions(-)

diff --git a/integration-tests/pgp/0007-pgp-stream-roundtrip.hpl 
b/integration-tests/pgp/0007-pgp-stream-roundtrip.hpl
new file mode 100644
index 0000000000..017929eb4b
--- /dev/null
+++ b/integration-tests/pgp/0007-pgp-stream-roundtrip.hpl
@@ -0,0 +1,205 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<pipeline>
+  <info>
+    <name>0007-pgp-stream-roundtrip</name>
+    <name_sync_with_filename>Y</name_sync_with_filename>
+    <description>Round trips a field through the PGP encrypt and decrypt 
stream transforms. These
+    two transforms are the only callers of the string based GnuPG methods, and 
nothing else in the
+    test suite exercises them.</description>
+    <extended_description/>
+    <pipeline_version/>
+    <pipeline_type>Normal</pipeline_type>
+    <parameters>
+    </parameters>
+    <capture_transform_performance>N</capture_transform_performance>
+    
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+    
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+    <created_user>-</created_user>
+    <created_date>2026/09/10 00:00:00.000</created_date>
+    <modified_user>-</modified_user>
+    <modified_date>2026/09/10 00:00:00.000</modified_date>
+  </info>
+  <notepads>
+  </notepads>
+  <order>
+    <hop>
+      <from>generate a payload</from>
+      <to>encrypt the field</to>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>encrypt the field</from>
+      <to>decrypt the field</to>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>decrypt the field</from>
+      <to>round trip preserved the value?</to>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>round trip preserved the value?</from>
+      <to>Abort</to>
+      <enabled>Y</enabled>
+    </hop>
+  </order>
+  <transform>
+    <name>generate a payload</name>
+    <type>RowGenerator</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <fields>
+      <field>
+        <name>payload</name>
+        <type>String</type>
+        <format/>
+        <length>-1</length>
+        <precision>-1</precision>
+        <currency/>
+        <decimal/>
+        <group/>
+        <nullif>Hop PGP stream round trip</nullif>
+        <set_empty_string>N</set_empty_string>
+      </field>
+    </fields>
+    <interval_in_ms>5000</interval_in_ms>
+    <last_time_field>FiveSecondsAgo</last_time_field>
+    <limit>1</limit>
+    <never_ending>N</never_ending>
+    <row_time_field>now</row_time_field>
+    <attributes/>
+    <GUI>
+      <xloc>96</xloc>
+      <yloc>96</yloc>
+    </GUI>
+  </transform>
+  <transform>
+    <name>encrypt the field</name>
+    <type>PGPEncryptStream</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <gpglocation>${PROJECT_HOME}/output/gpg-in-project.sh</gpglocation>
+    <keyname>[email protected]</keyname>
+    <keynameInField>N</keynameInField>
+    <keynameFieldName/>
+    <streamfield>payload</streamfield>
+    <resultfieldname>sealed</resultfieldname>
+    <attributes/>
+    <GUI>
+      <xloc>288</xloc>
+      <yloc>96</yloc>
+    </GUI>
+  </transform>
+  <transform>
+    <name>decrypt the field</name>
+    <type>PGPDecryptStream</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <gpglocation>${PROJECT_HOME}/output/gpg-in-project.sh</gpglocation>
+    <passhrase>hop-it-passphrase</passhrase>
+    <passphraseFromField>N</passphraseFromField>
+    <passphraseFieldName/>
+    <streamfield>sealed</streamfield>
+    <resultfieldname>opened</resultfieldname>
+    <attributes/>
+    <GUI>
+      <xloc>480</xloc>
+      <yloc>96</yloc>
+    </GUI>
+  </transform>
+  <transform>
+    <name>round trip preserved the value?</name>
+    <type>FilterRows</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <compare>
+      <condition>
+        <conditions>
+</conditions>
+        <function>=</function>
+        <leftvalue>opened</leftvalue>
+        <negated>N</negated>
+        <operator>-</operator>
+        <value>
+          <isnull>N</isnull>
+          <length>-1</length>
+          <name>constant</name>
+          <precision>-1</precision>
+          <text>Hop PGP stream round trip</text>
+          <type>String</type>
+        </value>
+      </condition>
+    </compare>
+    <send_false_to>Abort</send_false_to>
+    <attributes/>
+    <GUI>
+      <xloc>672</xloc>
+      <yloc>96</yloc>
+    </GUI>
+  </transform>
+  <transform>
+    <name>Abort</name>
+    <type>Abort</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <abort_option>ABORT_WITH_ERROR</abort_option>
+    <always_log_rows>Y</always_log_rows>
+    <row_threshold>0</row_threshold>
+    <attributes/>
+    <GUI>
+      <xloc>864</xloc>
+      <yloc>224</yloc>
+    </GUI>
+  </transform>
+  <transform_error_handling>
+  </transform_error_handling>
+  <attributes/>
+</pipeline>
diff --git 
a/integration-tests/pgp/main-0003-pgp-sign-folder-with-hostile-filenames.hwf 
b/integration-tests/pgp/main-0003-pgp-sign-folder-with-hostile-filenames.hwf
new file mode 100644
index 0000000000..ec826655ae
--- /dev/null
+++ b/integration-tests/pgp/main-0003-pgp-sign-folder-with-hostile-filenames.hwf
@@ -0,0 +1,278 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<workflow>
+  <name>main-0003-pgp-sign-folder-with-hostile-filenames</name>
+  <name_sync_with_filename>Y</name_sync_with_filename>
+  <description>Signs a whole folder whose filenames contain shell 
metacharacters. The names are
+  discovered by the action itself rather than configured, which is how they 
reach gpg in a real
+  deployment: whoever can write into a watched drop folder chooses 
them.</description>
+  <extended_description/>
+  <workflow_version/>
+  <created_user>-</created_user>
+  <created_date>2026/09/10 00:00:00.000</created_date>
+  <modified_user>-</modified_user>
+  <modified_date>2026/09/10 00:00:00.000</modified_date>
+  <parameters>
+    </parameters>
+  <actions>
+    <action>
+      <name>Start</name>
+      <description/>
+      <type>SPECIAL</type>
+      <attributes/>
+      <DayOfMonth>1</DayOfMonth>
+      <doNotWaitOnFirstExecution>N</doNotWaitOnFirstExecution>
+      <hour>12</hour>
+      <intervalMinutes>60</intervalMinutes>
+      <intervalSeconds>0</intervalSeconds>
+      <minutes>0</minutes>
+      <repeat>N</repeat>
+      <schedulerType>0</schedulerType>
+      <weekDay>1</weekDay>
+      <parallel>N</parallel>
+      <xloc>80</xloc>
+      <yloc>80</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>fill a drop folder with hostile filenames</name>
+      <description/>
+      <type>SHELL</type>
+      <attributes/>
+      <filename/>
+      <work_directory>${PROJECT_HOME}</work_directory>
+      <arg_from_previous>N</arg_from_previous>
+      <exec_per_row>N</exec_per_row>
+      <set_logfile>N</set_logfile>
+      <logfile/>
+      <set_append_logfile>N</set_append_logfile>
+      <logext/>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <insertScript>Y</insertScript>
+      <script>#!/bin/bash
+set -e
+
+GNUPG_HOME=${PROJECT_HOME}/output/gnupg
+WRAPPER=${PROJECT_HOME}/output/gpg-in-project.sh
+DROP=${PROJECT_HOME}/output/drop
+SIGNED=${PROJECT_HOME}/output/signed
+
+GPG_BIN=$(command -v gpg || true)
+if [ -z "$GPG_BIN" ]; then
+  echo "gpg is not installed; the PGP actions cannot run without it"
+  exit 1
+fi
+
+rm -rf "$GNUPG_HOME" "$DROP" "$SIGNED"
+mkdir -p "$GNUPG_HOME" "$DROP" "$SIGNED"
+chmod 700 "$GNUPG_HOME"
+
+printf '#!/bin/sh\nexec "%s" --homedir "%s" "$@"\n' "$GPG_BIN" "$GNUPG_HOME" 
&gt; "$WRAPPER"
+chmod 700 "$WRAPPER"
+
+"$GPG_BIN" --homedir "$GNUPG_HOME" --batch --yes --pinentry-mode loopback 
--passphrase '' \
+  --quiet --quick-generate-key "Hop Integration Test 
&lt;[email protected]&gt;" default default never 2&gt;&amp;1
+
+# Every name below is a legal file name that a shell would rewrite if it ever 
saw it. The action
+# discovers them by scanning the folder, so they must travel to gpg exactly as 
written here.
+printf 'id,amount\n1,100\n' &gt; "$DROP"'/report$(echo pwned).csv'
+printf 'id,amount\n2,200\n' &gt; "$DROP"'/report`echo pwned`.csv'
+printf 'id,amount\n3,300\n' &gt; "$DROP"'/report;echo pwned;.csv'
+printf 'id,amount\n4,400\n' &gt; "$DROP"'/report with spaces.csv'
+printf 'id,amount\n5,500\n' &gt; "$DROP"'/report&amp;&amp;echo pwned.csv'
+printf 'id,amount\n6,600\n' &gt; "$DROP"'/report|echo pwned.csv'
+printf 'id,amount\n7,700\n' &gt; "$DROP"'/report$HOME.csv'
+printf 'id,amount\n8,800\n' &gt; "$DROP"'/report*.csv'
+
+echo "drop folder contains $(find "$DROP" -type f | wc -l) files"
+</script>
+      <loglevel>Basic</loglevel>
+      <parallel>N</parallel>
+      <xloc>240</xloc>
+      <yloc>80</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>sign every file in the folder</name>
+      <description/>
+      <type>PGP_ENCRYPT_FILES</type>
+      <attributes/>
+      <gpglocation>${PROJECT_HOME}/output/gpg-in-project.sh</gpglocation>
+      <arg_from_previous>N</arg_from_previous>
+      <include_subfolders>N</include_subfolders>
+      <add_result_filesname>N</add_result_filesname>
+      <destination_is_a_file>N</destination_is_a_file>
+      <create_destination_folder>N</create_destination_folder>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <SpecifyFormat>N</SpecifyFormat>
+      <date_time_format/>
+      <nr_errors_less_than>10</nr_errors_less_than>
+      <success_condition>success_if_no_errors</success_condition>
+      <AddDateBeforeExtension>N</AddDateBeforeExtension>
+      <DoNotKeepFolderStructure>N</DoNotKeepFolderStructure>
+      <iffileexists>overwrite_file</iffileexists>
+      <destinationFolder/>
+      <ifmovedfileexists>do_nothing</ifmovedfileexists>
+      <moved_date_time_format/>
+      <create_move_to_folder>N</create_move_to_folder>
+      <add_moved_date>N</add_moved_date>
+      <add_moved_time>N</add_moved_time>
+      <SpecifyMoveFormat>N</SpecifyMoveFormat>
+      <AddMovedDateBeforeExtension>N</AddMovedDateBeforeExtension>
+      <asciiMode>Y</asciiMode>
+      <fields>
+        <field>
+          <action_type>sign</action_type>
+          <source_filefolder>${PROJECT_HOME}/output/drop</source_filefolder>
+          <wildcard>.*\.csv</wildcard>
+          <userid></userid>
+          
<destination_filefolder>${PROJECT_HOME}/output/signed</destination_filefolder>
+        </field>
+      </fields>
+      <parallel>N</parallel>
+      <xloc>460</xloc>
+      <yloc>80</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>every file was signed and verifies</name>
+      <description/>
+      <type>SHELL</type>
+      <attributes/>
+      <filename/>
+      <work_directory>${PROJECT_HOME}</work_directory>
+      <arg_from_previous>N</arg_from_previous>
+      <exec_per_row>N</exec_per_row>
+      <set_logfile>N</set_logfile>
+      <logfile/>
+      <set_append_logfile>N</set_append_logfile>
+      <logext/>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <insertScript>Y</insertScript>
+      <script>#!/bin/bash
+set -e
+
+WRAPPER=${PROJECT_HOME}/output/gpg-in-project.sh
+DROP=${PROJECT_HOME}/output/drop
+SIGNED=${PROJECT_HOME}/output/signed
+
+dropped=$(find "$DROP" -type f | wc -l | tr -d ' ')
+signed=$(find "$SIGNED" -type f | wc -l | tr -d ' ')
+
+if [ "$dropped" -eq 0 ]; then
+  echo "no source files were created, the test proves nothing"
+  exit 1
+fi
+
+if [ "$dropped" -ne "$signed" ]; then
+  echo "expected $dropped signatures but found $signed"
+  echo "a name that a shell rewrote would have been read as a file that does 
not exist"
+  find "$SIGNED" -type f
+  exit 1
+fi
+
+# Every output has to be a real signature over the file it came from.
+failed=0
+while IFS= read -r f; do
+  if ! head -n 1 "$f" | grep -q -- '-----BEGIN PGP SIGNED MESSAGE-----'; then
+    echo "not a clear-signed message: $f"
+    failed=1
+    continue
+  fi
+  if ! "$WRAPPER" --batch --verify "$f" &gt;/dev/null 2&gt;&amp;1; then
+    echo "signature does not verify: $f"
+    failed=1
+  fi
+done &lt; &lt;(find "$SIGNED" -type f)
+
+echo "$signed of $dropped files signed and verified"
+exit $failed
+</script>
+      <loglevel>Basic</loglevel>
+      <parallel>N</parallel>
+      <xloc>680</xloc>
+      <yloc>80</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>Abort workflow</name>
+      <description/>
+      <type>ABORT</type>
+      <attributes/>
+      <message>Signing a folder of files with shell metacharacters in their 
names failed</message>
+      <loglevel>ERROR</loglevel>
+      <always_log_rows>N</always_log_rows>
+      <parallel>N</parallel>
+      <xloc>460</xloc>
+      <yloc>220</yloc>
+      <attributes_hac/>
+    </action>
+  </actions>
+  <hops>
+    <hop>
+      <from>Start</from>
+      <to>fill a drop folder with hostile filenames</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>Y</unconditional>
+    </hop>
+    <hop>
+      <from>fill a drop folder with hostile filenames</from>
+      <to>sign every file in the folder</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>sign every file in the folder</from>
+      <to>every file was signed and verifies</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>fill a drop folder with hostile filenames</from>
+      <to>Abort workflow</to>
+      <enabled>Y</enabled>
+      <evaluation>N</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>sign every file in the folder</from>
+      <to>Abort workflow</to>
+      <enabled>Y</enabled>
+      <evaluation>N</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>every file was signed and verifies</from>
+      <to>Abort workflow</to>
+      <enabled>Y</enabled>
+      <evaluation>N</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+  </hops>
+  <notepads>
+  </notepads>
+  <attributes/>
+</workflow>
diff --git a/integration-tests/pgp/main-0004-pgp-encrypt-binary-roundtrip.hwf 
b/integration-tests/pgp/main-0004-pgp-encrypt-binary-roundtrip.hwf
new file mode 100644
index 0000000000..2b7ec8e790
--- /dev/null
+++ b/integration-tests/pgp/main-0004-pgp-encrypt-binary-roundtrip.hwf
@@ -0,0 +1,327 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<workflow>
+  <name>main-0004-pgp-encrypt-binary-roundtrip</name>
+  <name_sync_with_filename>Y</name_sync_with_filename>
+  <description>Encrypt without signing, writing binary rather than ASCII 
armour. This is the third
+  of the three action types and the only one that leaves the -a flag off, so 
it is the remaining
+  argument shape the other tests never build.</description>
+  <extended_description/>
+  <workflow_version/>
+  <created_user>-</created_user>
+  <created_date>2026/09/10 00:00:00.000</created_date>
+  <modified_user>-</modified_user>
+  <modified_date>2026/09/10 00:00:00.000</modified_date>
+  <parameters>
+    </parameters>
+  <actions>
+    <action>
+      <name>Start</name>
+      <description/>
+      <type>SPECIAL</type>
+      <attributes/>
+      <DayOfMonth>1</DayOfMonth>
+      <doNotWaitOnFirstExecution>N</doNotWaitOnFirstExecution>
+      <hour>12</hour>
+      <intervalMinutes>60</intervalMinutes>
+      <intervalSeconds>0</intervalSeconds>
+      <minutes>0</minutes>
+      <repeat>N</repeat>
+      <schedulerType>0</schedulerType>
+      <weekDay>1</weekDay>
+      <parallel>N</parallel>
+      <xloc>80</xloc>
+      <yloc>80</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>create a throwaway keyring</name>
+      <description/>
+      <type>SHELL</type>
+      <attributes/>
+      <filename/>
+      <work_directory>${PROJECT_HOME}</work_directory>
+      <arg_from_previous>N</arg_from_previous>
+      <exec_per_row>N</exec_per_row>
+      <set_logfile>N</set_logfile>
+      <logfile/>
+      <set_append_logfile>N</set_append_logfile>
+      <logext/>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <insertScript>Y</insertScript>
+      <script>#!/bin/bash
+set -e
+
+GNUPG_HOME=${PROJECT_HOME}/output/gnupg
+WRAPPER=${PROJECT_HOME}/output/gpg-in-project.sh
+
+GPG_BIN=$(command -v gpg || true)
+if [ -z "$GPG_BIN" ]; then
+  echo "gpg is not installed; the PGP actions cannot run without it"
+  exit 1
+fi
+
+rm -rf "$GNUPG_HOME"
+mkdir -p "$GNUPG_HOME"
+chmod 700 "$GNUPG_HOME"
+
+printf '#!/bin/sh\nexec "%s" --homedir "%s" "$@"\n' "$GPG_BIN" "$GNUPG_HOME" 
&gt; "$WRAPPER"
+chmod 700 "$WRAPPER"
+
+"$GPG_BIN" --homedir "$GNUPG_HOME" --batch --yes --pinentry-mode loopback 
--passphrase '' \
+  --quiet --quick-generate-key "Hop Integration Test 
&lt;[email protected]&gt;" default default never 2&gt;&amp;1
+
+printf 'id,amount\n9,900\n10,1000\n' &gt; ${PROJECT_HOME}/output/ledger.csv
+rm -f ${PROJECT_HOME}/output/ledger.csv.gpg 
${PROJECT_HOME}/output/ledger-decrypted.csv
+</script>
+      <loglevel>Basic</loglevel>
+      <parallel>N</parallel>
+      <xloc>240</xloc>
+      <yloc>80</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>encrypt the file as binary</name>
+      <description/>
+      <type>PGP_ENCRYPT_FILES</type>
+      <attributes/>
+      <gpglocation>${PROJECT_HOME}/output/gpg-in-project.sh</gpglocation>
+      <arg_from_previous>N</arg_from_previous>
+      <include_subfolders>N</include_subfolders>
+      <add_result_filesname>N</add_result_filesname>
+      <destination_is_a_file>Y</destination_is_a_file>
+      <create_destination_folder>N</create_destination_folder>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <SpecifyFormat>N</SpecifyFormat>
+      <date_time_format/>
+      <nr_errors_less_than>10</nr_errors_less_than>
+      <success_condition>success_if_no_errors</success_condition>
+      <AddDateBeforeExtension>N</AddDateBeforeExtension>
+      <DoNotKeepFolderStructure>N</DoNotKeepFolderStructure>
+      <iffileexists>overwrite_file</iffileexists>
+      <destinationFolder/>
+      <ifmovedfileexists>do_nothing</ifmovedfileexists>
+      <moved_date_time_format/>
+      <create_move_to_folder>N</create_move_to_folder>
+      <add_moved_date>N</add_moved_date>
+      <add_moved_time>N</add_moved_time>
+      <SpecifyMoveFormat>N</SpecifyMoveFormat>
+      <AddMovedDateBeforeExtension>N</AddMovedDateBeforeExtension>
+      <asciiMode>N</asciiMode>
+      <fields>
+        <field>
+          <action_type>encrypt</action_type>
+          
<source_filefolder>${PROJECT_HOME}/output/ledger.csv</source_filefolder>
+          <wildcard/>
+          <userid>[email protected]</userid>
+          
<destination_filefolder>${PROJECT_HOME}/output/ledger.csv.gpg</destination_filefolder>
+        </field>
+      </fields>
+      <parallel>N</parallel>
+      <xloc>460</xloc>
+      <yloc>80</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>the output is binary, not ASCII armour</name>
+      <description/>
+      <type>SHELL</type>
+      <attributes/>
+      <filename/>
+      <work_directory>${PROJECT_HOME}</work_directory>
+      <arg_from_previous>N</arg_from_previous>
+      <exec_per_row>N</exec_per_row>
+      <set_logfile>N</set_logfile>
+      <logfile/>
+      <set_append_logfile>N</set_append_logfile>
+      <logext/>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <insertScript>Y</insertScript>
+      <script>#!/bin/bash
+set -e
+
+SEALED=${PROJECT_HOME}/output/ledger.csv.gpg
+
+if [ ! -s "$SEALED" ]; then
+  echo "no encrypted file was written"
+  exit 1
+fi
+
+# With asciiMode off the -a flag is not passed, so the output must not be 
armoured.
+if head -n 1 "$SEALED" | grep -q -- '-----BEGIN PGP MESSAGE-----'; then
+  echo "expected a binary OpenPGP message but found ASCII armour"
+  exit 1
+fi
+
+echo "binary OpenPGP message written"
+</script>
+      <loglevel>Basic</loglevel>
+      <parallel>N</parallel>
+      <xloc>680</xloc>
+      <yloc>80</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <arg_from_previous>N</arg_from_previous>
+      <include_subfolders>N</include_subfolders>
+      <add_result_filesname>N</add_result_filesname>
+      <destination_is_a_file>Y</destination_is_a_file>
+      <create_destination_folder>N</create_destination_folder>
+      <fields>
+        <field>
+          
<source_filefolder>${PROJECT_HOME}/output/ledger.csv.gpg</source_filefolder>
+          <passphrase/>
+          
<destination_filefolder>${PROJECT_HOME}/output/ledger-decrypted.csv</destination_filefolder>
+          <wildcard/>
+        </field>
+      </fields>
+      <nr_errors_less_than>10</nr_errors_less_than>
+      <success_condition>success_if_no_errors</success_condition>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <SpecifyFormat>N</SpecifyFormat>
+      <date_time_format/>
+      <AddDateBeforeExtension>N</AddDateBeforeExtension>
+      <DoNotKeepFolderStructure>N</DoNotKeepFolderStructure>
+      <iffileexists>overwrite_file</iffileexists>
+      <destinationFolder/>
+      <ifmovedfileexists>do_nothing</ifmovedfileexists>
+      <moved_date_time_format/>
+      <AddMovedDateBeforeExtension>N</AddMovedDateBeforeExtension>
+      <add_moved_date>N</add_moved_date>
+      <add_moved_time>N</add_moved_time>
+      <SpecifyMoveFormat>N</SpecifyMoveFormat>
+      <create_move_to_folder>N</create_move_to_folder>
+      <gpglocation>${PROJECT_HOME}/output/gpg-in-project.sh</gpglocation>
+      <name>decrypt the binary file</name>
+      <description/>
+      <type>PGP_DECRYPT_FILES</type>
+      <attributes/>
+      <xloc>900</xloc>
+      <yloc>80</yloc>
+      <parallel>N</parallel>
+      <attributes_hac/>
+    </action>
+    <action>
+      <filename1>${PROJECT_HOME}/output/ledger.csv</filename1>
+      <filename2>${PROJECT_HOME}/output/ledger-decrypted.csv</filename2>
+      <add_filename_result>N</add_filename_result>
+      <name>the round trip preserved the file</name>
+      <description/>
+      <type>FILE_COMPARE</type>
+      <attributes/>
+      <xloc>1120</xloc>
+      <yloc>80</yloc>
+      <parallel>N</parallel>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>Abort workflow</name>
+      <description/>
+      <type>ABORT</type>
+      <attributes/>
+      <message>The binary encrypt and decrypt round trip failed</message>
+      <loglevel>ERROR</loglevel>
+      <always_log_rows>N</always_log_rows>
+      <parallel>N</parallel>
+      <xloc>680</xloc>
+      <yloc>220</yloc>
+      <attributes_hac/>
+    </action>
+  </actions>
+  <hops>
+    <hop>
+      <from>Start</from>
+      <to>create a throwaway keyring</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>Y</unconditional>
+    </hop>
+    <hop>
+      <from>create a throwaway keyring</from>
+      <to>encrypt the file as binary</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>encrypt the file as binary</from>
+      <to>the output is binary, not ASCII armour</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>the output is binary, not ASCII armour</from>
+      <to>decrypt the binary file</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>decrypt the binary file</from>
+      <to>the round trip preserved the file</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>create a throwaway keyring</from>
+      <to>Abort workflow</to>
+      <enabled>Y</enabled>
+      <evaluation>N</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>encrypt the file as binary</from>
+      <to>Abort workflow</to>
+      <enabled>Y</enabled>
+      <evaluation>N</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>the output is binary, not ASCII armour</from>
+      <to>Abort workflow</to>
+      <enabled>Y</enabled>
+      <evaluation>N</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>decrypt the binary file</from>
+      <to>Abort workflow</to>
+      <enabled>Y</enabled>
+      <evaluation>N</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>the round trip preserved the file</from>
+      <to>Abort workflow</to>
+      <enabled>Y</enabled>
+      <evaluation>N</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+  </hops>
+  <notepads>
+  </notepads>
+  <attributes/>
+</workflow>
diff --git a/integration-tests/pgp/main-0005-pgp-verify-detached-signature.hwf 
b/integration-tests/pgp/main-0005-pgp-verify-detached-signature.hwf
new file mode 100644
index 0000000000..fdf2521098
--- /dev/null
+++ b/integration-tests/pgp/main-0005-pgp-verify-detached-signature.hwf
@@ -0,0 +1,176 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<workflow>
+  <name>main-0005-pgp-verify-detached-signature</name>
+  <name_sync_with_filename>Y</name_sync_with_filename>
+  <description>Verifies a detached signature, which passes two filenames to 
gpg in one call rather
+  than one. The PGP actions cannot produce a detached signature, so gpg writes 
it directly and the
+  verify action reads it back. Both filenames carry shell 
metacharacters.</description>
+  <extended_description/>
+  <workflow_version/>
+  <created_user>-</created_user>
+  <created_date>2026/09/10 00:00:00.000</created_date>
+  <modified_user>-</modified_user>
+  <modified_date>2026/09/10 00:00:00.000</modified_date>
+  <parameters>
+    </parameters>
+  <actions>
+    <action>
+      <name>Start</name>
+      <description/>
+      <type>SPECIAL</type>
+      <attributes/>
+      <DayOfMonth>1</DayOfMonth>
+      <doNotWaitOnFirstExecution>N</doNotWaitOnFirstExecution>
+      <hour>12</hour>
+      <intervalMinutes>60</intervalMinutes>
+      <intervalSeconds>0</intervalSeconds>
+      <minutes>0</minutes>
+      <repeat>N</repeat>
+      <schedulerType>0</schedulerType>
+      <weekDay>1</weekDay>
+      <parallel>N</parallel>
+      <xloc>80</xloc>
+      <yloc>80</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>sign detached with a hostile filename</name>
+      <description/>
+      <type>SHELL</type>
+      <attributes/>
+      <filename/>
+      <work_directory>${PROJECT_HOME}</work_directory>
+      <arg_from_previous>N</arg_from_previous>
+      <exec_per_row>N</exec_per_row>
+      <set_logfile>N</set_logfile>
+      <logfile/>
+      <set_append_logfile>N</set_append_logfile>
+      <logext/>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <insertScript>Y</insertScript>
+      <script>#!/bin/bash
+set -e
+
+GNUPG_HOME=${PROJECT_HOME}/output/gnupg
+WRAPPER=${PROJECT_HOME}/output/gpg-in-project.sh
+DETACHED=${PROJECT_HOME}/output/detached
+
+GPG_BIN=$(command -v gpg || true)
+if [ -z "$GPG_BIN" ]; then
+  echo "gpg is not installed; the PGP actions cannot run without it"
+  exit 1
+fi
+
+rm -rf "$GNUPG_HOME" "$DETACHED"
+mkdir -p "$GNUPG_HOME" "$DETACHED"
+chmod 700 "$GNUPG_HOME"
+
+printf '#!/bin/sh\nexec "%s" --homedir "%s" "$@"\n' "$GPG_BIN" "$GNUPG_HOME" 
&gt; "$WRAPPER"
+chmod 700 "$WRAPPER"
+
+"$GPG_BIN" --homedir "$GNUPG_HOME" --batch --yes --pinentry-mode loopback 
--passphrase '' \
+  --quiet --quick-generate-key "Hop Integration Test 
&lt;[email protected]&gt;" default default never 2&gt;&amp;1
+
+# The data file and its signature both carry characters a shell would act on. 
The verify action
+# passes both to gpg in a single call, so both have to survive.
+DATA="$DETACHED"'/manifest$(echo pwned).csv'
+SIG="$DETACHED"'/manifest$(echo pwned).csv.sig'
+
+printf 'id,amount\n11,1100\n' &gt; "$DATA"
+"$GPG_BIN" --homedir "$GNUPG_HOME" --batch --yes --pinentry-mode loopback 
--passphrase '' \
+  --quiet --detach-sign --output "$SIG" "$DATA"
+
+if [ ! -s "$SIG" ]; then
+  echo "gpg did not write a detached signature"
+  exit 1
+fi
+
+echo "detached signature written"
+</script>
+      <loglevel>Basic</loglevel>
+      <parallel>N</parallel>
+      <xloc>240</xloc>
+      <yloc>80</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>verify the detached signature</name>
+      <description/>
+      <type>PGP_VERIFY_FILES</type>
+      <attributes/>
+      <gpglocation>${PROJECT_HOME}/output/gpg-in-project.sh</gpglocation>
+      <filename>${PROJECT_HOME}/output/detached/manifest$(echo 
pwned).csv</filename>
+      <detachedfilename>${PROJECT_HOME}/output/detached/manifest$(echo 
pwned).csv.sig</detachedfilename>
+      <useDetachedSignature>Y</useDetachedSignature>
+      <parallel>N</parallel>
+      <xloc>460</xloc>
+      <yloc>80</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>Abort workflow</name>
+      <description/>
+      <type>ABORT</type>
+      <attributes/>
+      <message>Verifying a detached signature failed</message>
+      <loglevel>ERROR</loglevel>
+      <always_log_rows>N</always_log_rows>
+      <parallel>N</parallel>
+      <xloc>460</xloc>
+      <yloc>220</yloc>
+      <attributes_hac/>
+    </action>
+  </actions>
+  <hops>
+    <hop>
+      <from>Start</from>
+      <to>sign detached with a hostile filename</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>Y</unconditional>
+    </hop>
+    <hop>
+      <from>sign detached with a hostile filename</from>
+      <to>verify the detached signature</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>sign detached with a hostile filename</from>
+      <to>Abort workflow</to>
+      <enabled>Y</enabled>
+      <evaluation>N</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>verify the detached signature</from>
+      <to>Abort workflow</to>
+      <enabled>Y</enabled>
+      <evaluation>N</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+  </hops>
+  <notepads>
+  </notepads>
+  <attributes/>
+</workflow>
diff --git a/integration-tests/pgp/main-0006-pgp-decrypt-with-passphrase.hwf 
b/integration-tests/pgp/main-0006-pgp-decrypt-with-passphrase.hwf
new file mode 100644
index 0000000000..0656dfa42a
--- /dev/null
+++ b/integration-tests/pgp/main-0006-pgp-decrypt-with-passphrase.hwf
@@ -0,0 +1,234 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<workflow>
+  <name>main-0006-pgp-decrypt-with-passphrase</name>
+  <name_sync_with_filename>Y</name_sync_with_filename>
+  <description>Decrypts with a key that is actually locked, which is the only 
way the passphrase
+  field of the decrypt action is exercised. Every other test in this project 
uses a passphrase-less
+  key, so the passphrase never has to work for them to pass.</description>
+  <extended_description/>
+  <workflow_version/>
+  <created_user>-</created_user>
+  <created_date>2026/09/10 00:00:00.000</created_date>
+  <modified_user>-</modified_user>
+  <modified_date>2026/09/10 00:00:00.000</modified_date>
+  <parameters>
+    </parameters>
+  <actions>
+    <action>
+      <name>Start</name>
+      <description/>
+      <type>SPECIAL</type>
+      <attributes/>
+      <DayOfMonth>1</DayOfMonth>
+      <doNotWaitOnFirstExecution>N</doNotWaitOnFirstExecution>
+      <hour>12</hour>
+      <intervalMinutes>60</intervalMinutes>
+      <intervalSeconds>0</intervalSeconds>
+      <minutes>0</minutes>
+      <repeat>N</repeat>
+      <schedulerType>0</schedulerType>
+      <weekDay>1</weekDay>
+      <parallel>N</parallel>
+      <xloc>80</xloc>
+      <yloc>80</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>create a locked key and encrypt to it</name>
+      <description/>
+      <type>SHELL</type>
+      <attributes/>
+      <filename/>
+      <work_directory>${PROJECT_HOME}</work_directory>
+      <arg_from_previous>N</arg_from_previous>
+      <exec_per_row>N</exec_per_row>
+      <set_logfile>N</set_logfile>
+      <logfile/>
+      <set_append_logfile>N</set_append_logfile>
+      <logext/>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <insertScript>Y</insertScript>
+      <script>#!/bin/bash
+set -e
+
+GNUPG_HOME=${PROJECT_HOME}/output/gnupg-locked
+WRAPPER=${PROJECT_HOME}/output/gpg-locked.sh
+PASSPHRASE='hop-it-passphrase'
+
+GPG_BIN=$(command -v gpg || true)
+if [ -z "$GPG_BIN" ]; then
+  echo "gpg is not installed; the PGP actions cannot run without it"
+  exit 1
+fi
+
+# A keyring of its own, so the agent cannot be holding an unlocked copy of the 
key from one of
+# the other tests in this project.
+if [ -d "$GNUPG_HOME" ]; then
+  gpgconf --homedir "$GNUPG_HOME" --kill all &gt;/dev/null 2&gt;&amp;1 || true
+fi
+rm -rf "$GNUPG_HOME"
+mkdir -p "$GNUPG_HOME"
+chmod 700 "$GNUPG_HOME"
+
+printf '#!/bin/sh\nexec "%s" --homedir "%s" "$@"\n' "$GPG_BIN" "$GNUPG_HOME" 
&gt; "$WRAPPER"
+chmod 700 "$WRAPPER"
+
+"$GPG_BIN" --homedir "$GNUPG_HOME" --batch --yes --pinentry-mode loopback \
+  --passphrase "$PASSPHRASE" --quiet \
+  --quick-generate-key "Hop Locked Key &lt;[email protected]&gt;" default 
default never 2&gt;&amp;1
+
+printf 'id,amount\n12,1200\n' &gt; ${PROJECT_HOME}/output/locked.csv
+rm -f ${PROJECT_HOME}/output/locked.csv.asc 
${PROJECT_HOME}/output/locked-decrypted.csv
+
+"$GPG_BIN" --homedir "$GNUPG_HOME" --batch --yes --quiet -a \
+  -r '[email protected]' --trust-model always \
+  --output ${PROJECT_HOME}/output/locked.csv.asc \
+  --encrypt ${PROJECT_HOME}/output/locked.csv
+
+# Drop anything the agent cached while generating the key, so the decrypt 
below genuinely needs
+# the passphrase the action supplies.
+gpgconf --homedir "$GNUPG_HOME" --kill all &gt;/dev/null 2&gt;&amp;1 || true
+
+echo "encrypted to a passphrase protected key"
+</script>
+      <loglevel>Basic</loglevel>
+      <parallel>N</parallel>
+      <xloc>240</xloc>
+      <yloc>80</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <arg_from_previous>N</arg_from_previous>
+      <include_subfolders>N</include_subfolders>
+      <add_result_filesname>N</add_result_filesname>
+      <destination_is_a_file>Y</destination_is_a_file>
+      <create_destination_folder>N</create_destination_folder>
+      <fields>
+        <field>
+          
<source_filefolder>${PROJECT_HOME}/output/locked.csv.asc</source_filefolder>
+          <passphrase>hop-it-passphrase</passphrase>
+          
<destination_filefolder>${PROJECT_HOME}/output/locked-decrypted.csv</destination_filefolder>
+          <wildcard/>
+        </field>
+      </fields>
+      <nr_errors_less_than>10</nr_errors_less_than>
+      <success_condition>success_if_no_errors</success_condition>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <SpecifyFormat>N</SpecifyFormat>
+      <date_time_format/>
+      <AddDateBeforeExtension>N</AddDateBeforeExtension>
+      <DoNotKeepFolderStructure>N</DoNotKeepFolderStructure>
+      <iffileexists>overwrite_file</iffileexists>
+      <destinationFolder/>
+      <ifmovedfileexists>do_nothing</ifmovedfileexists>
+      <moved_date_time_format/>
+      <AddMovedDateBeforeExtension>N</AddMovedDateBeforeExtension>
+      <add_moved_date>N</add_moved_date>
+      <add_moved_time>N</add_moved_time>
+      <SpecifyMoveFormat>N</SpecifyMoveFormat>
+      <create_move_to_folder>N</create_move_to_folder>
+      <gpglocation>${PROJECT_HOME}/output/gpg-locked.sh</gpglocation>
+      <name>decrypt with the passphrase</name>
+      <description/>
+      <type>PGP_DECRYPT_FILES</type>
+      <attributes/>
+      <xloc>460</xloc>
+      <yloc>80</yloc>
+      <parallel>N</parallel>
+      <attributes_hac/>
+    </action>
+    <action>
+      <filename1>${PROJECT_HOME}/output/locked.csv</filename1>
+      <filename2>${PROJECT_HOME}/output/locked-decrypted.csv</filename2>
+      <add_filename_result>N</add_filename_result>
+      <name>the round trip preserved the file</name>
+      <description/>
+      <type>FILE_COMPARE</type>
+      <attributes/>
+      <xloc>680</xloc>
+      <yloc>80</yloc>
+      <parallel>N</parallel>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>Abort workflow</name>
+      <description/>
+      <type>ABORT</type>
+      <attributes/>
+      <message>Decrypting with a passphrase protected key failed</message>
+      <loglevel>ERROR</loglevel>
+      <always_log_rows>N</always_log_rows>
+      <parallel>N</parallel>
+      <xloc>460</xloc>
+      <yloc>220</yloc>
+      <attributes_hac/>
+    </action>
+  </actions>
+  <hops>
+    <hop>
+      <from>Start</from>
+      <to>create a locked key and encrypt to it</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>Y</unconditional>
+    </hop>
+    <hop>
+      <from>create a locked key and encrypt to it</from>
+      <to>decrypt with the passphrase</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>decrypt with the passphrase</from>
+      <to>the round trip preserved the file</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>create a locked key and encrypt to it</from>
+      <to>Abort workflow</to>
+      <enabled>Y</enabled>
+      <evaluation>N</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>decrypt with the passphrase</from>
+      <to>Abort workflow</to>
+      <enabled>Y</enabled>
+      <evaluation>N</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>the round trip preserved the file</from>
+      <to>Abort workflow</to>
+      <enabled>Y</enabled>
+      <evaluation>N</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+  </hops>
+  <notepads>
+  </notepads>
+  <attributes/>
+</workflow>
diff --git a/integration-tests/pgp/main-0007-pgp-stream-transforms.hwf 
b/integration-tests/pgp/main-0007-pgp-stream-transforms.hwf
new file mode 100644
index 0000000000..0cbf1a289c
--- /dev/null
+++ b/integration-tests/pgp/main-0007-pgp-stream-transforms.hwf
@@ -0,0 +1,177 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<workflow>
+  <name>main-0007-pgp-stream-transforms</name>
+  <name_sync_with_filename>Y</name_sync_with_filename>
+  <description>Drives the PGP encrypt and decrypt stream transforms. They call 
GnuPG through the
+  string based methods, which no other test in this project 
reaches.</description>
+  <extended_description/>
+  <workflow_version/>
+  <created_user>-</created_user>
+  <created_date>2026/09/10 00:00:00.000</created_date>
+  <modified_user>-</modified_user>
+  <modified_date>2026/09/10 00:00:00.000</modified_date>
+  <parameters>
+    </parameters>
+  <actions>
+    <action>
+      <name>Start</name>
+      <description/>
+      <type>SPECIAL</type>
+      <attributes/>
+      <DayOfMonth>1</DayOfMonth>
+      <doNotWaitOnFirstExecution>N</doNotWaitOnFirstExecution>
+      <hour>12</hour>
+      <intervalMinutes>60</intervalMinutes>
+      <intervalSeconds>0</intervalSeconds>
+      <minutes>0</minutes>
+      <repeat>N</repeat>
+      <schedulerType>0</schedulerType>
+      <weekDay>1</weekDay>
+      <parallel>N</parallel>
+      <xloc>80</xloc>
+      <yloc>80</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>create a throwaway keyring</name>
+      <description/>
+      <type>SHELL</type>
+      <attributes/>
+      <filename/>
+      <work_directory>${PROJECT_HOME}</work_directory>
+      <arg_from_previous>N</arg_from_previous>
+      <exec_per_row>N</exec_per_row>
+      <set_logfile>N</set_logfile>
+      <logfile/>
+      <set_append_logfile>N</set_append_logfile>
+      <logext/>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <insertScript>Y</insertScript>
+      <script>#!/bin/bash
+set -e
+
+GNUPG_HOME=${PROJECT_HOME}/output/gnupg
+WRAPPER=${PROJECT_HOME}/output/gpg-in-project.sh
+
+GPG_BIN=$(command -v gpg || true)
+if [ -z "$GPG_BIN" ]; then
+  echo "gpg is not installed; the PGP transforms cannot run without it"
+  exit 1
+fi
+
+rm -rf "$GNUPG_HOME"
+mkdir -p "$GNUPG_HOME"
+chmod 700 "$GNUPG_HOME"
+
+printf '#!/bin/sh\nexec "%s" --homedir "%s" "$@"\n' "$GPG_BIN" "$GNUPG_HOME" 
&gt; "$WRAPPER"
+chmod 700 "$WRAPPER"
+
+# The decrypt stream transform requires a passphrase, so unlike the action 
tests this key has to
+# carry one.
+"$GPG_BIN" --homedir "$GNUPG_HOME" --batch --yes --pinentry-mode loopback \
+  --passphrase 'hop-it-passphrase' \
+  --quiet --quick-generate-key "Hop Integration Test 
&lt;[email protected]&gt;" default default never 2&gt;&amp;1
+
+gpgconf --homedir "$GNUPG_HOME" --kill all &gt;/dev/null 2&gt;&amp;1 || true
+
+echo "keyring ready"
+</script>
+      <loglevel>Basic</loglevel>
+      <parallel>N</parallel>
+      <xloc>240</xloc>
+      <yloc>80</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>round trip a field through the transforms</name>
+      <description/>
+      <type>PIPELINE</type>
+      <attributes/>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <clear_files>N</clear_files>
+      <clear_rows>N</clear_rows>
+      <create_parent_folder>N</create_parent_folder>
+      <exec_per_row>N</exec_per_row>
+      <filename>${PROJECT_HOME}/0007-pgp-stream-roundtrip.hpl</filename>
+      <loglevel>Basic</loglevel>
+      <parameters>
+        <pass_all_parameters>Y</pass_all_parameters>
+      </parameters>
+      <params_from_previous>N</params_from_previous>
+      <run_configuration>local</run_configuration>
+      <set_append_logfile>N</set_append_logfile>
+      <set_logfile>N</set_logfile>
+      <wait_until_finished>Y</wait_until_finished>
+      <parallel>N</parallel>
+      <xloc>460</xloc>
+      <yloc>80</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>Abort workflow</name>
+      <description/>
+      <type>ABORT</type>
+      <attributes/>
+      <message>The PGP stream transform round trip failed</message>
+      <loglevel>ERROR</loglevel>
+      <always_log_rows>N</always_log_rows>
+      <parallel>N</parallel>
+      <xloc>460</xloc>
+      <yloc>220</yloc>
+      <attributes_hac/>
+    </action>
+  </actions>
+  <hops>
+    <hop>
+      <from>Start</from>
+      <to>create a throwaway keyring</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>Y</unconditional>
+    </hop>
+    <hop>
+      <from>create a throwaway keyring</from>
+      <to>round trip a field through the transforms</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>create a throwaway keyring</from>
+      <to>Abort workflow</to>
+      <enabled>Y</enabled>
+      <evaluation>N</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>round trip a field through the transforms</from>
+      <to>Abort workflow</to>
+      <enabled>Y</enabled>
+      <evaluation>N</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+  </hops>
+  <notepads>
+  </notepads>
+  <attributes/>
+</workflow>
diff --git a/integration-tests/pgp/main-0008-pgp-filename-command-injection.hwf 
b/integration-tests/pgp/main-0008-pgp-filename-command-injection.hwf
new file mode 100644
index 0000000000..9718962742
--- /dev/null
+++ b/integration-tests/pgp/main-0008-pgp-filename-command-injection.hwf
@@ -0,0 +1,254 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<workflow>
+  <name>main-0008-pgp-filename-command-injection</name>
+  <name_sync_with_filename>Y</name_sync_with_filename>
+  <description>A file dropped into a watched folder carries a command in its 
name. Signing the
+  folder must not run it. The check runs whether or not the PGP action itself 
succeeded, because
+  the command would execute before GnuPG is ever reached.</description>
+  <extended_description/>
+  <workflow_version/>
+  <created_user>-</created_user>
+  <created_date>2026/09/10 00:00:00.000</created_date>
+  <modified_user>-</modified_user>
+  <modified_date>2026/09/10 00:00:00.000</modified_date>
+  <parameters>
+    </parameters>
+  <actions>
+    <action>
+      <name>Start</name>
+      <description/>
+      <type>SPECIAL</type>
+      <attributes/>
+      <DayOfMonth>1</DayOfMonth>
+      <doNotWaitOnFirstExecution>N</doNotWaitOnFirstExecution>
+      <hour>12</hour>
+      <intervalMinutes>60</intervalMinutes>
+      <intervalSeconds>0</intervalSeconds>
+      <minutes>0</minutes>
+      <repeat>N</repeat>
+      <schedulerType>0</schedulerType>
+      <weekDay>1</weekDay>
+      <parallel>N</parallel>
+      <xloc>80</xloc>
+      <yloc>80</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>drop a file whose name carries a command</name>
+      <description/>
+      <type>SHELL</type>
+      <attributes/>
+      <filename/>
+      <work_directory>${PROJECT_HOME}</work_directory>
+      <arg_from_previous>N</arg_from_previous>
+      <exec_per_row>N</exec_per_row>
+      <set_logfile>N</set_logfile>
+      <logfile/>
+      <set_append_logfile>N</set_append_logfile>
+      <logext/>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <insertScript>Y</insertScript>
+      <script>#!/bin/bash
+set -e
+
+GNUPG_HOME=${PROJECT_HOME}/output/gnupg
+WRAPPER=${PROJECT_HOME}/output/gpg-in-project.sh
+DROP=${PROJECT_HOME}/output/injection-drop
+SIGNED=${PROJECT_HOME}/output/injection-signed
+
+GPG_BIN=$(command -v gpg || true)
+if [ -z "$GPG_BIN" ]; then
+  echo "gpg is not installed; the PGP actions cannot run without it"
+  exit 1
+fi
+
+rm -rf "$GNUPG_HOME" "$DROP" "$SIGNED"
+mkdir -p "$GNUPG_HOME" "$DROP" "$SIGNED"
+chmod 700 "$GNUPG_HOME"
+
+printf '#!/bin/sh\nexec "%s" --homedir "%s" "$@"\n' "$GPG_BIN" "$GNUPG_HOME" 
&gt; "$WRAPPER"
+chmod 700 "$WRAPPER"
+
+"$GPG_BIN" --homedir "$GNUPG_HOME" --batch --yes --pinentry-mode loopback 
--passphrase '' \
+  --quiet --quick-generate-key "Hop Integration Test 
&lt;[email protected]&gt;" default default never 2&gt;&amp;1
+
+# Nothing may have left this behind from an earlier run.
+rm -f "$HOME/hop-pgp-exploit-marker"
+
+# A file name cannot hold a path separator, so the payload uses none: it 
changes directory and
+# then creates the marker. If anything hands this name to a shell, the marker 
appears.
+printf 'id,amount\n1,100\n' &gt; "$DROP"'/report$(cd $HOME &amp;&amp; touch 
hop-pgp-exploit-marker).csv'
+
+ls -1 "$DROP"
+</script>
+      <loglevel>Basic</loglevel>
+      <parallel>N</parallel>
+      <xloc>240</xloc>
+      <yloc>80</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>sign the folder</name>
+      <description/>
+      <type>PGP_ENCRYPT_FILES</type>
+      <attributes/>
+      <gpglocation>${PROJECT_HOME}/output/gpg-in-project.sh</gpglocation>
+      <arg_from_previous>N</arg_from_previous>
+      <include_subfolders>N</include_subfolders>
+      <add_result_filesname>N</add_result_filesname>
+      <destination_is_a_file>N</destination_is_a_file>
+      <create_destination_folder>N</create_destination_folder>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <SpecifyFormat>N</SpecifyFormat>
+      <date_time_format/>
+      <nr_errors_less_than>10</nr_errors_less_than>
+      <success_condition>success_if_no_errors</success_condition>
+      <AddDateBeforeExtension>N</AddDateBeforeExtension>
+      <DoNotKeepFolderStructure>N</DoNotKeepFolderStructure>
+      <iffileexists>overwrite_file</iffileexists>
+      <destinationFolder/>
+      <ifmovedfileexists>do_nothing</ifmovedfileexists>
+      <moved_date_time_format/>
+      <create_move_to_folder>N</create_move_to_folder>
+      <add_moved_date>N</add_moved_date>
+      <add_moved_time>N</add_moved_time>
+      <SpecifyMoveFormat>N</SpecifyMoveFormat>
+      <AddMovedDateBeforeExtension>N</AddMovedDateBeforeExtension>
+      <asciiMode>Y</asciiMode>
+      <fields>
+        <field>
+          <action_type>sign</action_type>
+          
<source_filefolder>${PROJECT_HOME}/output/injection-drop</source_filefolder>
+          <wildcard>.*\.csv</wildcard>
+          <userid></userid>
+          
<destination_filefolder>${PROJECT_HOME}/output/injection-signed</destination_filefolder>
+        </field>
+      </fields>
+      <parallel>N</parallel>
+      <xloc>460</xloc>
+      <yloc>80</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>nothing in the filename was executed</name>
+      <description/>
+      <type>SHELL</type>
+      <attributes/>
+      <filename/>
+      <work_directory>${PROJECT_HOME}</work_directory>
+      <arg_from_previous>N</arg_from_previous>
+      <exec_per_row>N</exec_per_row>
+      <set_logfile>N</set_logfile>
+      <logfile/>
+      <set_append_logfile>N</set_append_logfile>
+      <logext/>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <insertScript>Y</insertScript>
+      <script>#!/bin/bash
+set -e
+
+MARKER=$HOME/hop-pgp-exploit-marker
+SIGNED=${PROJECT_HOME}/output/injection-signed
+
+failed=0
+
+if [ -e "$MARKER" ]; then
+  echo "SECURITY: the command embedded in the file name was executed"
+  echo "a shell created $MARKER while the PGP action was running"
+  rm -f "$MARKER"
+  failed=1
+else
+  echo "the command embedded in the file name was not executed"
+fi
+
+# The name also has to have reached gpg intact, not merely been rejected.
+signed=$(find "$SIGNED" -type f | wc -l | tr -d ' ')
+if [ "$signed" -ne 1 ]; then
+  echo "expected the dropped file to be signed, found $signed signatures"
+  failed=1
+fi
+
+exit $failed
+</script>
+      <loglevel>Basic</loglevel>
+      <parallel>N</parallel>
+      <xloc>680</xloc>
+      <yloc>80</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>Abort workflow</name>
+      <description/>
+      <type>ABORT</type>
+      <attributes/>
+      <message>A command embedded in a file name reached a shell</message>
+      <loglevel>ERROR</loglevel>
+      <always_log_rows>N</always_log_rows>
+      <parallel>N</parallel>
+      <xloc>680</xloc>
+      <yloc>220</yloc>
+      <attributes_hac/>
+    </action>
+  </actions>
+  <hops>
+    <hop>
+      <from>Start</from>
+      <to>drop a file whose name carries a command</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>Y</unconditional>
+    </hop>
+    <hop>
+      <from>drop a file whose name carries a command</from>
+      <to>sign the folder</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>sign the folder</from>
+      <to>nothing in the filename was executed</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>Y</unconditional>
+    </hop>
+    <hop>
+      <from>drop a file whose name carries a command</from>
+      <to>Abort workflow</to>
+      <enabled>Y</enabled>
+      <evaluation>N</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>nothing in the filename was executed</from>
+      <to>Abort workflow</to>
+      <enabled>Y</enabled>
+      <evaluation>N</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+  </hops>
+  <notepads>
+  </notepads>
+  <attributes/>
+</workflow>
diff --git 
a/plugins/actions/pgpfiles/src/main/java/org/apache/hop/workflow/actions/pgpencryptfiles/GPG.java
 
b/plugins/actions/pgpfiles/src/main/java/org/apache/hop/workflow/actions/pgpencryptfiles/GPG.java
index 079779e876..d698382133 100644
--- 
a/plugins/actions/pgpfiles/src/main/java/org/apache/hop/workflow/actions/pgpencryptfiles/GPG.java
+++ 
b/plugins/actions/pgpfiles/src/main/java/org/apache/hop/workflow/actions/pgpencryptfiles/GPG.java
@@ -24,9 +24,10 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
 import java.io.OutputStreamWriter;
+import java.util.ArrayList;
+import java.util.List;
 import org.apache.commons.vfs2.FileObject;
 import org.apache.commons.vfs2.FileType;
-import org.apache.hop.core.Const;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.logging.ILogChannel;
 import org.apache.hop.core.util.Utils;
@@ -38,11 +39,21 @@ import org.apache.hop.i18n.BaseMessages;
 public class GPG {
 
   private static final Class<?> PKG = ActionPGPEncryptFiles.class;
-  public static final String CONST_BATCH_YES = "--batch --yes";
 
   private ILogChannel log;
 
-  private final String gnuPGCommand = "--batch --armor ";
+  /** Options prepended when GnuPG reads the data to process from stdin 
instead of a file. */
+  private static final List<String> GNU_PG_COMMAND = List.of("--batch", 
"--armor");
+
+  /** Options prepended to the file based operations. */
+  private static final List<String> BATCH_YES = List.of("--batch", "--yes");
+
+  /**
+   * Ends GnuPG's option parsing. Everything after it is a file operand, so a 
name that starts with
+   * a dash is opened rather than read as a switch. Scanned folders decide 
these names, and a
+   * relative one such as {@code -o} or {@code --status-fd} is a legal 
filename.
+   */
+  private static final String END_OF_OPTIONS = "--";
 
   /** gpg program location */
   private String gpgexe = "/usr/local/bin/gpg";
@@ -149,31 +160,38 @@ public class GPG {
   }
 
   /**
-   * Runs GnuPG external program
+   * Runs GnuPG external program.
    *
-   * @param commandArgs command line arguments
-   * @param inputStr key ID of the key in GnuPG's key database
-   * @param fileMode
+   * <p>The arguments are handed to the process as a list, never as a single 
command line: they are
+   * passed to GnuPG verbatim and are never interpreted by a shell. Filenames 
reach this method from
+   * directory scans, so any character a filesystem accepts has to survive 
unchanged.
+   *
+   * @param args command line arguments
+   * @param inputStr data written to the standard input of the process, or 
null. This is the
+   *     passphrase for every caller except {@link #encrypt(String, String)}, 
which puts the data to
+   *     encrypt there instead.
+   * @param fileMode when false, {@code --batch --armor} is prepended to the 
argument list
    * @return result
    * @throws HopException
    */
-  private String execGnuPG(String commandArgs, String inputStr, boolean 
fileMode)
+  private String execGnuPG(List<String> args, String inputStr, boolean 
fileMode)
       throws HopException {
     Process p;
-    String command = getGpgExeFile() + " " + (fileMode ? "" : gnuPGCommand + " 
") + commandArgs;
+    List<String> command = new ArrayList<>();
+    command.add(getGpgExeFile());
+    if (!fileMode) {
+      command.addAll(GNU_PG_COMMAND);
+    }
+    command.addAll(args);
 
     if (log.isDebug()) {
-      log.logDebug(BaseMessages.getString(PKG, "GPG.RunningCommand", command));
+      // Secrets are handed to GnuPG over stdin, so the argument list is safe 
to log.
+      log.logDebug(BaseMessages.getString(PKG, "GPG.RunningCommand", 
String.join(" ", command)));
     }
     String retval;
 
     try {
-      if (Const.isWindows()) {
-        p = Runtime.getRuntime().exec(command);
-      } else {
-        ProcessBuilder processBuilder = new ProcessBuilder("/bin/sh", "-c", 
command);
-        p = processBuilder.start();
-      }
+      p = new ProcessBuilder(command).start();
     } catch (IOException io) {
       throw new HopException(BaseMessages.getString(PKG, "GPG.IOException"), 
io);
     }
@@ -182,20 +200,20 @@ public class GPG {
     ProcessStreamReader psrStdErr = new 
ProcessStreamReader(p.getErrorStream());
     psrStdOut.start();
     psrStdErr.start();
-    if (inputStr != null) {
-      BufferedWriter out = new BufferedWriter(new 
OutputStreamWriter(p.getOutputStream()));
-      try {
+    // Standard input is closed either way: without data on it GnuPG must see 
end of file rather
+    // than wait for input that is never coming.
+    BufferedWriter out = new BufferedWriter(new 
OutputStreamWriter(p.getOutputStream()));
+    try {
+      if (inputStr != null) {
         out.write(inputStr);
-      } catch (IOException io) {
-        throw new HopException(BaseMessages.getString(PKG, 
"GPG.ExceptionWrite"), io);
-      } finally {
-        if (out != null) {
-          try {
-            out.close();
-          } catch (Exception e) {
-            // Ignore
-          }
-        }
+      }
+    } catch (IOException io) {
+      throw new HopException(BaseMessages.getString(PKG, 
"GPG.ExceptionWrite"), io);
+    } finally {
+      try {
+        out.close();
+      } catch (Exception e) {
+        // Ignore: GnuPG may have exited already.
       }
     }
 
@@ -225,6 +243,61 @@ public class GPG {
     return retval;
   }
 
+  /**
+   * Asks GnuPG to read the passphrase from standard input.
+   *
+   * <p>On the command line a passphrase is readable by every other user on 
the machine through the
+   * process table, so it travels over stdin instead. GnuPG 2.1 and later 
ignore a passphrase given
+   * this way unless the loopback pinentry is requested as well: without it 
the agent tries to
+   * prompt and the operation fails with "Inappropriate ioctl for device".
+   *
+   * @param args argument list to append to
+   */
+  private static void addPassPhraseFromStdin(List<String> args) {
+    args.add("--pinentry-mode");
+    args.add("loopback");
+    args.add("--passphrase-fd");
+    args.add("0");
+  }
+
+  /**
+   * Adds the recipient the data is encrypted to.
+   *
+   * <p>An empty user ID is refused rather than omitted. GnuPG would fall back 
to whatever {@code
+   * default-recipient} or {@code encrypt-to} is configured in {@code 
gpg.conf}, so the data would
+   * be sealed to a key nobody named and the caller would have no way to tell.
+   *
+   * @param args argument list to append to
+   * @param userID the recipient key, required
+   */
+  private static void addRecipient(List<String> args, String userID) throws 
HopException {
+    if (Utils.isEmpty(userID)) {
+      throw new HopException(BaseMessages.getString(PKG, "GPG.UserIDMissing"));
+    }
+    args.add("-r");
+    args.add(userID);
+  }
+
+  /** Arguments for signing the given file with a passphrase supplied over 
stdin. */
+  private static List<String> signArgs(String filename) {
+    List<String> args = new ArrayList<>();
+    addPassPhraseFromStdin(args);
+    args.add("--sign");
+    args.add(END_OF_OPTIONS);
+    args.add(filename);
+    return args;
+  }
+
+  /** Arguments for decrypting the given file with a passphrase supplied over 
stdin. */
+  private static List<String> decryptArgs(String filename) {
+    List<String> args = new ArrayList<>();
+    addPassPhraseFromStdin(args);
+    args.add("--decrypt");
+    args.add(END_OF_OPTIONS);
+    args.add(filename);
+    return args;
+  }
+
   /**
    * Decrypt a file
    *
@@ -253,19 +326,18 @@ public class GPG {
       throws HopException {
 
     try {
-      execGnuPG(
-          "--batch --yes "
-              + (Utils.isEmpty(passPhrase) ? "" : "--passphrase " + "\"" + 
passPhrase + "\" ")
-              + "--output "
-              + "\""
-              + decryptedFilename
-              + "\" "
-              + "--decrypt "
-              + "\""
-              + cryptedFilename
-              + "\"",
-          null,
-          true);
+      List<String> args = new ArrayList<>(BATCH_YES);
+      boolean withPassPhrase = !Utils.isEmpty(passPhrase);
+      if (withPassPhrase) {
+        addPassPhraseFromStdin(args);
+      }
+      args.add("--output");
+      args.add(decryptedFilename);
+      args.add("--decrypt");
+      args.add(END_OF_OPTIONS);
+      args.add(cryptedFilename);
+
+      execGnuPG(args, withPassPhrase ? passPhrase : null, true);
 
     } catch (Exception e) {
       throw new HopException(e);
@@ -276,7 +348,8 @@ public class GPG {
    * Encrypt a file
    *
    * @param filename file to encrypt
-   * @param userID specific user id key
+   * @param userID specific user id key, required: encrypting without one 
would let GnuPG fall back
+   *     to the default recipient in gpg.conf
    * @param cryptedFilename crypted filename
    * @param asciiMode output ASCII file
    * @throws HopException
@@ -292,7 +365,8 @@ public class GPG {
    * Encrypt a file
    *
    * @param filename file to encrypt
-   * @param userID specific user id key
+   * @param userID specific user id key, required: encrypting without one 
would let GnuPG fall back
+   *     to the default recipient in gpg.conf
    * @param cryptedFilename crypted filename
    * @param asciiMode output ASCII file
    * @throws HopException
@@ -300,23 +374,18 @@ public class GPG {
   public void encryptFile(String filename, String userID, String 
cryptedFilename, boolean asciiMode)
       throws HopException {
     try {
-      execGnuPG(
-          CONST_BATCH_YES
-              + (asciiMode ? " -a" : "")
-              + " -r "
-              + "\""
-              + Const.NVL(userID, "")
-              + "\" "
-              + "--output "
-              + "\""
-              + cryptedFilename
-              + "\" "
-              + "--encrypt  "
-              + "\""
-              + filename
-              + "\"",
-          null,
-          true);
+      List<String> args = new ArrayList<>(BATCH_YES);
+      if (asciiMode) {
+        args.add("-a");
+      }
+      addRecipient(args, userID);
+      args.add("--output");
+      args.add(cryptedFilename);
+      args.add("--encrypt");
+      args.add(END_OF_OPTIONS);
+      args.add(filename);
+
+      execGnuPG(args, null, true);
 
     } catch (Exception e) {
       throw new HopException(e);
@@ -327,7 +396,8 @@ public class GPG {
    * Sign and encrypt a file
    *
    * @param file file to encrypt
-   * @param userID specific user id key
+   * @param userID specific user id key, required: encrypting without one 
would let GnuPG fall back
+   *     to the default recipient in gpg.conf
    * @param cryptedFile crypted filename
    * @param asciiMode output ASCII file
    * @throws HopException
@@ -343,7 +413,8 @@ public class GPG {
    * Sign and encrypt a file
    *
    * @param filename file to encrypt
-   * @param userID specific user id key
+   * @param userID specific user id key, required: encrypting without one 
would let GnuPG fall back
+   *     to the default recipient in gpg.conf
    * @param cryptedFilename crypted filename
    * @param asciiMode output ASCII file
    * @throws HopException
@@ -353,22 +424,19 @@ public class GPG {
       throws HopException {
 
     try {
-
-      execGnuPG(
-          CONST_BATCH_YES
-              + (asciiMode ? " -a" : "")
-              + (Utils.isEmpty(userID) ? "" : " -r " + "\"" + userID + "\"")
-              + " "
-              + "--output "
-              + "\""
-              + cryptedFilename
-              + "\" "
-              + "--encrypt --sign "
-              + "\""
-              + filename
-              + "\"",
-          null,
-          true);
+      List<String> args = new ArrayList<>(BATCH_YES);
+      if (asciiMode) {
+        args.add("-a");
+      }
+      addRecipient(args, userID);
+      args.add("--output");
+      args.add(cryptedFilename);
+      args.add("--encrypt");
+      args.add("--sign");
+      args.add(END_OF_OPTIONS);
+      args.add(filename);
+
+      execGnuPG(args, null, true);
     } catch (Exception e) {
       throw new HopException(e);
     }
@@ -386,21 +454,21 @@ public class GPG {
   public void signFile(String filename, String userID, String signedFilename, 
boolean asciiMode)
       throws HopException {
     try {
-      execGnuPG(
-          CONST_BATCH_YES
-              + (asciiMode ? " -a" : "")
-              + (Utils.isEmpty(userID) ? "" : " -r " + "\"" + userID + "\"")
-              + " "
-              + "--output "
-              + "\""
-              + signedFilename
-              + "\" "
-              + (asciiMode ? "--clearsign " : "--sign ")
-              + "\""
-              + filename
-              + "\"",
-          null,
-          true);
+      List<String> args = new ArrayList<>(BATCH_YES);
+      if (asciiMode) {
+        args.add("-a");
+      }
+      if (!Utils.isEmpty(userID)) {
+        args.add("-r");
+        args.add(userID);
+      }
+      args.add("--output");
+      args.add(signedFilename);
+      args.add(asciiMode ? "--clearsign" : "--sign");
+      args.add(END_OF_OPTIONS);
+      args.add(filename);
+
+      execGnuPG(args, null, true);
 
     } catch (Exception e) {
       throw new HopException(e);
@@ -444,7 +512,7 @@ public class GPG {
    */
   public void verifySignature(String filename) throws HopException {
 
-    execGnuPG("--batch --verify " + "\"" + filename + "\"", null, true);
+    execGnuPG(List.of("--batch", "--verify", END_OF_OPTIONS, filename), null, 
true);
   }
 
   /**
@@ -457,7 +525,7 @@ public class GPG {
   public void verifyDetachedSignature(String signatureFilename, String 
originalFilename)
       throws HopException {
     execGnuPG(
-        "--batch --verify " + "\"" + signatureFilename + "\" " + "\"" + 
originalFilename + "\"",
+        List.of("--batch", "--verify", END_OF_OPTIONS, signatureFilename, 
originalFilename),
         null,
         true);
   }
@@ -478,19 +546,25 @@ public class GPG {
    * Encrypt a string
    *
    * @param plainText input string to encrypt
-   * @param keyID key ID of the key in GnuPG's key database to encrypt with
+   * @param keyID key ID of the key in GnuPG's key database to encrypt with, 
required: encrypting
+   *     without one would let GnuPG fall back to the default recipient in 
gpg.conf
    * @return encrypted string
    * @throws HopException
    */
   public String encrypt(String plainText, String keyID) throws HopException {
-    return execGnuPG("-r \"" + keyID + "\" --encrypt ", plainText, false);
+    List<String> args = new ArrayList<>();
+    addRecipient(args, keyID);
+    args.add("--encrypt");
+
+    return execGnuPG(args, plainText, false);
   }
 
   /**
    * Signs and encrypts a string
    *
    * @param plainText input string to encrypt
-   * @param userID key ID of the key in GnuPG's key database to encrypt with
+   * @param userID key ID of the key in GnuPG's key database to encrypt with, 
required: encrypting
+   *     without one would let GnuPG fall back to the default recipient in 
gpg.conf
    * @param passPhrase passphrase for the personal private key to sign with
    * @return encrypted string
    * @throws HopException
@@ -500,10 +574,14 @@ public class GPG {
     try {
       createTempFile(plainText);
 
-      return execGnuPG(
-          "-r \"" + userID + "\" --passphrase-fd 0 -se \"" + getTempFileName() 
+ "\"",
-          passPhrase,
-          false);
+      List<String> args = new ArrayList<>();
+      addRecipient(args, userID);
+      addPassPhraseFromStdin(args);
+      args.add("-se");
+      args.add(END_OF_OPTIONS);
+      args.add(getTempFileName());
+
+      return execGnuPG(args, passPhrase, false);
     } finally {
 
       deleteTempFile();
@@ -523,8 +601,7 @@ public class GPG {
 
       createTempFile(stringToSign);
 
-      retval =
-          execGnuPG("--passphrase-fd 0 --sign \"" + getTempFileName() + "\"", 
passPhrase, false);
+      retval = execGnuPG(signArgs(getTempFileName()), passPhrase, false);
 
     } finally {
       deleteTempFile();
@@ -544,8 +621,7 @@ public class GPG {
     try {
       createTempFile(cryptedText);
 
-      return execGnuPG(
-          "--passphrase-fd 0 --decrypt \"" + getTempFileName() + "\"", 
passPhrase, false);
+      return execGnuPG(decryptArgs(getTempFileName()), passPhrase, false);
 
     } finally {
       deleteTempFile();
diff --git 
a/plugins/actions/pgpfiles/src/main/resources/org/apache/hop/workflow/actions/pgpencryptfiles/messages/messages_en_US.properties
 
b/plugins/actions/pgpfiles/src/main/resources/org/apache/hop/workflow/actions/pgpencryptfiles/messages/messages_en_US.properties
index b621de1b27..56c797d481 100644
--- 
a/plugins/actions/pgpfiles/src/main/resources/org/apache/hop/workflow/actions/pgpencryptfiles/messages/messages_en_US.properties
+++ 
b/plugins/actions/pgpfiles/src/main/resources/org/apache/hop/workflow/actions/pgpencryptfiles/messages/messages_en_US.properties
@@ -158,3 +158,4 @@ GPG.GPGNotAFile=[{0}] is not a valid GPG file\! Please 
provid a filename.
 GPG.IOException=IO exception while writing running command\!
 GPG.RunningCommand=Running GPG command [{0}] ...
 GPG.TempFileCreated=Temporary GPG file [{0}] was created.
+GPG.UserIDMissing=No user ID (recipient key) was specified for this 
encryption\!
diff --git 
a/plugins/actions/pgpfiles/src/test/java/org/apache/hop/workflow/actions/pgpencryptfiles/ActionPGPEncryptFilesSignTest.java
 
b/plugins/actions/pgpfiles/src/test/java/org/apache/hop/workflow/actions/pgpencryptfiles/ActionPGPEncryptFilesSignTest.java
index 82e2a580c4..cb9e212779 100644
--- 
a/plugins/actions/pgpfiles/src/test/java/org/apache/hop/workflow/actions/pgpencryptfiles/ActionPGPEncryptFilesSignTest.java
+++ 
b/plugins/actions/pgpfiles/src/test/java/org/apache/hop/workflow/actions/pgpencryptfiles/ActionPGPEncryptFilesSignTest.java
@@ -204,6 +204,44 @@ class ActionPGPEncryptFilesSignTest {
     assertEquals(PLAIN_TEXT, Files.readString(opened), "the round trip must 
preserve the content");
   }
 
+  /**
+   * Filenames are discovered by scanning a folder, so their content is chosen 
by whoever can write
+   * to it. They must reach gpg as literal arguments and never be interpreted.
+   *
+   * <p>Each name below expands to something different when a shell looks at 
it: if one ever were
+   * evaluated, gpg would be handed a name that does not exist and the action 
would report an error.
+   * Signing successfully is therefore the assertion that the name was passed 
through untouched. See
+   * https://github.com/apache/hop/issues/8311.
+   */
+  @Test
+  void filenamesAreNotInterpretedByAShell() throws Exception {
+    List<String> hostileNames =
+        List.of(
+            "report$(echo pwned).csv",
+            "report`echo pwned`.csv",
+            "report$HOME.csv",
+            "report\";echo pwned;\".csv",
+            "report'; echo pwned; '.csv");
+
+    for (String name : hostileNames) {
+      Path source = Files.writeString(work.resolve(name), PLAIN_TEXT);
+      Path signed = work.resolve(name + ".asc");
+
+      ActionPGPEncryptFiles sign = encryptAction();
+      sign.setAsciiMode(true);
+      sign.getPgpFiles().add(pgpFile(ActionPGPEncryptFiles.ActionType.SIGN, 
source, signed, ""));
+
+      Result result = sign.execute(new Result(), 0);
+
+      assertEquals(0, result.getNrErrors(), "signing must not report errors 
for: " + name);
+      assertTrue(result.getResult(), "signing must succeed for: " + name);
+      assertTrue(Files.exists(signed), "the signed file must be written for: " 
+ name);
+      assertTrue(
+          Files.readString(signed).startsWith("-----BEGIN PGP SIGNED 
MESSAGE-----"),
+          "the output must be a clear-signed message for: " + name);
+    }
+  }
+
   private ActionPGPEncryptFiles encryptAction() {
     ActionPGPEncryptFiles action = new ActionPGPEncryptFiles("PGP encrypt 
files");
     attachToWorkflow(action);
diff --git 
a/plugins/actions/pgpfiles/src/test/java/org/apache/hop/workflow/actions/pgpencryptfiles/GpgArgumentPassingTest.java
 
b/plugins/actions/pgpfiles/src/test/java/org/apache/hop/workflow/actions/pgpencryptfiles/GpgArgumentPassingTest.java
new file mode 100644
index 0000000000..7ec302f877
--- /dev/null
+++ 
b/plugins/actions/pgpfiles/src/test/java/org/apache/hop/workflow/actions/pgpencryptfiles/GpgArgumentPassingTest.java
@@ -0,0 +1,417 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.workflow.actions.pgpencryptfiles;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.util.Comparator;
+import java.util.List;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+
+/**
+ * Asserts what {@link GPG} actually hands to the GnuPG process, by standing a 
recorder script in
+ * for the binary and reading back the argument vector and standard input it 
was given. See
+ * https://github.com/apache/hop/issues/8311.
+ *
+ * <p>POSIX only: the recorder is a shell script. Argument passing on Windows 
is not covered here.
+ */
+@EnabledOnOs({OS.LINUX, OS.MAC})
+class GpgArgumentPassingTest {
+
+  /**
+   * Names that a shell would rewrite. Every one is a legal POSIX filename, 
and every one is what an
+   * attacker who can write into a scanned folder would choose.
+   */
+  private static final List<String> HOSTILE_NAMES =
+      List.of(
+          "report$(echo pwned).csv",
+          "report`echo pwned`.csv",
+          "report$HOME.csv",
+          "report;echo pwned;.csv",
+          "report\";echo pwned;\".csv",
+          "report'; echo pwned; '.csv",
+          "report file with spaces.csv",
+          "report&&echo pwned.csv",
+          "report|echo pwned.csv",
+          "report*.csv");
+
+  private static final String PASSPHRASE = "s3cr3t-do-not-leak";
+
+  /** Created by the payloads below if a shell ever evaluates them, and by 
nothing else. */
+  private static final String EXPLOIT_MARKER = "hop-pgp-exploit-marker";
+
+  private Path sandbox;
+  private Path recorder;
+  private Path record;
+  private Path stdinRecord;
+  private ILogChannel log;
+  private IVariables variables;
+
+  @BeforeAll
+  static void initLogging() {
+    HopLogStore.init();
+  }
+
+  @BeforeEach
+  void createRecorder() throws Exception {
+    sandbox = Files.createTempDirectory("hop-gpg-argv");
+    record = sandbox.resolve("argv.txt");
+    stdinRecord = sandbox.resolve("stdin.txt");
+    recorder = sandbox.resolve("gpg-recorder.sh");
+
+    // Writes one argument per line, keeps whatever arrived on stdin, and 
succeeds, so the caller
+    // carries on as if GnuPG had run.
+    Files.writeString(
+        recorder,
+        "#!/bin/sh\n"
+            + "{ for a in \"$@\"; do printf '%s\\n' \"$a\"; done; } > '"
+            + record
+            + "'\n"
+            + "cat > '"
+            + stdinRecord
+            + "'\n"
+            + "exit 0\n",
+        StandardCharsets.UTF_8);
+    Files.setPosixFilePermissions(recorder, 
PosixFilePermissions.fromString("rwx------"));
+
+    log = new LogChannel("GpgArgumentPassingTest");
+    variables = new Variables();
+  }
+
+  @AfterEach
+  void removeSandbox() throws Exception {
+    // If a payload ever did run, do not leave its marker behind for the next 
test to trip over.
+    
Files.deleteIfExists(Path.of(System.getProperty("user.dir")).resolve(EXPLOIT_MARKER));
+    deleteRecursively(sandbox);
+  }
+
+  @Test
+  void signFilePassesFilenamesLiterally() throws Exception {
+    for (String name : HOSTILE_NAMES) {
+      gpg().signFile(name, "", "signed-" + name, true);
+      assertPassedLiterally(name, "signFile source");
+      assertPassedLiterally("signed-" + name, "signFile destination");
+    }
+  }
+
+  @Test
+  void encryptFilePassesFilenamesLiterally() throws Exception {
+    for (String name : HOSTILE_NAMES) {
+      gpg().encryptFile(name, "[email protected]", "encrypted-" + name, false);
+      assertPassedLiterally(name, "encryptFile source");
+      assertPassedLiterally("encrypted-" + name, "encryptFile destination");
+      assertRecipient("[email protected]", "encryptFile");
+    }
+  }
+
+  @Test
+  void signAndEncryptFilePassesFilenamesLiterally() throws Exception {
+    for (String name : HOSTILE_NAMES) {
+      gpg().signAndEncryptFile(name, "[email protected]", "sealed-" + name, 
true);
+      assertPassedLiterally(name, "signAndEncryptFile source");
+      assertPassedLiterally("sealed-" + name, "signAndEncryptFile 
destination");
+      assertRecipient("[email protected]", "signAndEncryptFile");
+    }
+  }
+
+  @Test
+  void decryptFilePassesFilenamesLiterally() throws Exception {
+    for (String name : HOSTILE_NAMES) {
+      gpg().decryptFile(name, "", "opened-" + name);
+      assertPassedLiterally(name, "decryptFile source");
+      assertPassedLiterally("opened-" + name, "decryptFile destination");
+    }
+  }
+
+  @Test
+  void verifySignaturePassesFilenamesLiterally() throws Exception {
+    for (String name : HOSTILE_NAMES) {
+      gpg().verifySignature(name);
+      assertPassedLiterally(name, "verifySignature filename");
+    }
+  }
+
+  @Test
+  void verifyDetachedSignaturePassesFilenamesLiterally() throws Exception {
+    for (String name : HOSTILE_NAMES) {
+      gpg().verifyDetachedSignature(name, "original-" + name);
+      assertPassedLiterally(name, "verifyDetachedSignature signature");
+      assertPassedLiterally("original-" + name, "verifyDetachedSignature 
original");
+    }
+  }
+
+  /** Used by the PGP encrypt stream transform in plugins/transforms/pgp. */
+  @Test
+  void encryptStringPassesTheKeyIdLiterally() throws Exception {
+    for (String keyId : HOSTILE_NAMES) {
+      gpg().encrypt("some data", keyId);
+      assertPassedLiterally(keyId, "encrypt key id");
+      assertRecipient(keyId, "encrypt");
+    }
+  }
+
+  /** Used by the PGP decrypt stream transform in plugins/transforms/pgp. */
+  @Test
+  void decryptStringSendsThePassphraseOverStdin() throws Exception {
+    gpg().decrypt("some data", PASSPHRASE);
+    assertPassphraseOnStdinOnly("decrypt");
+  }
+
+  @Test
+  void signStringSendsThePassphraseOverStdin() throws Exception {
+    gpg().sign("some data", PASSPHRASE);
+    assertPassphraseOnStdinOnly("sign");
+  }
+
+  @Test
+  void signAndEncryptStringSendsThePassphraseOverStdin() throws Exception {
+    gpg().signAndEncrypt("some data", "[email protected]", PASSPHRASE);
+    assertPassphraseOnStdinOnly("signAndEncrypt");
+  }
+
+  @Test
+  void decryptFileSendsThePassphraseOverStdin() throws Exception {
+    gpg().decryptFile("sealed.asc", PASSPHRASE, "opened.txt");
+    assertPassphraseOnStdinOnly("decryptFile");
+  }
+
+  /** No passphrase means no file descriptor to read it from, and nothing on 
stdin. */
+  @Test
+  void decryptFileWithoutAPassphraseAsksForNoFileDescriptor() throws Exception 
{
+    gpg().decryptFile("sealed.asc", "", "opened.txt");
+    List<String> args = recordedArguments();
+    assertFalse(
+        args.contains("--passphrase-fd"),
+        "without a passphrase GnuPG must not be told to read one: " + args);
+    assertEquals("", recordedStdin(), "nothing must be written to stdin 
without a passphrase");
+  }
+
+  @Test
+  void anEmptyUserIdOmitsTheRecipientFlagWhenSigning() throws Exception {
+    gpg().signFile("plain.txt", "", "plain.txt.asc", true);
+    assertFalse(
+        recordedArguments().contains("-r"),
+        "an empty user id must not be passed to GnuPG as an empty recipient");
+
+    gpg().signFile("plain.txt", "[email protected]", "plain.txt.asc", true);
+    assertRecipient("[email protected]", "signFile");
+  }
+
+  /**
+   * Encrypting without a recipient would let GnuPG fall back to the {@code 
default-recipient} in
+   * {@code gpg.conf}, sealing the data to a key the workflow never named.
+   */
+  @Test
+  void anEmptyUserIdIsRefusedOnEveryEncryptPath() throws Exception {
+    GPG gpg = gpg();
+    assertThrows(
+        HopException.class,
+        () -> gpg.encryptFile("plain.txt", "", "sealed.asc", false),
+        "encryptFile must refuse an empty recipient");
+    assertThrows(
+        HopException.class,
+        () -> gpg.signAndEncryptFile("plain.txt", "", "sealed.asc", false),
+        "signAndEncryptFile must refuse an empty recipient");
+    assertThrows(
+        HopException.class,
+        () -> gpg.encrypt("some data", ""),
+        "encrypt must refuse an empty recipient");
+    assertThrows(
+        HopException.class,
+        () -> gpg.signAndEncrypt("some data", "", PASSPHRASE),
+        "signAndEncrypt must refuse an empty recipient");
+
+    assertFalse(Files.exists(record), "GnuPG must not be started without a 
recipient");
+  }
+
+  /**
+   * A file operand that starts with a dash is a legal name a scanned folder 
can produce. GnuPG
+   * option-parses it unless the argument list ends option processing first.
+   */
+  @Test
+  void filenamesThatLookLikeOptionsAreMarkedAsOperands() throws Exception {
+    gpg().verifySignature("--status-fd");
+    assertOperand("--status-fd", "verifySignature");
+
+    gpg().signFile("-o", "", "signed.asc", true);
+    assertOperand("-o", "signFile");
+
+    gpg().encryptFile("--output", "[email protected]", "sealed.asc", false);
+    assertOperand("--output", "encryptFile");
+
+    gpg().decryptFile("-r", "", "opened.txt");
+    assertOperand("-r", "decryptFile");
+
+    gpg().verifyDetachedSignature("-o.sig", "-o");
+    assertOperand("-o.sig", "verifyDetachedSignature");
+  }
+
+  /**
+   * A command substitution runs with whatever working directory the Hop 
process has, so a marker
+   * file appearing there is the evidence that a shell evaluated the name.
+   */
+  @Test
+  void aCommandSubstitutionInAFilenameIsNeverExecuted() throws Exception {
+    Path marker = 
Path.of(System.getProperty("user.dir")).resolve(EXPLOIT_MARKER);
+    Files.deleteIfExists(marker);
+
+    // Reads as one file name, and every method below is handed it as such.
+    String payload = "report$(touch " + EXPLOIT_MARKER + ").csv";
+
+    gpg().verifySignature(payload);
+    assertFalse(Files.exists(marker), "verifySignature executed a command from 
a filename");
+
+    gpg().signFile(payload, "", "signed.asc", true);
+    assertFalse(Files.exists(marker), "signFile executed a command from a 
filename");
+
+    gpg().encryptFile(payload, "[email protected]", "sealed.asc", false);
+    assertFalse(Files.exists(marker), "encryptFile executed a command from a 
filename");
+
+    gpg().decryptFile(payload, "", "opened.csv");
+    assertFalse(Files.exists(marker), "decryptFile executed a command from a 
filename");
+
+    gpg().verifyDetachedSignature(payload, payload + ".sig");
+    assertFalse(Files.exists(marker), "verifyDetachedSignature executed a 
command from a filename");
+
+    // And the name still arrived intact rather than being dropped on the 
floor.
+    assertPassedLiterally(payload, "the payload");
+  }
+
+  /** The same, for the key id the PGP stream transforms hand to the string 
based methods. */
+  @Test
+  void aCommandSubstitutionInAKeyIdIsNeverExecuted() throws Exception {
+    Path marker = 
Path.of(System.getProperty("user.dir")).resolve(EXPLOIT_MARKER);
+    Files.deleteIfExists(marker);
+
+    String payload = "user$(touch " + EXPLOIT_MARKER + ")@example.org";
+
+    gpg().encrypt("some data", payload);
+    assertFalse(Files.exists(marker), "encrypt executed a command from a key 
id");
+
+    gpg().signAndEncrypt("some data", payload, PASSPHRASE);
+    assertFalse(Files.exists(marker), "signAndEncrypt executed a command from 
a key id");
+
+    assertPassedLiterally(payload, "the payload");
+  }
+
+  private GPG gpg() throws Exception {
+    return new GPG(recorder.toString(), log, variables);
+  }
+
+  /**
+   * The value has to appear in the recorded vector exactly once and as a 
whole element. A shell
+   * would have rewritten it, split it across elements, or dropped it.
+   */
+  private void assertPassedLiterally(String value, String what) throws 
IOException {
+    List<String> args = recordedArguments();
+    assertTrue(
+        args.contains(value),
+        what
+            + " must reach GnuPG as one literal argument.\nexpected to find: "
+            + value
+            + "\nactual arguments: "
+            + args);
+  }
+
+  /**
+   * The passphrase belongs on stdin and nowhere else: on the command line 
every other user on the
+   * machine can read it out of the process table. GnuPG 2.1 and later ignore 
a passphrase on a file
+   * descriptor unless the loopback pinentry is asked for, so both options 
have to be there.
+   */
+  private void assertPassphraseOnStdinOnly(String what) throws IOException {
+    List<String> args = recordedArguments();
+    assertTrue(
+        args.stream().noneMatch(a -> a.contains(PASSPHRASE)),
+        what + " must not put the passphrase on the command line.\nactual 
arguments: " + args);
+    assertTrue(
+        args.contains("--passphrase-fd"),
+        what + " must ask GnuPG to read the passphrase from a file descriptor: 
" + args);
+    assertEquals(
+        "0",
+        args.get(args.indexOf("--passphrase-fd") + 1),
+        what + " must point GnuPG at stdin for the passphrase: " + args);
+    assertTrue(
+        args.contains("--pinentry-mode"),
+        what + " must request a pinentry mode, or GnuPG 2.1+ ignores the 
passphrase: " + args);
+    assertEquals(
+        "loopback",
+        args.get(args.indexOf("--pinentry-mode") + 1),
+        what + " must request the loopback pinentry: " + args);
+    assertEquals(PASSPHRASE, recordedStdin(), what + " must write the 
passphrase to stdin");
+  }
+
+  private void assertRecipient(String expected, String what) throws 
IOException {
+    List<String> args = recordedArguments();
+    assertTrue(args.contains("-r"), what + " must pass a recipient: " + args);
+    assertEquals(
+        expected,
+        args.get(args.indexOf("-r") + 1),
+        what + " must pass the recipient as its own argument following -r: " + 
args);
+  }
+
+  /**
+   * The value has to sit after the {@code --} that ends GnuPG's option 
parsing. It may well also
+   * appear before it as a genuine switch, so only what follows the terminator 
is looked at.
+   */
+  private void assertOperand(String value, String what) throws IOException {
+    List<String> args = recordedArguments();
+    assertTrue(args.contains("--"), what + " must end option parsing before 
the file: " + args);
+    List<String> operands = args.subList(args.indexOf("--") + 1, args.size());
+    assertTrue(
+        operands.contains(value),
+        what + " must pass " + value + " as a file operand, not an option: " + 
args);
+  }
+
+  private List<String> recordedArguments() throws IOException {
+    assertTrue(Files.exists(record), "GnuPG was never invoked");
+    return Files.readAllLines(record, StandardCharsets.UTF_8);
+  }
+
+  private String recordedStdin() throws IOException {
+    assertTrue(Files.exists(stdinRecord), "GnuPG was never invoked");
+    return Files.readString(stdinRecord, StandardCharsets.UTF_8);
+  }
+
+  private static void deleteRecursively(Path root) throws IOException {
+    if (root == null || !Files.exists(root)) {
+      return;
+    }
+    try (var paths = Files.walk(root)) {
+      paths.sorted(Comparator.reverseOrder()).forEach(p -> 
p.toFile().delete());
+    }
+  }
+}

Reply via email to