diff --git a/concore_cli/commands/validate.py b/concore_cli/commands/validate.py
index fa1ea18..f8265b9 100644
--- a/concore_cli/commands/validate.py
+++ b/concore_cli/commands/validate.py
@@ -84,6 +84,11 @@ def validate_workflow(workflow_file, console):
label = label_tag.text.strip()
node_labels.append(label)
+ # reject shell metacharacters to prevent command injection (#251)
+ if re.search(r'[;&|`$\'"()\\]', label):
+ errors.append(f"Node '{label}' contains unsafe shell characters")
+ continue
+
if ':' not in label:
warnings.append(f"Node '{label}' missing format 'ID:filename'")
else:
diff --git a/tests/test_graph.py b/tests/test_graph.py
index 97102dc..f6e2825 100644
--- a/tests/test_graph.py
+++ b/tests/test_graph.py
@@ -112,6 +112,23 @@ def test_validate_node_missing_filename(self):
self.assertIn('Validation failed', result.output)
self.assertIn('has no filename', result.output)
+ def test_validate_unsafe_node_label(self):
+ content = '''
+
+
+
+ n0;rm -rf /:script.py
+
+
+
+ '''
+ filepath = self.create_graph_file('injection.graphml', content)
+
+ result = self.runner.invoke(cli, ['validate', filepath])
+
+ self.assertIn('Validation failed', result.output)
+ self.assertIn('unsafe shell characters', result.output)
+
def test_validate_valid_graph(self):
content = '''