feat(risk): add argument profiles for command normalization and enhance risk classification
Signed-off-by: Boris Bliznioukov <blib@mail.com>
This commit is contained in:
parent
1a28887f9a
commit
cea543ce40
9 changed files with 476 additions and 32 deletions
|
|
@ -343,6 +343,7 @@
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"risk_threshold": "medium",
|
"risk_threshold": "medium",
|
||||||
"risk_overrides": {},
|
"risk_overrides": {},
|
||||||
|
"arg_profiles": {},
|
||||||
"arg_modifiers": {},
|
"arg_modifiers": {},
|
||||||
"env_allowlist": [],
|
"env_allowlist": [],
|
||||||
"env_set": {}
|
"env_set": {}
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,9 @@
|
||||||
|
|
||||||
5. The risk classifier MUST apply argument-aware modifiers (e.g., `curl` is `medium`, but `curl -X POST` is `high`; `git` is `medium`, but `git push` is `high`). All matching modifiers MUST be scanned and the highest level that exceeds the base level is applied (highest-match-wins, not first-match-wins).
|
5. The risk classifier MUST apply argument-aware modifiers (e.g., `curl` is `medium`, but `curl -X POST` is `high`; `git` is `medium`, but `git push` is `high`). All matching modifiers MUST be scanned and the highest level that exceeds the base level is applied (highest-match-wins, not first-match-wins).
|
||||||
|
|
||||||
5a. `risk_overrides` sets the **base level** for a command (replacing the built-in table entry). Argument modifiers MUST still be applied on top of the overridden base level and can elevate it further. This means `risk_overrides: {"rm": "medium"}` allows plain `rm` but `rm -rf` is still elevated to `critical` by the built-in modifier.
|
5a. Before modifier matching, argv SHOULD be normalized using command-specific flag profiles so equivalent CLI forms are classified identically. The implementation MAY normalize grouped short flags (e.g. `-rf` → `-r`, `-f`), long `--flag=value` forms, and explicitly whitelisted attached short-value forms (e.g. `curl -XPOST` → `-X`, `POST`).
|
||||||
|
|
||||||
|
5b. `risk_overrides` sets the **base level** for a command (replacing the built-in table entry). Argument modifiers MUST still be applied on top of the overridden base level and can elevate it further. This means `risk_overrides: {"rm": "medium"}` allows plain `rm` but `rm -rf` is still elevated to `critical` by the built-in modifier.
|
||||||
|
|
||||||
6. When a command is blocked, the `ToolResult` MUST include:
|
6. When a command is blocked, the `ToolResult` MUST include:
|
||||||
- Risk level of the command.
|
- Risk level of the command.
|
||||||
|
|
@ -123,19 +125,20 @@
|
||||||
|
|
||||||
8. The `interp.Runner` MUST be configured with an `OpenHandler` that validates all file-open paths (from shell redirections) resolve within the configured workspace directory. Paths to safe pseudo-devices (`/dev/null`, `/dev/zero`, `/dev/urandom`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`) MUST be exempted.
|
8. The `interp.Runner` MUST be configured with an `OpenHandler` that validates all file-open paths (from shell redirections) resolve within the configured workspace directory. Paths to safe pseudo-devices (`/dev/null`, `/dev/zero`, `/dev/urandom`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`) MUST be exempted.
|
||||||
|
|
||||||
9. The existing regex-based guard (`defaultDenyPatterns`, `guardCommand()`) MUST be removed entirely. The `ExecConfig` fields `EnableDenyPatterns`, `CustomDenyPatterns`, and `CustomAllowPatterns` MUST be removed from the struct.
|
9. The existing regex-based guard (`defaultDenyPatterns`, `guardCommand()`) MUST be removed entirely. The deprecated `ExecConfig` fields `EnableDenyPatterns`, `CustomDenyPatterns`, and `CustomAllowPatterns` MAY remain in the struct for backward-compatible config parsing, but they MUST be ignored at runtime and only emit migration warnings.
|
||||||
|
|
||||||
10. The `ExecConfig` struct MUST be extended with:
|
10. The `ExecConfig` struct MUST be extended with:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
RiskThreshold string `json:"risk_threshold"` // "low"|"medium"|"high"|"critical"; default "medium"
|
RiskThreshold string `json:"risk_threshold"` // "low"|"medium"|"high"|"critical"; default "medium"
|
||||||
RiskOverrides map[string]string `json:"risk_overrides"` // command → level override
|
RiskOverrides map[string]string `json:"risk_overrides"` // command → level override
|
||||||
|
ArgProfiles map[string]ArgProfileConfig `json:"arg_profiles"` // command → argv normalization profile (extends built-ins)
|
||||||
EnvAllowlist []string `json:"env_allowlist"` // extra vars to pass (extends defaults)
|
EnvAllowlist []string `json:"env_allowlist"` // extra vars to pass (extends defaults)
|
||||||
EnvSet map[string]string `json:"env_set"` // explicit var=value pairs
|
EnvSet map[string]string `json:"env_set"` // explicit var=value pairs
|
||||||
ArgModifiers map[string][]ArgModifierConfig `json:"arg_modifiers"` // command → argument-aware risk adjustments (extends built-ins)
|
ArgModifiers map[string][]ArgModifierConfig `json:"arg_modifiers"` // command → argument-aware risk adjustments (extends built-ins)
|
||||||
```
|
```
|
||||||
|
|
||||||
`ArgModifierConfig` is `struct { Args []string; Level string }`. User-defined modifiers are checked alongside built-ins using highest-match-wins semantics (the maximum level across all matching modifiers is applied).
|
`ArgModifierConfig` is `struct { Args []string; Level string }`. `ArgProfileConfig` configures argv normalization (`split_combined_short`, `split_long_equals`, `short_attached_value_flags`, `separate_value_flags`) using a fixed set of transforms (`identity`, `upper`, `lower`). User-defined modifiers are checked alongside built-ins using highest-match-wins semantics (the maximum level across all matching modifiers is applied). Built-in flag profiles are merged with config-supplied profiles; code remains the source of truth when the document lags.
|
||||||
|
|
||||||
11. If deprecated config fields (`enable_deny_patterns`, `custom_deny_patterns`, `custom_allow_patterns`) are present in user config, the system SHOULD log a warning with migration instructions. The system MUST NOT fail to start.
|
11. If deprecated config fields (`enable_deny_patterns`, `custom_deny_patterns`, `custom_allow_patterns`) are present in user config, the system SHOULD log a warning with migration instructions. The system MUST NOT fail to start.
|
||||||
|
|
||||||
|
|
@ -209,7 +212,7 @@
|
||||||
| `pkg/tools/shell_tool.go` | Thin adapter: `ExecTool` struct implementing `Tool` + `AsyncExecutor`. Delegates to `pkg/tools/shell/` subpackage. Background results delivered via registry `AsyncCallback`. |
|
| `pkg/tools/shell_tool.go` | Thin adapter: `ExecTool` struct implementing `Tool` + `AsyncExecutor`. Delegates to `pkg/tools/shell/` subpackage. Background results delivered via registry `AsyncCallback`. |
|
||||||
| `pkg/tools/shell_tool_test.go` | Tests for `ExecTool` sync/async behavior and interface compliance. |
|
| `pkg/tools/shell_tool_test.go` | Tests for `ExecTool` sync/async behavior and interface compliance. |
|
||||||
| `pkg/tools/shell_process_unix.go`, `shell_process_windows.go` | Removed. Interpreter manages process lifecycle. |
|
| `pkg/tools/shell_process_unix.go`, `shell_process_windows.go` | Removed. Interpreter manages process lifecycle. |
|
||||||
| `pkg/config/config.go` (`ExecConfig`) | Three fields removed, four fields added. |
|
| `pkg/config/config.go` (`ExecConfig`) | Risk config expanded with `risk_threshold`, `risk_overrides`, `arg_profiles`, `arg_modifiers`, env controls; deprecated regex-era fields remain for backward-compatible parsing but are ignored. |
|
||||||
| `pkg/config/defaults.go` | Default `RiskThreshold` set to `"medium"`. |
|
| `pkg/config/defaults.go` | Default `RiskThreshold` set to `"medium"`. |
|
||||||
| `pkg/tools/cron.go` | Updated to use new `ExecTool` constructor. |
|
| `pkg/tools/cron.go` | Updated to use new `ExecTool` constructor. |
|
||||||
| `docs/tools_configuration.md` | Rewritten for new config fields and risk model. |
|
| `docs/tools_configuration.md` | Rewritten for new config fields and risk model. |
|
||||||
|
|
@ -222,16 +225,16 @@
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `pkg/tools/shell/risk.go` | Risk classifier: command→level mapping, argument modifiers (built-in + configurable), `ClassifyCommand` with highest-match-wins semantics. |
|
| `pkg/tools/shell/risk.go` | Risk classifier: command→level mapping, flag-profile-based argv normalization, argument modifiers (built-in + configurable), `ClassifyCommand` / `ClassifyCommandWithProfiles` with highest-match-wins semantics. |
|
||||||
| `pkg/tools/shell/risk_test.go` | Table-driven tests for all 4 levels, argument modifiers, overrides, extra modifiers. |
|
| `pkg/tools/shell/risk_test.go` | Table-driven tests for all 4 levels, argument modifiers, overrides, extra modifiers, and profile-driven argv normalization. |
|
||||||
| `pkg/tools/shell/env.go` | Env sanitization: allowlist builder, default list, `LC_*` prefix matching, `env_set` application. |
|
| `pkg/tools/shell/env.go` | Env sanitization: allowlist builder, default list, `LC_*` prefix matching, `env_set` application. |
|
||||||
| `pkg/tools/shell/env_test.go` | Verify secrets stripped, allowlist passed, `env_set` applied. |
|
| `pkg/tools/shell/env_test.go` | Verify secrets stripped, allowlist passed, `env_set` applied. |
|
||||||
| `pkg/tools/shell/sandbox.go` | `OpenHandler` implementation, path-within-workspace validation, pseudo-device exemptions. |
|
| `pkg/tools/shell/sandbox.go` | `OpenHandler` implementation, path-within-workspace validation, pseudo-device exemptions. |
|
||||||
| `pkg/tools/shell/sandbox_test.go` | Redirect inside/outside workspace, symlink escape, safe-path exemption. |
|
| `pkg/tools/shell/sandbox_test.go` | Redirect inside/outside workspace, symlink escape, safe-path exemption. |
|
||||||
| `pkg/tools/shell/runner.go` | `Run` function: parser + interpreter + `ExecHandlers` middleware integration. |
|
| `pkg/tools/shell/runner.go` | `Run` function: parser + interpreter + `ExecHandlers` middleware integration. |
|
||||||
| `pkg/tools/shell/runner_test.go` | End-to-end runner tests: timeout, working dir, env sanitization, pipelines. |
|
| `pkg/tools/shell/runner_test.go` | End-to-end runner tests: timeout, working dir, env sanitization, pipelines. |
|
||||||
| `pkg/tools/shell_tool.go` | Adapter: `ExecTool` struct, `NewExecToolWithConfig(workDir, restrict, cfg)`, `AsyncExecutor` impl, arg modifier wiring. |
|
| `pkg/tools/shell_tool.go` | Adapter: `ExecTool` struct, `NewExecToolWithConfig(workDir, restrict, cfg)`, `AsyncExecutor` impl, arg modifier + arg profile wiring. |
|
||||||
| `pkg/tools/shell_tool_test.go` | `ExecTool` sync/async tests, interface compliance checks. |
|
| `pkg/tools/shell_tool_test.go` | `ExecTool` sync/async tests, interface compliance checks, deprecated-config warnings, and config→flag-profile parsing coverage. |
|
||||||
| `pkg/tools/cron_exec_test.go` | AC-8: cron-originated `ExecTool` blocks dangerous commands identically to agent-created one; safe commands pass. |
|
| `pkg/tools/cron_exec_test.go` | AC-8: cron-originated `ExecTool` blocks dangerous commands identically to agent-created one; safe commands pass. |
|
||||||
|
|
||||||
### Follow-up tasks
|
### Follow-up tasks
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ AST-based risk classification, environment sanitization, and file-access sandbox
|
||||||
| ---------------- | ------ | ---------- | ------------------------------------------------------------------------------- |
|
| ---------------- | ------ | ---------- | ------------------------------------------------------------------------------- |
|
||||||
| `risk_threshold` | string | `"medium"` | Maximum allowed risk level: `"low"`, `"medium"`, `"high"`, `"critical"` |
|
| `risk_threshold` | string | `"medium"` | Maximum allowed risk level: `"low"`, `"medium"`, `"high"`, `"critical"` |
|
||||||
| `risk_overrides` | object | `{}` | Per-command base risk level (command name → level); modifiers can still elevate |
|
| `risk_overrides` | object | `{}` | Per-command base risk level (command name → level); modifiers can still elevate |
|
||||||
|
| `arg_profiles` | object | `{}` | Per-command argv normalization rules used before argument modifier matching |
|
||||||
| `arg_modifiers` | object | `{}` | Per-command argument patterns that adjust risk level |
|
| `arg_modifiers` | object | `{}` | Per-command argument patterns that adjust risk level |
|
||||||
| `env_allowlist` | array | `[]` | Extra environment variables to expose (extends built-in defaults) |
|
| `env_allowlist` | array | `[]` | Extra environment variables to expose (extends built-in defaults) |
|
||||||
| `env_set` | object | `{}` | Explicit `VAR=value` pairs injected into every command |
|
| `env_set` | object | `{}` | Explicit `VAR=value` pairs injected into every command |
|
||||||
|
|
@ -91,6 +92,54 @@ must all be present (order-independent) and the resulting level:
|
||||||
|
|
||||||
The **highest matching** modifier wins (built-in and custom are merged).
|
The **highest matching** modifier wins (built-in and custom are merged).
|
||||||
|
|
||||||
|
#### Argument profiles
|
||||||
|
|
||||||
|
Argument profiles normalize argv before modifier matching. This is useful when a
|
||||||
|
tool accepts multiple flag syntaxes for the same semantic operation, such as
|
||||||
|
`curl -XPOST`, `curl -X POST`, and `curl --request=POST`.
|
||||||
|
|
||||||
|
Each command profile can enable:
|
||||||
|
|
||||||
|
- `split_combined_short`: split grouped flags like `-rf` into `-r`, `-f`
|
||||||
|
- `split_long_equals`: split `--flag=value` into `--flag`, `value`
|
||||||
|
- `short_attached_value_flags`: split attached short-value forms like `-XPOST`
|
||||||
|
- `separate_value_flags`: normalize the token after a flag like `-X post`
|
||||||
|
|
||||||
|
Supported value transforms are:
|
||||||
|
|
||||||
|
- `identity`: keep the value unchanged
|
||||||
|
- `upper`: uppercase the value
|
||||||
|
- `lower`: lowercase the value
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"arg_profiles": {
|
||||||
|
"curl": {
|
||||||
|
"split_combined_short": true,
|
||||||
|
"split_long_equals": true,
|
||||||
|
"short_attached_value_flags": {
|
||||||
|
"-X": "upper",
|
||||||
|
"-d": "identity",
|
||||||
|
"-T": "identity"
|
||||||
|
},
|
||||||
|
"separate_value_flags": {
|
||||||
|
"-X": "upper",
|
||||||
|
"--request": "upper",
|
||||||
|
"-d": "identity",
|
||||||
|
"--data": "identity",
|
||||||
|
"-T": "identity",
|
||||||
|
"--upload-file": "identity"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Built-in profiles are applied first; config profiles extend or override the
|
||||||
|
command's flag maps.
|
||||||
|
|
||||||
#### Precedence
|
#### Precedence
|
||||||
|
|
||||||
The final risk level is computed as:
|
The final risk level is computed as:
|
||||||
|
|
@ -155,6 +204,22 @@ execution.
|
||||||
"ffmpeg": "low",
|
"ffmpeg": "low",
|
||||||
"terraform": "critical"
|
"terraform": "critical"
|
||||||
},
|
},
|
||||||
|
"arg_profiles": {
|
||||||
|
"curl": {
|
||||||
|
"split_combined_short": true,
|
||||||
|
"split_long_equals": true,
|
||||||
|
"short_attached_value_flags": {
|
||||||
|
"-X": "upper",
|
||||||
|
"-d": "identity"
|
||||||
|
},
|
||||||
|
"separate_value_flags": {
|
||||||
|
"-X": "upper",
|
||||||
|
"--request": "upper",
|
||||||
|
"-d": "identity",
|
||||||
|
"--data": "identity"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"arg_modifiers": {
|
"arg_modifiers": {
|
||||||
"curl": [{ "args": ["--upload-file"], "level": "high" }]
|
"curl": [{ "args": ["--upload-file"], "level": "high" }]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -608,10 +608,20 @@ type ArgModifierConfig struct {
|
||||||
Level string `json:"level"` // target risk level: "low"|"medium"|"high"|"critical"
|
Level string `json:"level"` // target risk level: "low"|"medium"|"high"|"critical"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ArgProfileConfig describes how a command's argv should be normalized before
|
||||||
|
// argument-aware risk modifiers are matched.
|
||||||
|
type ArgProfileConfig struct {
|
||||||
|
SplitCombinedShort bool `json:"split_combined_short"`
|
||||||
|
SplitLongEquals bool `json:"split_long_equals"`
|
||||||
|
ShortAttachedValue map[string]string `json:"short_attached_value_flags"`
|
||||||
|
SeparateValueFlags map[string]string `json:"separate_value_flags"`
|
||||||
|
}
|
||||||
|
|
||||||
type ExecConfig struct {
|
type ExecConfig struct {
|
||||||
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"`
|
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"`
|
||||||
RiskThreshold string ` json:"risk_threshold" env:"PICOCLAW_TOOLS_EXEC_RISK_THRESHOLD"` // "low"|"medium"|"high"|"critical"; default "medium"
|
RiskThreshold string ` json:"risk_threshold" env:"PICOCLAW_TOOLS_EXEC_RISK_THRESHOLD"` // "low"|"medium"|"high"|"critical"; default "medium"
|
||||||
RiskOverrides map[string]string ` json:"risk_overrides" env:"PICOCLAW_TOOLS_EXEC_RISK_OVERRIDES"` // command → level override
|
RiskOverrides map[string]string ` json:"risk_overrides" env:"PICOCLAW_TOOLS_EXEC_RISK_OVERRIDES"` // command → level override
|
||||||
|
ArgProfiles map[string]ArgProfileConfig ` json:"arg_profiles"` // command → argv normalization profile (extends built-ins)
|
||||||
ArgModifiers map[string][]ArgModifierConfig ` json:"arg_modifiers" env:"PICOCLAW_TOOLS_EXEC_ARG_MODIFIERS"` // command → argument-aware risk adjustments (extends built-ins)
|
ArgModifiers map[string][]ArgModifierConfig ` json:"arg_modifiers" env:"PICOCLAW_TOOLS_EXEC_ARG_MODIFIERS"` // command → argument-aware risk adjustments (extends built-ins)
|
||||||
EnvAllowlist []string ` json:"env_allowlist" env:"PICOCLAW_TOOLS_EXEC_ENV_ALLOWLIST"` // extra env vars to pass (extends defaults)
|
EnvAllowlist []string ` json:"env_allowlist" env:"PICOCLAW_TOOLS_EXEC_ENV_ALLOWLIST"` // extra env vars to pass (extends defaults)
|
||||||
EnvSet map[string]string ` json:"env_set" env:"PICOCLAW_TOOLS_EXEC_ENV_SET"` // explicit var=value pairs
|
EnvSet map[string]string ` json:"env_set" env:"PICOCLAW_TOOLS_EXEC_ENV_SET"` // explicit var=value pairs
|
||||||
|
|
|
||||||
|
|
@ -269,6 +269,59 @@ type ArgModifier struct {
|
||||||
Level RiskLevel
|
Level RiskLevel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FlagValueTransform string
|
||||||
|
|
||||||
|
const (
|
||||||
|
FlagValueIdentity FlagValueTransform = "identity"
|
||||||
|
FlagValueUpper FlagValueTransform = "upper"
|
||||||
|
FlagValueLower FlagValueTransform = "lower"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ParseFlagValueTransform(s string) (FlagValueTransform, error) {
|
||||||
|
switch FlagValueTransform(strings.ToLower(s)) {
|
||||||
|
case "", FlagValueIdentity:
|
||||||
|
return FlagValueIdentity, nil
|
||||||
|
case FlagValueUpper:
|
||||||
|
return FlagValueUpper, nil
|
||||||
|
case FlagValueLower:
|
||||||
|
return FlagValueLower, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unknown flag value transform %q, must be one of: identity, upper, lower", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FlagProfile struct {
|
||||||
|
SplitCombinedShort bool
|
||||||
|
SplitLongEquals bool
|
||||||
|
ShortAttachedValue map[string]FlagValueTransform
|
||||||
|
SeparateValueFlags map[string]FlagValueTransform
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultFlagProfile = FlagProfile{
|
||||||
|
SplitCombinedShort: true,
|
||||||
|
SplitLongEquals: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var commandFlagProfiles = map[string]FlagProfile{
|
||||||
|
"curl": {
|
||||||
|
SplitCombinedShort: true,
|
||||||
|
SplitLongEquals: true,
|
||||||
|
ShortAttachedValue: map[string]FlagValueTransform{
|
||||||
|
"-X": FlagValueUpper,
|
||||||
|
"-d": FlagValueIdentity,
|
||||||
|
"-T": FlagValueIdentity,
|
||||||
|
},
|
||||||
|
SeparateValueFlags: map[string]FlagValueTransform{
|
||||||
|
"-X": FlagValueUpper,
|
||||||
|
"--request": FlagValueUpper,
|
||||||
|
"-d": FlagValueIdentity,
|
||||||
|
"--data": FlagValueIdentity,
|
||||||
|
"-T": FlagValueIdentity,
|
||||||
|
"--upload-file": FlagValueIdentity,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
// argumentModifiers maps command names to their argument-aware risk adjustments.
|
// argumentModifiers maps command names to their argument-aware risk adjustments.
|
||||||
// All matching modifiers are scanned; the highest level wins.
|
// All matching modifiers are scanned; the highest level wins.
|
||||||
//
|
//
|
||||||
|
|
@ -380,6 +433,24 @@ var argumentModifiers = map[string][]ArgModifier{
|
||||||
// This means risk_overrides: {"rm": "medium"} allows plain `rm` but
|
// This means risk_overrides: {"rm": "medium"} allows plain `rm` but
|
||||||
// `rm -rf` is still elevated to critical by the built-in modifier.
|
// `rm -rf` is still elevated to critical by the built-in modifier.
|
||||||
func ClassifyCommand(args []string, overrides map[string]string, extraModifiers ...map[string][]ArgModifier) RiskLevel {
|
func ClassifyCommand(args []string, overrides map[string]string, extraModifiers ...map[string][]ArgModifier) RiskLevel {
|
||||||
|
return classifyCommand(args, overrides, nil, extraModifiers...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClassifyCommandWithProfiles(
|
||||||
|
args []string,
|
||||||
|
overrides map[string]string,
|
||||||
|
extraProfiles map[string]FlagProfile,
|
||||||
|
extraModifiers ...map[string][]ArgModifier,
|
||||||
|
) RiskLevel {
|
||||||
|
return classifyCommand(args, overrides, extraProfiles, extraModifiers...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyCommand(
|
||||||
|
args []string,
|
||||||
|
overrides map[string]string,
|
||||||
|
extraProfiles map[string]FlagProfile,
|
||||||
|
extraModifiers ...map[string][]ArgModifier,
|
||||||
|
) RiskLevel {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return RiskMedium
|
return RiskMedium
|
||||||
}
|
}
|
||||||
|
|
@ -404,7 +475,8 @@ func ClassifyCommand(args []string, overrides map[string]string, extraModifiers
|
||||||
|
|
||||||
// Normalize args: expand combined short flags like -rf → -r, -f
|
// Normalize args: expand combined short flags like -rf → -r, -f
|
||||||
// so that modifiers match regardless of how flags were grouped or ordered.
|
// so that modifiers match regardless of how flags were grouped or ordered.
|
||||||
normalizedArgs := normalizeFlags(args[1:])
|
profile := flagProfileFor(cmdName, extraProfiles)
|
||||||
|
normalizedArgs := normalizeFlagsWithProfile(profile, args[1:])
|
||||||
|
|
||||||
// Apply built-in modifiers, then user-supplied. Keep the highest match
|
// Apply built-in modifiers, then user-supplied. Keep the highest match
|
||||||
// across all sources. Modifiers can only elevate, never lower.
|
// across all sources. Modifiers can only elevate, never lower.
|
||||||
|
|
@ -517,6 +589,42 @@ func NormalizeCommandKeys[V any](m map[string]V) map[string]V {
|
||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func MergeFlagProfiles(base, override FlagProfile) FlagProfile {
|
||||||
|
merged := base
|
||||||
|
merged.SplitCombinedShort = base.SplitCombinedShort || override.SplitCombinedShort
|
||||||
|
merged.SplitLongEquals = base.SplitLongEquals || override.SplitLongEquals
|
||||||
|
merged.ShortAttachedValue = mergeFlagTransformMaps(base.ShortAttachedValue, override.ShortAttachedValue)
|
||||||
|
merged.SeparateValueFlags = mergeFlagTransformMaps(base.SeparateValueFlags, override.SeparateValueFlags)
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeFlagTransformMaps(base, override map[string]FlagValueTransform) map[string]FlagValueTransform {
|
||||||
|
if len(base) == 0 && len(override) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
merged := make(map[string]FlagValueTransform, len(base)+len(override))
|
||||||
|
for k, v := range base {
|
||||||
|
merged[k] = v
|
||||||
|
}
|
||||||
|
for k, v := range override {
|
||||||
|
merged[k] = v
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
func flagProfileFor(cmdName string, extraProfiles map[string]FlagProfile) FlagProfile {
|
||||||
|
profile := defaultFlagProfile
|
||||||
|
if builtIn, ok := commandFlagProfiles[cmdName]; ok {
|
||||||
|
profile = MergeFlagProfiles(profile, builtIn)
|
||||||
|
}
|
||||||
|
if extraProfiles != nil {
|
||||||
|
if extra, ok := extraProfiles[cmdName]; ok {
|
||||||
|
profile = MergeFlagProfiles(profile, extra)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
// baseCommand extracts the basename from a command path.
|
// baseCommand extracts the basename from a command path.
|
||||||
// On Windows, it additionally lowercases the name and strips known
|
// On Windows, it additionally lowercases the name and strips known
|
||||||
// executable extensions (.exe, .cmd, .bat, .com) so that
|
// executable extensions (.exe, .cmd, .bat, .com) so that
|
||||||
|
|
@ -540,24 +648,107 @@ func baseCommand(cmd string) string {
|
||||||
return lower
|
return lower
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeFlags expands combined short flags (e.g., "-rf" → "-r", "-f")
|
// normalizeFlags expands/normalizes flag forms according to the command's
|
||||||
// so that modifier matching works regardless of how flags are grouped.
|
// flag profile so modifier matching works regardless of grouping or
|
||||||
// Long flags (--flag), non-flag arguments, and slash flags (/s, /MIR) are
|
// flag-value style.
|
||||||
// passed through unchanged. Single-dash flags longer than 3 characters
|
func normalizeFlags(cmdName string, args []string) []string {
|
||||||
// (e.g., "-urlcache") are treated as long flags and NOT expanded, since
|
profile := flagProfileFor(cmdName, nil)
|
||||||
// no standard tool uses 4+ combined single-letter flags.
|
return normalizeFlagsWithProfile(profile, args)
|
||||||
func normalizeFlags(args []string) []string {
|
}
|
||||||
|
|
||||||
|
func normalizeFlagsWithProfile(profile FlagProfile, args []string) []string {
|
||||||
result := make([]string, 0, len(args)*2)
|
result := make([]string, 0, len(args)*2)
|
||||||
for _, a := range args {
|
for _, a := range args {
|
||||||
if len(a) > 2 && len(a) <= 4 && a[0] == '-' && a[1] != '-' {
|
if flag, value, ok := splitShortAttachedValue(profile, a); ok {
|
||||||
|
result = append(result, flag)
|
||||||
|
if value != "" {
|
||||||
|
result = append(result, normalizeFlagValue(profile, flag, value))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if profile.SplitLongEquals && len(a) > 2 && strings.HasPrefix(a, "--") {
|
||||||
|
if idx := strings.IndexByte(a, '='); idx > 2 {
|
||||||
|
flag := a[:idx]
|
||||||
|
result = append(result, flag)
|
||||||
|
if idx+1 < len(a) {
|
||||||
|
result = append(result, normalizeFlagValue(profile, flag, a[idx+1:]))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if profile.SplitCombinedShort && len(a) > 2 && len(a) <= 4 && a[0] == '-' && a[1] != '-' {
|
||||||
for _, ch := range a[1:] {
|
for _, ch := range a[1:] {
|
||||||
result = append(result, "-"+string(ch))
|
result = append(result, "-"+string(ch))
|
||||||
}
|
}
|
||||||
} else {
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
result = append(result, a)
|
result = append(result, a)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return normalizeSeparateFlagValues(profile, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitShortAttachedValue(profile FlagProfile, arg string) (string, string, bool) {
|
||||||
|
if len(arg) <= 3 || arg[0] != '-' || arg[1] == '-' {
|
||||||
|
return "", "", false
|
||||||
}
|
}
|
||||||
return result
|
|
||||||
|
flag := arg[:2]
|
||||||
|
if _, ok := profile.ShortAttachedValue[flag]; !ok {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
if arg[2] == '=' {
|
||||||
|
if len(arg) == 3 {
|
||||||
|
return flag, "", true
|
||||||
|
}
|
||||||
|
return flag, arg[3:], true
|
||||||
|
}
|
||||||
|
|
||||||
|
return flag, arg[2:], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeFlagValue(profile FlagProfile, flag, value string) string {
|
||||||
|
if transform, ok := profile.SeparateValueFlags[flag]; ok {
|
||||||
|
return applyFlagValueTransform(transform, value)
|
||||||
|
}
|
||||||
|
if transform, ok := profile.ShortAttachedValue[flag]; ok {
|
||||||
|
return applyFlagValueTransform(transform, value)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyFlagValueTransform(transform FlagValueTransform, value string) string {
|
||||||
|
switch transform {
|
||||||
|
case FlagValueUpper:
|
||||||
|
return strings.ToUpper(value)
|
||||||
|
case FlagValueLower:
|
||||||
|
return strings.ToLower(value)
|
||||||
|
case FlagValueIdentity, "":
|
||||||
|
return value
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSeparateFlagValues(profile FlagProfile, args []string) []string {
|
||||||
|
if len(profile.SeparateValueFlags) == 0 {
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized := append([]string(nil), args...)
|
||||||
|
for i := 0; i+1 < len(normalized); i++ {
|
||||||
|
transform, ok := profile.SeparateValueFlags[normalized[i]]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
normalized[i+1] = applyFlagValueTransform(transform, normalized[i+1])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
// matchArgs checks if ALL pattern tokens are present in args (order-independent).
|
// matchArgs checks if ALL pattern tokens are present in args (order-independent).
|
||||||
|
|
|
||||||
|
|
@ -80,10 +80,18 @@ func TestClassifyCommand_ArgumentModifiers(t *testing.T) {
|
||||||
|
|
||||||
{"curl GET (default)", []string{"curl", "https://example.com"}, RiskMedium},
|
{"curl GET (default)", []string{"curl", "https://example.com"}, RiskMedium},
|
||||||
{"curl POST", []string{"curl", "-X", "POST", "https://example.com"}, RiskHigh},
|
{"curl POST", []string{"curl", "-X", "POST", "https://example.com"}, RiskHigh},
|
||||||
|
{"curl post lowercase", []string{"curl", "-X", "post", "https://example.com"}, RiskHigh},
|
||||||
{"curl -d data", []string{"curl", "-d", "data", "https://example.com"}, RiskHigh},
|
{"curl -d data", []string{"curl", "-d", "data", "https://example.com"}, RiskHigh},
|
||||||
{"curl --data data", []string{"curl", "--data", "data", "url"}, RiskHigh},
|
{"curl --data data", []string{"curl", "--data", "data", "url"}, RiskHigh},
|
||||||
|
{"curl --data=payload", []string{"curl", "--data=payload", "url"}, RiskHigh},
|
||||||
{"curl -X DELETE", []string{"curl", "-X", "DELETE", "url"}, RiskHigh},
|
{"curl -X DELETE", []string{"curl", "-X", "DELETE", "url"}, RiskHigh},
|
||||||
|
{"curl -XDELETE", []string{"curl", "-XDELETE", "url"}, RiskHigh},
|
||||||
|
{"curl -X=DELETE", []string{"curl", "-X=DELETE", "url"}, RiskHigh},
|
||||||
{"curl --request POST", []string{"curl", "--request", "POST", "url"}, RiskHigh},
|
{"curl --request POST", []string{"curl", "--request", "POST", "url"}, RiskHigh},
|
||||||
|
{"curl --request=post lowercase", []string{"curl", "--request=post", "url"}, RiskHigh},
|
||||||
|
{"curl --request=POST", []string{"curl", "--request=POST", "url"}, RiskHigh},
|
||||||
|
{"curl -dDATA", []string{"curl", "-dDATA", "https://example.com"}, RiskHigh},
|
||||||
|
{"curl -d=DATA", []string{"curl", "-d=DATA", "https://example.com"}, RiskHigh},
|
||||||
|
|
||||||
{"rm file (no flags)", []string{"rm", "file.txt"}, RiskHigh},
|
{"rm file (no flags)", []string{"rm", "file.txt"}, RiskHigh},
|
||||||
{"rm -rf", []string{"rm", "-rf", "/"}, RiskCritical},
|
{"rm -rf", []string{"rm", "-rf", "/"}, RiskCritical},
|
||||||
|
|
@ -353,15 +361,22 @@ func TestNormalizeFlags(t *testing.T) {
|
||||||
}{
|
}{
|
||||||
{[]string{"-rf"}, []string{"-r", "-f"}},
|
{[]string{"-rf"}, []string{"-r", "-f"}},
|
||||||
{[]string{"-fr"}, []string{"-f", "-r"}},
|
{[]string{"-fr"}, []string{"-f", "-r"}},
|
||||||
|
{[]string{"-XPOST"}, []string{"-XPOST"}},
|
||||||
|
{[]string{"-X=POST"}, []string{"-X=POST"}},
|
||||||
|
{[]string{"-dDATA"}, []string{"-dDATA"}},
|
||||||
|
{[]string{"-d=DATA"}, []string{"-d=DATA"}},
|
||||||
|
{[]string{"--request=POST"}, []string{"--request", "POST"}},
|
||||||
|
{[]string{"--data=body"}, []string{"--data", "body"}},
|
||||||
{[]string{"-r", "-f"}, []string{"-r", "-f"}},
|
{[]string{"-r", "-f"}, []string{"-r", "-f"}},
|
||||||
{[]string{"--force"}, []string{"--force"}},
|
{[]string{"--force"}, []string{"--force"}},
|
||||||
{[]string{"-f"}, []string{"-f"}},
|
{[]string{"-f"}, []string{"-f"}},
|
||||||
|
{[]string{"-9"}, []string{"-9"}},
|
||||||
{[]string{"push"}, []string{"push"}},
|
{[]string{"push"}, []string{"push"}},
|
||||||
{[]string{"-rf", "dir", "--verbose"}, []string{"-r", "-f", "dir", "--verbose"}},
|
{[]string{"-rf", "dir", "--verbose"}, []string{"-r", "-f", "dir", "--verbose"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
got := normalizeFlags(tt.input)
|
got := normalizeFlags("git", tt.input)
|
||||||
if len(got) != len(tt.want) {
|
if len(got) != len(tt.want) {
|
||||||
t.Errorf("normalizeFlags(%v) = %v, want %v", tt.input, got, tt.want)
|
t.Errorf("normalizeFlags(%v) = %v, want %v", tt.input, got, tt.want)
|
||||||
continue
|
continue
|
||||||
|
|
@ -375,6 +390,59 @@ func TestNormalizeFlags(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeFlags_CurlAttachedShortValues(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input []string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{[]string{"-XPOST"}, []string{"-X", "POST"}},
|
||||||
|
{[]string{"-Xpost"}, []string{"-X", "POST"}},
|
||||||
|
{[]string{"-X=POST"}, []string{"-X", "POST"}},
|
||||||
|
{[]string{"-dDATA"}, []string{"-d", "DATA"}},
|
||||||
|
{[]string{"-d=DATA"}, []string{"-d", "DATA"}},
|
||||||
|
{[]string{"-Tfile.txt"}, []string{"-T", "file.txt"}},
|
||||||
|
{[]string{"--request=post"}, []string{"--request", "POST"}},
|
||||||
|
{[]string{"-X", "post"}, []string{"-X", "POST"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := normalizeFlags("curl", tt.input)
|
||||||
|
if len(got) != len(tt.want) {
|
||||||
|
t.Errorf("normalizeFlags(curl, %v) = %v, want %v", tt.input, got, tt.want)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != tt.want[i] {
|
||||||
|
t.Errorf("normalizeFlags(curl, %v) = %v, want %v", tt.input, got, tt.want)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyCommandWithProfiles_CustomAttachedValue(t *testing.T) {
|
||||||
|
profiles := map[string]FlagProfile{
|
||||||
|
"http": {
|
||||||
|
ShortAttachedValue: map[string]FlagValueTransform{
|
||||||
|
"-m": FlagValueUpper,
|
||||||
|
},
|
||||||
|
SeparateValueFlags: map[string]FlagValueTransform{
|
||||||
|
"-m": FlagValueUpper,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
modifiers := map[string][]ArgModifier{
|
||||||
|
"http": {
|
||||||
|
{Args: []string{"-m", "POST"}, Level: RiskHigh},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := ClassifyCommandWithProfiles([]string{"http", "-mpost", "https://example.com"}, nil, profiles, modifiers)
|
||||||
|
if got != RiskHigh {
|
||||||
|
t.Fatalf("ClassifyCommandWithProfiles(custom attached value) = %s, want %s", got, RiskHigh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMatchArgs_OrderIndependent(t *testing.T) {
|
func TestMatchArgs_OrderIndependent(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
@ -493,21 +561,21 @@ func TestClassifyCommand_ShellWrapperFullPath(t *testing.T) {
|
||||||
func TestApplyModifiers_HighestMatchWins(t *testing.T) {
|
func TestApplyModifiers_HighestMatchWins(t *testing.T) {
|
||||||
// When multiple modifiers match, the highest level should win.
|
// When multiple modifiers match, the highest level should win.
|
||||||
// Scenario: git push matches both ["push"] → High and ["push", "-f"] → Critical
|
// Scenario: git push matches both ["push"] → High and ["push", "-f"] → Critical
|
||||||
args := normalizeFlags([]string{"push", "-f", "origin"})
|
args := normalizeFlags("git", []string{"push", "-f", "origin"})
|
||||||
result := applyModifiers(args, "git", RiskMedium, argumentModifiers)
|
result := applyModifiers(args, "git", RiskMedium, argumentModifiers)
|
||||||
if result != RiskCritical {
|
if result != RiskCritical {
|
||||||
t.Errorf("git push -f should resolve to critical (highest match), got %s", result)
|
t.Errorf("git push -f should resolve to critical (highest match), got %s", result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only ["push"] matches → High
|
// Only ["push"] matches → High
|
||||||
args2 := normalizeFlags([]string{"push", "origin"})
|
args2 := normalizeFlags("git", []string{"push", "origin"})
|
||||||
result2 := applyModifiers(args2, "git", RiskMedium, argumentModifiers)
|
result2 := applyModifiers(args2, "git", RiskMedium, argumentModifiers)
|
||||||
if result2 != RiskHigh {
|
if result2 != RiskHigh {
|
||||||
t.Errorf("git push (no -f) should resolve to high, got %s", result2)
|
t.Errorf("git push (no -f) should resolve to high, got %s", result2)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No modifier matches → base level unchanged
|
// No modifier matches → base level unchanged
|
||||||
args3 := normalizeFlags([]string{"status"})
|
args3 := normalizeFlags("git", []string{"status"})
|
||||||
result3 := applyModifiers(args3, "git", RiskMedium, argumentModifiers)
|
result3 := applyModifiers(args3, "git", RiskMedium, argumentModifiers)
|
||||||
if result3 != RiskMedium {
|
if result3 != RiskMedium {
|
||||||
t.Errorf("git status should stay medium, got %s", result3)
|
t.Errorf("git status should stay medium, got %s", result3)
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ type RunConfig struct {
|
||||||
|
|
||||||
RiskThreshold RiskLevel
|
RiskThreshold RiskLevel
|
||||||
RiskOverrides map[string]string
|
RiskOverrides map[string]string
|
||||||
|
ExtraFlagProfiles map[string]FlagProfile
|
||||||
ExtraArgModifiers map[string][]ArgModifier // user-defined, appended after built-ins
|
ExtraArgModifiers map[string][]ArgModifier // user-defined, appended after built-ins
|
||||||
EnvAllowlist []string
|
EnvAllowlist []string
|
||||||
EnvSet map[string]string
|
EnvSet map[string]string
|
||||||
|
|
@ -74,7 +75,7 @@ func Run(ctx context.Context, cfg RunConfig) RunResult {
|
||||||
opts,
|
opts,
|
||||||
interp.ExecHandlers(
|
interp.ExecHandlers(
|
||||||
pathAwareExecHandler(env),
|
pathAwareExecHandler(env),
|
||||||
riskExecHandler(cfg.RiskThreshold, cfg.RiskOverrides, cfg.ExtraArgModifiers),
|
riskExecHandler(cfg.RiskThreshold, cfg.RiskOverrides, cfg.ExtraFlagProfiles, cfg.ExtraArgModifiers),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -134,6 +135,7 @@ func Run(ctx context.Context, cfg RunConfig) RunResult {
|
||||||
func riskExecHandler(
|
func riskExecHandler(
|
||||||
threshold RiskLevel,
|
threshold RiskLevel,
|
||||||
overrides map[string]string,
|
overrides map[string]string,
|
||||||
|
extraProfiles map[string]FlagProfile,
|
||||||
extraMods map[string][]ArgModifier,
|
extraMods map[string][]ArgModifier,
|
||||||
) func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
|
) func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
|
||||||
return func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
|
return func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
|
||||||
|
|
@ -142,7 +144,7 @@ func riskExecHandler(
|
||||||
return next(ctx, args)
|
return next(ctx, args)
|
||||||
}
|
}
|
||||||
|
|
||||||
level := ClassifyCommand(args, overrides, extraMods)
|
level := ClassifyCommandWithProfiles(args, overrides, extraProfiles, extraMods)
|
||||||
if !IsAllowed(level, threshold) {
|
if !IsAllowed(level, threshold) {
|
||||||
return NewBlockedError(args, level, threshold, "command risk exceeds configured threshold")
|
return NewBlockedError(args, level, threshold, "command risk exceeds configured threshold")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ type ExecTool struct {
|
||||||
|
|
||||||
riskThreshold shell.RiskLevel
|
riskThreshold shell.RiskLevel
|
||||||
riskOverrides map[string]string
|
riskOverrides map[string]string
|
||||||
|
argProfiles map[string]shell.FlagProfile
|
||||||
argModifiers map[string][]shell.ArgModifier
|
argModifiers map[string][]shell.ArgModifier
|
||||||
envAllowlist []string
|
envAllowlist []string
|
||||||
envSet map[string]string
|
envSet map[string]string
|
||||||
|
|
@ -66,6 +67,7 @@ func NewExecToolWithConfig(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
t.riskOverrides = shell.NormalizeCommandKeys(execCfg.RiskOverrides)
|
t.riskOverrides = shell.NormalizeCommandKeys(execCfg.RiskOverrides)
|
||||||
|
t.argProfiles = shell.NormalizeCommandKeys(parseArgProfiles(execCfg.ArgProfiles))
|
||||||
t.argModifiers = shell.NormalizeCommandKeys(parseArgModifiers(execCfg.ArgModifiers))
|
t.argModifiers = shell.NormalizeCommandKeys(parseArgModifiers(execCfg.ArgModifiers))
|
||||||
t.envAllowlist = execCfg.EnvAllowlist
|
t.envAllowlist = execCfg.EnvAllowlist
|
||||||
t.envSet = execCfg.EnvSet
|
t.envSet = execCfg.EnvSet
|
||||||
|
|
@ -214,6 +216,7 @@ func (t *ExecTool) buildConfig(args map[string]any) (shell.RunConfig, *ToolResul
|
||||||
WorkspaceDir: t.workingDir,
|
WorkspaceDir: t.workingDir,
|
||||||
RiskThreshold: t.riskThreshold,
|
RiskThreshold: t.riskThreshold,
|
||||||
RiskOverrides: t.riskOverrides,
|
RiskOverrides: t.riskOverrides,
|
||||||
|
ExtraFlagProfiles: t.argProfiles,
|
||||||
ExtraArgModifiers: t.argModifiers,
|
ExtraArgModifiers: t.argModifiers,
|
||||||
EnvAllowlist: t.envAllowlist,
|
EnvAllowlist: t.envAllowlist,
|
||||||
EnvSet: t.envSet,
|
EnvSet: t.envSet,
|
||||||
|
|
@ -258,3 +261,61 @@ func parseArgModifiers(raw map[string][]config.ArgModifierConfig) map[string][]s
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseArgProfiles(raw map[string]config.ArgProfileConfig) map[string]shell.FlagProfile {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]shell.FlagProfile, len(raw))
|
||||||
|
for cmd, profile := range raw {
|
||||||
|
parsed := shell.FlagProfile{
|
||||||
|
SplitCombinedShort: profile.SplitCombinedShort,
|
||||||
|
SplitLongEquals: profile.SplitLongEquals,
|
||||||
|
}
|
||||||
|
|
||||||
|
if transforms := parseFlagTransforms(
|
||||||
|
cmd,
|
||||||
|
"short_attached_value_flags",
|
||||||
|
profile.ShortAttachedValue,
|
||||||
|
); len(
|
||||||
|
transforms,
|
||||||
|
) > 0 {
|
||||||
|
parsed.ShortAttachedValue = transforms
|
||||||
|
}
|
||||||
|
if transforms := parseFlagTransforms(
|
||||||
|
cmd,
|
||||||
|
"separate_value_flags",
|
||||||
|
profile.SeparateValueFlags,
|
||||||
|
); len(
|
||||||
|
transforms,
|
||||||
|
) > 0 {
|
||||||
|
parsed.SeparateValueFlags = transforms
|
||||||
|
}
|
||||||
|
|
||||||
|
out[cmd] = parsed
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFlagTransforms(cmd, field string, raw map[string]string) map[string]shell.FlagValueTransform {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]shell.FlagValueTransform, len(raw))
|
||||||
|
for flag, name := range raw {
|
||||||
|
transform, err := shell.ParseFlagValueTransform(name)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(
|
||||||
|
"Warning: invalid %s transform %q for command %q flag %q: %v. Skipping this flag.\n",
|
||||||
|
field,
|
||||||
|
name,
|
||||||
|
cmd,
|
||||||
|
flag,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[flag] = transform
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/shell"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExecTool_SyncExecution(t *testing.T) {
|
func TestExecTool_SyncExecution(t *testing.T) {
|
||||||
|
|
@ -175,12 +176,12 @@ func captureStdout(t *testing.T, fn func()) string {
|
||||||
return buf.String()
|
return buf.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func boolPtr(b bool) *bool { return &b }
|
func ptr[T any](v T) *T { return &v }
|
||||||
|
|
||||||
func TestWarnDeprecatedExecConfig_EnableDenyPatternsFalse(t *testing.T) {
|
func TestWarnDeprecatedExecConfig_EnableDenyPatternsFalse(t *testing.T) {
|
||||||
out := captureStdout(t, func() {
|
out := captureStdout(t, func() {
|
||||||
warnDeprecatedExecConfig(config.ExecConfig{
|
warnDeprecatedExecConfig(config.ExecConfig{
|
||||||
EnableDenyPatterns: boolPtr(false),
|
EnableDenyPatterns: ptr(false),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -195,7 +196,7 @@ func TestWarnDeprecatedExecConfig_EnableDenyPatternsFalse(t *testing.T) {
|
||||||
func TestWarnDeprecatedExecConfig_EnableDenyPatternsTrue(t *testing.T) {
|
func TestWarnDeprecatedExecConfig_EnableDenyPatternsTrue(t *testing.T) {
|
||||||
out := captureStdout(t, func() {
|
out := captureStdout(t, func() {
|
||||||
warnDeprecatedExecConfig(config.ExecConfig{
|
warnDeprecatedExecConfig(config.ExecConfig{
|
||||||
EnableDenyPatterns: boolPtr(true),
|
EnableDenyPatterns: ptr(true),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -236,7 +237,7 @@ func TestWarnDeprecatedExecConfig_CustomPatterns(t *testing.T) {
|
||||||
func TestWarnDeprecatedExecConfig_AllDeprecatedFields(t *testing.T) {
|
func TestWarnDeprecatedExecConfig_AllDeprecatedFields(t *testing.T) {
|
||||||
out := captureStdout(t, func() {
|
out := captureStdout(t, func() {
|
||||||
warnDeprecatedExecConfig(config.ExecConfig{
|
warnDeprecatedExecConfig(config.ExecConfig{
|
||||||
EnableDenyPatterns: boolPtr(false),
|
EnableDenyPatterns: ptr(false),
|
||||||
CustomDenyPatterns: []string{"rm"},
|
CustomDenyPatterns: []string{"rm"},
|
||||||
CustomAllowPatterns: []string{"ls"},
|
CustomAllowPatterns: []string{"ls"},
|
||||||
})
|
})
|
||||||
|
|
@ -257,7 +258,7 @@ func TestWarnDeprecatedExecConfig_AllDeprecatedFields(t *testing.T) {
|
||||||
func TestNewExecToolWithConfig_EnableDenyPatternsFalseWarning(t *testing.T) {
|
func TestNewExecToolWithConfig_EnableDenyPatternsFalseWarning(t *testing.T) {
|
||||||
out := captureStdout(t, func() {
|
out := captureStdout(t, func() {
|
||||||
cfg := &config.Config{}
|
cfg := &config.Config{}
|
||||||
cfg.Tools.Exec.EnableDenyPatterns = boolPtr(false)
|
cfg.Tools.Exec.EnableDenyPatterns = ptr(false)
|
||||||
_, err := NewExecToolWithConfig(t.TempDir(), false, cfg)
|
_, err := NewExecToolWithConfig(t.TempDir(), false, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|
@ -268,3 +269,45 @@ func TestNewExecToolWithConfig_EnableDenyPatternsFalseWarning(t *testing.T) {
|
||||||
t.Errorf("expected warning in NewExecToolWithConfig output: %s", out)
|
t.Errorf("expected warning in NewExecToolWithConfig output: %s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseArgProfiles(t *testing.T) {
|
||||||
|
profiles := parseArgProfiles(map[string]config.ArgProfileConfig{
|
||||||
|
"curl": {
|
||||||
|
SplitCombinedShort: true,
|
||||||
|
SplitLongEquals: true,
|
||||||
|
ShortAttachedValue: map[string]string{"-X": "upper"},
|
||||||
|
SeparateValueFlags: map[string]string{"--request": "upper"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
profile, ok := profiles["curl"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected curl profile")
|
||||||
|
}
|
||||||
|
if !profile.SplitCombinedShort || !profile.SplitLongEquals {
|
||||||
|
t.Fatal("expected split flags to be enabled")
|
||||||
|
}
|
||||||
|
if got := profile.ShortAttachedValue["-X"]; got != shell.FlagValueUpper {
|
||||||
|
t.Fatalf("ShortAttachedValue[-X] = %q, want %q", got, shell.FlagValueUpper)
|
||||||
|
}
|
||||||
|
if got := profile.SeparateValueFlags["--request"]; got != shell.FlagValueUpper {
|
||||||
|
t.Fatalf("SeparateValueFlags[--request] = %q, want %q", got, shell.FlagValueUpper)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseArgProfiles_InvalidTransformWarning(t *testing.T) {
|
||||||
|
out := captureStdout(t, func() {
|
||||||
|
profiles := parseArgProfiles(map[string]config.ArgProfileConfig{
|
||||||
|
"curl": {
|
||||||
|
ShortAttachedValue: map[string]string{"-X": "bogus"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if got := len(profiles["curl"].ShortAttachedValue); got != 0 {
|
||||||
|
t.Fatalf("expected invalid transform to be skipped, got %d entries", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if !strings.Contains(out, "invalid short_attached_value_flags transform") {
|
||||||
|
t.Fatalf("expected invalid transform warning, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue