Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ yq -P -oy sample.json

rootCmd.PersistentFlags().BoolVarP(&yqlib.ConfiguredSecurityPreferences.DisableEnvOps, "security-disable-env-ops", "", false, "Disable env related operations.")
rootCmd.PersistentFlags().BoolVarP(&yqlib.ConfiguredSecurityPreferences.DisableFileOps, "security-disable-file-ops", "", false, "Disable file related operations (e.g. load)")
rootCmd.PersistentFlags().BoolVarP(&yqlib.ConfiguredSecurityPreferences.EnableSystemOps, "enable-system-operator", "", false, "Enable system operator to allow execution of external commands.")

rootCmd.AddCommand(
createEvaluateSequenceCommand(),
Expand Down
23 changes: 23 additions & 0 deletions pkg/yqlib/doc/operators/headers/system-operators.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# System Operators

The `system` operator allows you to run an external command and use its output as a value in your expression.

**Security warning**: The system operator is disabled by default. You must explicitly pass `--enable-system-operator` to use it.

## Usage

```bash
yq --enable-system-operator '.field = system("command"; "arg1")'
```

The operator takes:
- A command string (required)
- An argument or array of arguments separated by `;` (optional)

The current matched node's value is serialised and piped to the command via stdin. The command's stdout (with trailing newline stripped) is returned as a string.

## Disabling the system operator

The system operator is disabled by default. When disabled, a warning is logged and `null` is returned instead of running the command.

Use `--enable-system-operator` flag to enable it.
72 changes: 72 additions & 0 deletions pkg/yqlib/doc/operators/system-operators.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# System Operators

The `system` operator allows you to run an external command and use its output as a value in your expression.

**Security warning**: The system operator is disabled by default. You must explicitly pass `--enable-system-operator` to use it.

## Usage

```bash
yq --enable-system-operator '.field = system("command"; "arg1")'
```

The operator takes:
- A command string (required)
- An argument or array of arguments separated by `;` (optional)

The current matched node's value is serialised and piped to the command via stdin. The command's stdout (with trailing newline stripped) is returned as a string.

## Disabling the system operator

The system operator is disabled by default. When disabled, a warning is logged and `null` is returned instead of running the command.

Use `--enable-system-operator` flag to enable it.

## system operator returns null when disabled
Use `--enable-system-operator` to enable the system operator.

Given a sample.yml file of:
```yaml
country: Australia
```
then
```bash
yq '.country = system("/usr/bin/echo"; "test")' sample.yml
```
will output
```yaml
country: null
```

## Run a command with an argument
Use `--enable-system-operator` to enable the system operator.

Given a sample.yml file of:
```yaml
country: Australia
```
then
```bash
yq '.country = system("/usr/bin/echo"; "test")' sample.yml
```
will output
```yaml
country: test
```

## Run a command without arguments
Omit the semicolon and args to run the command with no extra arguments.

Given a sample.yml file of:
```yaml
a: hello
```
then
```bash
yq '.a = system("/bin/echo")' sample.yml
```
will output
```yaml
a: ""
```

2 changes: 2 additions & 0 deletions pkg/yqlib/lexer_participle.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ var participleYqRules = []*participleYqRule{
simpleOp("load_?str|str_?load", loadStringOpType),
{"LoadYaml", `load`, loadOp(NewYamlDecoder(LoadYamlPreferences)), 0},

simpleOp("system", systemOpType),

{"SplitDocument", `splitDoc|split_?doc`, opToken(splitDocumentOpType), 0},

simpleOp("select", selectOpType),
Expand Down
2 changes: 2 additions & 0 deletions pkg/yqlib/operation.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ var stringInterpolationOpType = &operationType{Type: "STRING_INT", NumArgs: 0, P
var loadOpType = &operationType{Type: "LOAD", NumArgs: 1, Precedence: 52, Handler: loadOperator, CheckForPostTraverse: true}
var loadStringOpType = &operationType{Type: "LOAD_STRING", NumArgs: 1, Precedence: 52, Handler: loadStringOperator}

var systemOpType = &operationType{Type: "SYSTEM", NumArgs: 1, Precedence: 50, Handler: systemOperator}

var keysOpType = &operationType{Type: "KEYS", NumArgs: 0, Precedence: 52, Handler: keysOperator, CheckForPostTraverse: true}

var collectObjectOpType = &operationType{Type: "COLLECT_OBJECT", NumArgs: 0, Precedence: 50, Handler: collectObjectOperator}
Expand Down
102 changes: 102 additions & 0 deletions pkg/yqlib/operator_system.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package yqlib

import (
"bytes"
"container/list"
"fmt"
"os/exec"
"strings"
)

func systemOperator(d *dataTreeNavigator, context Context, expressionNode *ExpressionNode) (Context, error) {
if !ConfiguredSecurityPreferences.EnableSystemOps {
log.Warning("system operator is disabled, use --enable-system-operator flag to enable")
results := list.New()
for el := context.MatchingNodes.Front(); el != nil; el = el.Next() {
candidate := el.Value.(*CandidateNode)
results.PushBack(candidate.CreateReplacement(ScalarNode, "!!null", "null"))
}
return context.ChildContext(results), nil
}

var command string
var argsExpression *ExpressionNode

// check if it's a block operator (command; args) or just (command)
if expressionNode.RHS.Operation.OperationType == blockOpType {
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

command/argsExpression are resolved once using context.ReadOnlyClone() before iterating over context.MatchingNodes, and only the first match is used. This means system(<expr>) and system(<expr>; <expr>) cannot vary per matched node (e.g. system(.cmd; .args)), and may pick an unrelated first result when multiple nodes are matched. Consider evaluating the command/args expressions inside the per-candidate loop using context.SingleReadonlyChildContext(candidate) (and failing if they don't yield a single scalar/sequence) so parameter expressions behave consistently with other operators like load.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in da611f7. Command and args expressions are now evaluated inside the per-candidate loop using context.SingleReadonlyChildContext(candidate), matching the pattern used by loadOperator. This allows expressions like system(.cmd; .arg) to resolve .cmd and .arg against each matched node individually. Also added a test case for per-node evaluation, used exec.LookPath for portability, and fixed CRLF-aware newline trimming.

block := expressionNode.RHS
commandNodes, err := d.GetMatchingNodes(context.ReadOnlyClone(), block.LHS)
if err != nil {
return Context{}, err
}
if commandNodes.MatchingNodes.Front() == nil {
return Context{}, fmt.Errorf("system operator: command expression returned no results")
}
command = commandNodes.MatchingNodes.Front().Value.(*CandidateNode).Value
argsExpression = block.RHS
} else {
commandNodes, err := d.GetMatchingNodes(context.ReadOnlyClone(), expressionNode.RHS)
if err != nil {
return Context{}, err
}
if commandNodes.MatchingNodes.Front() == nil {
return Context{}, fmt.Errorf("system operator: command expression returned no results")
}
command = commandNodes.MatchingNodes.Front().Value.(*CandidateNode).Value
}

// evaluate args if present
var args []string
if argsExpression != nil {
argsNodes, err := d.GetMatchingNodes(context.ReadOnlyClone(), argsExpression)
if err != nil {
return Context{}, err
}
if argsNodes.MatchingNodes.Front() != nil {
argsNode := argsNodes.MatchingNodes.Front().Value.(*CandidateNode)
if argsNode.Kind == SequenceNode {
for _, child := range argsNode.Content {
args = append(args, child.Value)
}
} else if argsNode.Tag != "!!null" {
args = []string{argsNode.Value}
}
}
}

var results = list.New()

for el := context.MatchingNodes.Front(); el != nil; el = el.Next() {
candidate := el.Value.(*CandidateNode)

var stdin bytes.Buffer
if candidate.Tag != "!!null" {
encoded, err := encodeToYamlString(candidate)
if err != nil {
return Context{}, err
}
stdin.WriteString(encoded)
}

// #nosec G204 - intentional: user must explicitly enable this operator
cmd := exec.Command(command, args...)
cmd.Stdin = &stdin
var stderr bytes.Buffer
cmd.Stderr = &stderr

output, err := cmd.Output()
if err != nil {
stderrStr := strings.TrimSpace(stderr.String())
if stderrStr != "" {
return Context{}, fmt.Errorf("system command '%v' failed: %w\nstderr: %v", command, err, stderrStr)
}
return Context{}, fmt.Errorf("system command '%v' failed: %w", command, err)
}

result := strings.TrimRight(string(output), "\n")
newNode := candidate.CreateReplacement(ScalarNode, "!!str", result)
results.PushBack(newNode)
}

return context.ChildContext(results), nil
}
84 changes: 84 additions & 0 deletions pkg/yqlib/operator_system_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package yqlib

import (
"testing"
)

var systemOperatorDisabledScenarios = []expressionScenario{
{
description: "system operator returns null when disabled",
subdescription: "Use `--enable-system-operator` to enable the system operator.",
document: "country: Australia",
expression: `.country = system("/usr/bin/echo"; "test")`,
expected: []string{
"D0, P[], (!!map)::country: null\n",
},
},
}

var systemOperatorEnabledScenarios = []expressionScenario{
{
description: "Run a command with an argument",
subdescription: "Use `--enable-system-operator` to enable the system operator.",
document: "country: Australia",
expression: `.country = system("/usr/bin/echo"; "test")`,
expected: []string{
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests depend on hard-coded absolute paths like /usr/bin/echo and /bin/echo, which can fail on some Linux distros/containers and all non-POSIX platforms. Consider resolving the executable at runtime (e.g. via exec.LookPath) or skipping these scenarios when the required commands aren't available, to keep the test suite portable and reliable.

Copilot uses AI. Check for mistakes.
"D0, P[], (!!map)::country: test\n",
},
},
{
description: "Run a command without arguments",
subdescription: "Omit the semicolon and args to run the command with no extra arguments.",
document: "a: hello",
expression: `.a = system("/bin/echo")`,
expected: []string{
"D0, P[], (!!map)::a: \"\"\n",
},
},
{
description: "Run a command with multiple arguments",
subdescription: "Pass an array of arguments.",
skipDoc: true,
document: "a: hello",
expression: `.a = system("/bin/echo"; ["foo", "bar"])`,
expected: []string{
"D0, P[], (!!map)::a: foo bar\n",
},
},
{
description: "Command failure returns error",
skipDoc: true,
document: "a: hello",
expression: `.a = system("/bin/false")`,
expectedError: "system command '/bin/false' failed: exit status 1",
},
}

func TestSystemOperatorDisabledScenarios(t *testing.T) {
// ensure system operator is disabled
originalEnableSystemOps := ConfiguredSecurityPreferences.EnableSystemOps
defer func() {
ConfiguredSecurityPreferences.EnableSystemOps = originalEnableSystemOps
}()

ConfiguredSecurityPreferences.EnableSystemOps = false

for _, tt := range systemOperatorDisabledScenarios {
testScenario(t, &tt)
}
documentOperatorScenarios(t, "system-operators", systemOperatorDisabledScenarios)
}

func TestSystemOperatorEnabledScenarios(t *testing.T) {
originalEnableSystemOps := ConfiguredSecurityPreferences.EnableSystemOps
defer func() {
ConfiguredSecurityPreferences.EnableSystemOps = originalEnableSystemOps
}()

ConfiguredSecurityPreferences.EnableSystemOps = true

for _, tt := range systemOperatorEnabledScenarios {
testScenario(t, &tt)
}
appendOperatorDocumentScenario(t, "system-operators", systemOperatorEnabledScenarios)
}
10 changes: 6 additions & 4 deletions pkg/yqlib/security_prefs.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package yqlib

type SecurityPreferences struct {
DisableEnvOps bool
DisableFileOps bool
DisableEnvOps bool
DisableFileOps bool
EnableSystemOps bool
}
Comment on lines 3 to 7
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SecurityPreferences currently uses a Disable* naming pattern (DisableEnvOps, DisableFileOps), but this introduces EnableSystemOps. For consistency and to reduce confusion when adding future prefs, consider renaming so all fields use the same polarity/pattern (e.g. DisableSystemOps with an inverted default, or rename the existing fields).

Copilot uses AI. Check for mistakes.

var ConfiguredSecurityPreferences = SecurityPreferences{
DisableEnvOps: false,
DisableFileOps: false,
DisableEnvOps: false,
DisableFileOps: false,
EnableSystemOps: false,
}
Loading