feat(shellscan): AST-based shell pre-flight classifier for the pre-tool hook - #673
feat(shellscan): AST-based shell pre-flight classifier for the pre-tool hook#673peycheff-com wants to merge 25 commits into
Conversation
|
Strix is installed on this repository, but we couldn't run this PR security review because this workspace's trial has ended. Add a card to resume code reviews here. |
There was a problem hiding this comment.
Pull request overview
Adds a new AST-based shell command classifier (core/pkg/shellscan) and wires it into the pre-tool hook so Bash commands are structurally scanned (mvdan shell parser) before deciding whether to route them through the existing signed workstation decision (permit/receipt) path.
Changes:
- Introduces
shellscan.Classifyto parse shell into an AST, unwrap common wrappers, detect destructive patterns, and emit advisory reasons/signals. - Adds an arity-aware prefix dictionary to classify command “meaning” beyond raw string needles.
- Updates the pre-tool hook to use the classifier for Bash, and adds regression + evasion tests for both
shellscanand the hook.
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| core/pkg/shellscan/shellscan.go | New AST-based classifier (wrappers, destructive matchers, signals, legacy-needle fallback). |
| core/pkg/shellscan/arity.go | Arity dictionary + prefix derivation for human-meaningful command prefixes. |
| core/pkg/shellscan/shellscan_test.go | Unit tests for decision/pass cases, signals, wrapper chains, and prefix behavior. |
| core/cmd/helm-ai-kernel/hook_cmd.go | Hooks Bash pre-tool classification into shellscan.Classify and carries classifier reason into decision path. |
| core/cmd/helm-ai-kernel/hook_cmd_test.go | Adds hook-level tests for AST-closed evasions and benign passthrough regression. |
| core/go.mod | Adds mvdan shell parser dependency. |
| core/go.sum | Updates sums for new dependencies. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
71f6d7e to
8f83c29
Compare
Address blocking findings from the HELM Autonomous Release Permit review (openai/gpt-5.6-sol) on PR #673: 1. SHELL_COMBINED_C_BYPASS: shell wrapper recognition only matched an exact "-c" token, so "bash -lc 'rm -rf /x'" bypassed. Add scanShellScriptFlag: parses combined short-flag clusters (-lc, -fc), attached payloads (-cCMD / -c'cmd'), value-taking flags (bash -O), long flags with values (--init-file/--rcfile), and "--" end-of-options. "bash -c" with no operand reads stdin → fail closed. 2. ENV_FLAG_ASSIGNMENT_BYPASS: env stripped assignments before flags, so "env -i FOO=bar rm -rf /x" treated FOO=bar as the command. Rewrite env handling to model real semantics: flags (short clusters, long flags, values) and VAR=val assignments are skipped in any order until the real command; env -S/--split-string payloads are recursively classified as command lines; dynamic or unrecognized env arguments fail closed. 3. DYNAMIC_REDIRECT_BYPASS: dynamic write-redirect targets returned without a decision, so 'echo SECRET=x > "$TARGET"' could write protected files. Write-capable redirects with unresolvable targets now route to the decision path (fail closed). Each finding carries regression tests proving the bypass now routes to the decision path; all pre-existing 52+26 classifier cases and the hook golden tests stay green. Signed-off-by: Mindburn Labs <mindburnlabs@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 7 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (2)
core/pkg/shellscan/shellscan.go:78
- sensitiveRedirectTargets is described as mirroring the hook’s sensitive-write target list, but it currently omits the Windows-style
.git\\entry thatisSensitiveWriteTargetmatches (hook_cmd.go:295-303). That allows a bash redirect target containing.git\\to bypass the sensitive redirect detection layer.
"id_rsa",
"id_ed25519",
".git/",
}
core/pkg/shellscan/shellscan.go:155
- classifyString uses collector-wide
sawPipe/sawShell/sawEval/sawDecodestate to decide "encoded payload decoded into a shell or eval". Because this state is shared across recursive classifyString calls (e.g. viabash -c,eval,env -S), a nested snippet that happens to contain a pipe can incorrectly influence the outer snippet’s encoded-wrapper decision even when no decode-to-shell pipeline exists in the outer command.
func (c *collector) classifyString(src, via string, depth int) {
if depth > maxWrapperDepth {
c.decide("wrapper nesting too deep to classify statically")
return
}
Address blocking findings from the HELM Autonomous Release Permit review (openai/gpt-5.6-sol) on PR #673: 1. SHELL_COMBINED_C_BYPASS: shell wrapper recognition only matched an exact "-c" token, so "bash -lc 'rm -rf /x'" bypassed. Add scanShellScriptFlag: parses combined short-flag clusters (-lc, -fc), attached payloads (-cCMD / -c'cmd'), value-taking flags (bash -O), long flags with values (--init-file/--rcfile), and "--" end-of-options. "bash -c" with no operand reads stdin → fail closed. 2. ENV_FLAG_ASSIGNMENT_BYPASS: env stripped assignments before flags, so "env -i FOO=bar rm -rf /x" treated FOO=bar as the command. Rewrite env handling to model real semantics: flags (short clusters, long flags, values) and VAR=val assignments are skipped in any order until the real command; env -S/--split-string payloads are recursively classified as command lines; dynamic or unrecognized env arguments fail closed. 3. DYNAMIC_REDIRECT_BYPASS: dynamic write-redirect targets returned without a decision, so 'echo SECRET=x > "$TARGET"' could write protected files. Write-capable redirects with unresolvable targets now route to the decision path (fail closed). Each finding carries regression tests proving the bypass now routes to the decision path; all pre-existing 52+26 classifier cases and the hook golden tests stay green. Signed-off-by: Mindburn Labs <mindburnlabs@users.noreply.github.com>
0b26791 to
fe251e0
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 7 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
core/pkg/shellscan/shellscan.go:78
sensitiveRedirectTargetsis meant to mirrorisSensitiveWriteTargetin the hook, but it’s missing the Windows-style.git\\entry (hook has both.git/and.git\\). That allows redirects like>> .git\\configto bypass the same sensitive-target protection in the shell classifier.
// sensitiveRedirectTargets mirrors the sensitive-write list in the hook so a
// shell redirect cannot bypass the Write-tool path protection.
var sensitiveRedirectTargets = []string{
".env",
".pem",
".key",
"id_rsa",
"id_ed25519",
".git/",
}
core/pkg/shellscan/shellscan.go:189
- The
SignalEncodedWrapperdecision check uses global booleans (sawDecode,sawPipe,sawShell,sawEval) aggregated across the entire parsed snippet. That can produce false positives when decode and shell/eval occur in different statements or unrelated pipelines (e.g.echo x | base64 -d > out; sh -c 'echo hi'), routing benign commands into the decision path (regression risk).
if c.sawDecode && (c.sawPipe && (c.sawShell || c.sawEval)) {
c.signal(SignalEncodedWrapper)
c.decide("encoded payload decoded into a shell or eval")
}
core/pkg/shellscan/arity.go:168
Prefixclaims that "tokens that look like flags stop prefix growth", but the implementation can currently include flags inside the returned prefix (e.g. tokens["git","-C","/repo","reset"]returns"git -C"becausearity["git"]=2). That makes prefixes unstable/less human-meaningful and undermines the arity-table intent for future scoping.
// Prefix returns the arity-derived, human-meaningful command prefix for a
// tokenized command (e.g. ["git","checkout","main"] -> "git checkout",
// ["npm","run","dev"] -> "npm run dev"). Longest matching prefix wins;
// unknown commands fall back to the first token. Tokens that look like flags
// stop prefix growth unless a longer dictionary entry matched first.
Address the second round of blocking P1 findings from the HELM Autonomous Release Permit review (openai/gpt-5.6-sol, PR #673): 1. DYNAMIC_DESTRUCTIVE_ARGS_FAIL_OPEN: dynamic tokens in subcommand or flag position of destructive families were silently ignored, so kubectl "$(printf delete)" ns and docker rm "$(printf %s -f)" c1 executed destructively while classified benign. firstSubcommand now reports dynamic words explicitly; kubectl/docker/git invocations with dynamic subcommands or unresolvable flags, docker rm with dynamic flags, git reset/clean with dynamic flags, and find -exec/-execdir with dynamic payloads all route to the decision path. 2. WRAPPER_VALUE_FLAG_BYPASS: wrapper parsing omitted value-taking long flags, so sudo --user root rm -rf /x treated root as the command. dropWrapperFlags now models long value flags (with --flag=value attached forms), short-flag clusters with attached values, and known no-value long flags; unrecognized long flags and dynamic words in wrapper flag position are ambiguous and fail closed. Regression tests cover every bypass command from the review plus adjacent variants; benign sudo --preserve-env stays passthrough. All prior classifier and hook golden tests remain green. Signed-off-by: Mindburn Labs <mindburnlabs@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 7 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (4)
core/pkg/shellscan/shellscan.go:189
SignalEncodedWrapperdetection is based on collector-wide booleans (sawDecode,sawPipe,sawShell,sawEval) and triggers when all are seen anywhere in the parsed snippet. This can produce false positives across independent statements/pipelines (e.g.echo x | base64 -d; echo ok | shwould be flagged even though the decode output is not piped into the shell), changing behavior from the PR’s stated “decode-to-shell pipeline” intent.
if c.sawDecode && (c.sawPipe && (c.sawShell || c.sawEval)) {
c.signal(SignalEncodedWrapper)
c.decide("encoded payload decoded into a shell or eval")
}
core/pkg/shellscan/shellscan.go:821
- In
matchGit(clean), the loop continues when it sees the end-of-options marker--instead of stopping, so pathspec operands like-- -fdcan be misinterpreted as flags and incorrectly classified as destructive. Break out of flag scanning when--is reached.
for _, tok := range rest {
if tok.dynamic {
c.decide("git clean with unresolvable flags (fail-closed)")
return
}
core/pkg/shellscan/arity.go:181
Prefixcan return prefixes that include flag tokens (e.g. tokens like["git","-C","/repo",...]yield prefix"git -C"becausearity["git"]=2). This contradicts the comment that “flags never count as tokens” and will produce misleading prefixes for common CLI patterns with global flags.
if n, ok := arity[tokens[0]]; ok && n <= len(tokens) {
return joinTokens(tokens[:n])
}
core/pkg/shellscan/shellscan.go:75
sensitiveRedirectTargetsclaims to mirror the hook’s sensitive-write list, but it’s missing the Windows-path needle.git\\thatisSensitiveWriteTargetchecks for. This creates an inconsistency whereecho x > .git\\configcould bypass the redirect-based protection layer.
var sensitiveRedirectTargets = []string{
".env",
".pem",
".key",
"id_rsa",
…ol hook Replace the evadable substring needle list in hook_cmd.go with a structural bash AST classifier (mvdan.cc/sh, pure Go, no cgo) as advisory input to the existing signed decision path: - parse every command into a bash AST and walk all command nodes, including pipelines, subshells and command substitutions - classify arity-aware command prefixes (git checkout, npm run, ...) via a port of opencode's arity dictionary - close evasion classes: split/reordered/long flags, sudo/env/nice/ xargs/busybox wrappers, sh -c and eval payload recursion, base64/xxd decode-to-shell pipelines, path obfuscation, find -delete/-exec rm, write redirects to sensitive targets - fail-closed: unparseable input, dynamic command words, dynamic flags on destructive families, and dynamic eval/sh -c payloads all route to the decision path; the legacy needle list is kept verbatim as a fallback layer so existing behavior is strictly preserved - verdict authority is unchanged: workstation.Decide still produces the signed permit/receipt verdict; the classifier only decides whether a command reaches that path Evidence: research/opencode-study/03-core-tools.md (tree-sitter shell scan + arity table), research/opencode-study/25-helm-map-agent-side.md (A1/A2: needle list trivially evadable; keep needle fallback layer). Proposed Linear key: HELM-W1 (proposal only; Linear MCP unavailable). Signed-off-by: Mindburn Labs <mindburnlabs@users.noreply.github.com>
Address blocking findings from the HELM Autonomous Release Permit review (openai/gpt-5.6-sol) on PR #673: 1. SHELL_COMBINED_C_BYPASS: shell wrapper recognition only matched an exact "-c" token, so "bash -lc 'rm -rf /x'" bypassed. Add scanShellScriptFlag: parses combined short-flag clusters (-lc, -fc), attached payloads (-cCMD / -c'cmd'), value-taking flags (bash -O), long flags with values (--init-file/--rcfile), and "--" end-of-options. "bash -c" with no operand reads stdin → fail closed. 2. ENV_FLAG_ASSIGNMENT_BYPASS: env stripped assignments before flags, so "env -i FOO=bar rm -rf /x" treated FOO=bar as the command. Rewrite env handling to model real semantics: flags (short clusters, long flags, values) and VAR=val assignments are skipped in any order until the real command; env -S/--split-string payloads are recursively classified as command lines; dynamic or unrecognized env arguments fail closed. 3. DYNAMIC_REDIRECT_BYPASS: dynamic write-redirect targets returned without a decision, so 'echo SECRET=x > "$TARGET"' could write protected files. Write-capable redirects with unresolvable targets now route to the decision path (fail closed). Each finding carries regression tests proving the bypass now routes to the decision path; all pre-existing 52+26 classifier cases and the hook golden tests stay green. Signed-off-by: Mindburn Labs <mindburnlabs@users.noreply.github.com>
Address the second round of blocking P1 findings from the HELM Autonomous Release Permit review (openai/gpt-5.6-sol, PR #673): 1. DYNAMIC_DESTRUCTIVE_ARGS_FAIL_OPEN: dynamic tokens in subcommand or flag position of destructive families were silently ignored, so kubectl "$(printf delete)" ns and docker rm "$(printf %s -f)" c1 executed destructively while classified benign. firstSubcommand now reports dynamic words explicitly; kubectl/docker/git invocations with dynamic subcommands or unresolvable flags, docker rm with dynamic flags, git reset/clean with dynamic flags, and find -exec/-execdir with dynamic payloads all route to the decision path. 2. WRAPPER_VALUE_FLAG_BYPASS: wrapper parsing omitted value-taking long flags, so sudo --user root rm -rf /x treated root as the command. dropWrapperFlags now models long value flags (with --flag=value attached forms), short-flag clusters with attached values, and known no-value long flags; unrecognized long flags and dynamic words in wrapper flag position are ambiguous and fail closed. Regression tests cover every bypass command from the review plus adjacent variants; benign sudo --preserve-env stays passthrough. All prior classifier and hook golden tests remain green. Signed-off-by: Mindburn Labs <mindburnlabs@users.noreply.github.com>
6bde3da to
dff2190
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 7 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (3)
core/pkg/shellscan/shellscan.go:78
sensitiveRedirectTargetsis stated to mirror the hook's sensitive write targets, but it currently omits the Windows-style.git\\needle thatisSensitiveWriteTargetchecks for (core/cmd/helm-ai-kernel/hook_cmd.go:295-303). This allows a redirect like> .git\\configto bypass the sensitive-redirect detection.
".git/",
}
core/pkg/shellscan/shellscan.go:189
- Encoded-wrapper detection is based on collector-wide booleans (
sawDecode,sawPipe,sawShell,sawEval) that can be set by unrelated statements in the same input. For example,echo a | base64 -d | cat; sh -c 'echo hi'would satisfy the condition even though the decoded bytes are not piped into a shell/eval. This can introduce false positives (extra receipts/denies) and an inaccurate reason string.
if c.sawDecode && (c.sawPipe && (c.sawShell || c.sawEval)) {
c.signal(SignalEncodedWrapper)
c.decide("encoded payload decoded into a shell or eval")
}
core/pkg/shellscan/arity.go:170
Prefix's docstring says "Tokens that look like flags stop prefix growth", but the implementation can return prefixes that include flags (e.g.[]string{"git","-C","/repo","status"}returns"git -C"becausearity["git"]=2). This makes prefixes less human-meaningful and contradicts the documented behavior.
// unknown commands fall back to the first token. Tokens that look like flags
// stop prefix growth unless a longer dictionary entry matched first.
func Prefix(tokens []string) string {
if len(tokens) == 0 {
Address the third round of blocking P1 findings from the HELM Autonomous Release Permit review (openai/gpt-5.6-sol, PR #673): 1. SHELL_OPTION_VALUE_BYPASS: scanShellScriptFlag did not model -o option-name values, so "bash -o posix -c 'rm -rf /x'" hid the -c payload behind the skipped value. -o (and bash -O) now consume attached or next-token values; zsh --emulate is recognized as a value-taking long flag. 2. SUDO_CHDIR_FLAG_BYPASS: sudo -D (--chdir) was absent from the value-flag table, so "sudo -D /tmp rm -rf /x" misclassified /tmp as the command. Added -D plus the remaining value-taking sudo flags (-t/--type, -U/--other-user, --command-timeout) after auditing sudo's option set. Sibling hardening in the same paths (fail-closed on opaque execution): - shells with no static script source (bare bash, bash -s, bash -c without operand, curl ... | bash) read commands from stdin and now route to the decision path; static 'bash script.sh' stays passthrough - exec is unwrapped like command/builtin (exec bash -c '...' no longer hides the payload) Regression tests cover every bypass command plus variants; benign sudo --preserve-env and static script invocation stay passthrough. All prior classifier and hook golden tests remain green. Signed-off-by: Mindburn Labs <mindburnlabs@users.noreply.github.com>
Address two blocking P1 findings from the HELM Autonomous Release Permit review (openai/gpt-5.6-sol, PR #673): 1. XARGS_REPLACEMENT_BYPASS: with -I/--replace (including the deprecated -i alias) the xargs command template contains the replacement token, so the executed command is data-driven — "printf 'rm\n' | xargs -I{} {} --recursive --force /x" executed a recursive rm while classified benign. xargs with replacement mode now routes to the decision path (fail-closed); plain "xargs rm" style still unwraps and classifies the embedded command normally. Detection honors the value-flag table so replacement tokens inside flag values are not misread. 2. SUDO_INTERACTIVE_SHELL_BYPASS: sudo -s/-i unwrapped to an empty command and was allowed, but launches a privileged shell. sudo -s/-i (and --shell/--login, and any trailing command with them) plus bare sudo with no command now route to the decision path. The -s/-i scan is value-flag aware: -ui is -u with value "i", not the shell flag. Regression tests cover every bypass command; benign sudo -E/-u and plain xargs invocations classify as before. All prior classifier and hook golden tests remain green. Signed-off-by: Mindburn Labs <mindburnlabs@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 7 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (4)
core/pkg/shellscan/shellscan.go:78
- sensitiveRedirectTargets is intended to mirror the hook’s isSensitiveWriteTarget list, but it’s missing the Windows-style ".git\" needle (hook_cmd.go includes both ".git/" and ".git\"). This can let a redirect like
echo x >> .git\\configbypass the sensitive redirect detection on Windows paths.
// sensitiveRedirectTargets mirrors the sensitive-write list in the hook so a
// shell redirect cannot bypass the Write-tool path protection.
var sensitiveRedirectTargets = []string{
".env",
".pem",
".key",
"id_rsa",
"id_ed25519",
".git/",
}
core/pkg/shellscan/shellscan.go:189
- Encoded-wrapper detection uses collector-wide booleans (sawDecode/sawPipe/sawShell/sawEval) aggregated across the entire parsed snippet. This can incorrectly label unrelated statements as an "encoded payload decoded into a shell or eval" (e.g. a base64 -d in one pipeline and a separate
bashreading stdin elsewhere in the command string). This produces misleading receipt reasons/signals and could spuriously route benign multi-statement commands to the decision path.
// classifyString parses a shell snippet and walks every command node,
// including those inside pipelines, substitutions and subshells.
func (c *collector) classifyString(src, via string, depth int) {
if depth > maxWrapperDepth {
c.decide("wrapper nesting too deep to classify statically")
return
}
parser := syntax.NewParser()
file, err := parser.Parse(strings.NewReader(src), "")
if err != nil {
c.signal(SignalParseError)
c.parseOK = false
c.decide("unparseable shell command (fail-closed)")
return
}
if len(file.Stmts) > 1 {
c.signal(SignalChaining)
}
syntax.Walk(file, func(node syntax.Node) bool {
switch n := node.(type) {
case *syntax.BinaryCmd:
switch n.Op {
case syntax.Pipe:
c.sawPipe = true
c.signal(SignalChaining)
case syntax.AndStmt, syntax.OrStmt:
c.signal(SignalChaining)
}
case *syntax.CmdSubst, *syntax.ProcSubst:
c.signal(SignalCommandSubstitution)
case *syntax.Redirect:
c.classifyRedirect(n)
case *syntax.CallExpr:
c.classifyCall(n, via, depth)
}
return true
})
if c.sawDecode && (c.sawPipe && (c.sawShell || c.sawEval)) {
c.signal(SignalEncodedWrapper)
c.decide("encoded payload decoded into a shell or eval")
}
core/pkg/shellscan/shellscan.go:1206
- In git clean flag scanning,
--should terminate option parsing. The current conditionif !HasPrefix("-") || tok.text == "--" { continue }continues scanning after--, so path operands after--that start with-can be misinterpreted as flags and spuriously trigger a destructive classification.
force, dirs := false, false
for _, tok := range rest {
if tok.dynamic {
c.decide("git clean with unresolvable flags (fail-closed)")
return
}
if !strings.HasPrefix(tok.text, "-") || tok.text == "--" {
continue
}
core/pkg/shellscan/shellscan.go:1261
- In docker rm flag scanning,
--should terminate option parsing. Without handling it, a container name after--(or any operand starting with '-') can be treated as a flag and spuriously trigger a destructive classification.
for _, tok := range rest {
if tok.dynamic {
c.decide("docker rm with unresolvable flags (fail-closed)")
return
}
if tok.text == "--force" {
c.decide("docker rm --force")
return
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 7 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (2)
core/pkg/shellscan/shellscan.go:189
- Encoded-wrapper detection is based on global booleans (
sawDecode,sawPipe,sawShell/sawEval) across the entire parse. This can incorrectly mark unrelated statements as an "encoded payload decoded into a shell" (e.g., a benign pipe earlier + a separatebase64 -d file+ a separatebash script.sh). Consider tying this detection to the specific pipeline structure instead of cross-statement aggregation (or scoping/resetting these flags per pipeline).
case syntax.Pipe:
c.sawPipe = true
c.signal(SignalChaining)
case syntax.AndStmt, syntax.OrStmt:
c.signal(SignalChaining)
}
case *syntax.CmdSubst, *syntax.ProcSubst:
c.signal(SignalCommandSubstitution)
case *syntax.Redirect:
c.classifyRedirect(n)
case *syntax.CallExpr:
c.classifyCall(n, via, depth)
}
return true
})
if c.sawDecode && (c.sawPipe && (c.sawShell || c.sawEval)) {
c.signal(SignalEncodedWrapper)
c.decide("encoded payload decoded into a shell or eval")
}
core/pkg/shellscan/arity.go:182
Prefix's docstring says flags stop prefix growth, but the implementation can return prefixes that include flags (e.g., tokens starting withgit -C ...will yieldgit -C). This makes the recordedCommand.Prefixmisleading for common “global-flag then subcommand” patterns and diverges from the comment above the function.
// Prefix returns the arity-derived, human-meaningful command prefix for a
// tokenized command (e.g. ["git","checkout","main"] -> "git checkout",
// ["npm","run","dev"] -> "npm run dev"). Longest matching prefix wins;
// unknown commands fall back to the first token. Tokens that look like flags
// stop prefix growth unless a longer dictionary entry matched first.
func Prefix(tokens []string) string {
if len(tokens) == 0 {
return ""
}
for length := len(tokens); length > 1; length-- {
key := joinTokens(tokens[:length])
if n, ok := arity[key]; ok && n <= len(tokens) {
return joinTokens(tokens[:n])
}
}
if n, ok := arity[tokens[0]]; ok && n <= len(tokens) {
return joinTokens(tokens[:n])
}
return tokens[0]
Address two blocking P1 findings from the HELM Autonomous Release Permit review (openai/gpt-5.6-sol, PR #673): 1. UNMODELED_SHELL_BYPASS: shells outside shellNames were treated as benign commands, so "fish -c 'rm -R -f /x'" bypassed both AST detection and the legacy needles. shellNames now covers fish, nu, pwsh/powershell, elvish, xonsh, mksh, tcsh/csh, yash and rc. Since these are not POSIX-option shells (fish -d takes a value, pwsh uses -Word options), unrecognized short-flag characters on them are ambiguous and route to the decision path; POSIX shells keep the letter-options-are-valueless rule. 2. FLOCK_COMMAND_ORDER_BYPASS: executor parsing stopped at flock's lockfile positional, so canonical "flock /tmp/l -c 'rm -R -f /x'" classified -c as the command. flock's -c/--command is now detected anywhere after the lockfile (GNU permutation), and any post-operand "command" that still looks like a flag makes the wrapper's argument layout ambiguous → decision path (also closes late-flag forms like "timeout 5 -v rm -rf /x"). Regression tests cover every bypass command; benign timeout/flock/ taskset invocations classify as before. All prior classifier and hook golden tests remain green. Signed-off-by: Mindburn Labs <mindburnlabs@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 7 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
core/pkg/shellscan/shellscan.go:189
encoded payloaddetection currently uses global flags (sawDecode,sawPipe,sawShell/sawEval) aggregated across the whole parsed snippet. That can incorrectly flip a benignbash -c '...'(or benigneval "...") toDecide=truejust because some other pipeline in the same snippet used|and some other command usedbase64 -d/xxd -r. This becomes an unintended deny/receipt for otherwise-allowed commands.
A safer approach is to only consider stdin-reading shell invocations (i.e., actual ... | sh / ... | bash -s style) as sawShell, and drop sawEval from the heuristic (eval doesn’t consume pipeline stdin as code).
if c.sawDecode && (c.sawPipe && (c.sawShell || c.sawEval)) {
c.signal(SignalEncodedWrapper)
c.decide("encoded payload decoded into a shell or eval")
}
core/pkg/shellscan/shellscan.go:980
c.sawShellis set for all shell invocations (includingbash -c '...'), but it’s only used to drive the decode-to-shell heuristic. As a result, any snippet that contains both (a) a decode command likebase64 -dsomewhere and (b) any pipe anywhere can end up being treated as anencoded payload decoded into a shell, even when the shell is executing a static-cscript and is not consuming decoded stdin as code.
To avoid false-positive denies, only set sawShell for stdin-reading shells (the stdin branch).
case shellNames[name]:
c.signal(SignalShellInvocation)
c.sawShell = true
rest := args[1:]
core/pkg/shellscan/shellscan.go:78
sensitiveRedirectTargetsclaims to mirror the hook’s sensitive write target list, but it’s missing the".git\\"variant that the hook blocks (isSensitiveWriteTargetincludes both.git/and.git\\). On Windows/MSYS-style paths, a redirect like> .git\configcould bypass the intended protection in this pre-flight layer.
// sensitiveRedirectTargets mirrors the sensitive-write list in the hook so a
// shell redirect cannot bypass the Write-tool path protection.
var sensitiveRedirectTargets = []string{
".env",
".pem",
".key",
"id_rsa",
"id_ed25519",
".git/",
}
Address the remaining blocking P1 from the HELM Autonomous Release Permit review (openai/gpt-5.6-sol, PR #673): UNMODELED_EXECUTOR_BYPASS: unrecognized executor wrappers were treated as leaf commands, so "strace rm -f -r /x" executed recursive deletion without matching needles or reaching the signed decision path. The executor registry now covers the tracer/instrumentation family: strace, ltrace (with -p pid mode classified as non-executing), catchsegv, and valgrind. Unknown flags on any executor wrapper remain fail-closed. Benign strace -f ls stays passthrough. Regression tests cover the bypass command and flag variants; all prior classifier and hook golden tests remain green. Signed-off-by: Mindburn Labs <mindburnlabs@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 7 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
core/pkg/shellscan/shellscan.go:189
- Encoded-wrapper detection uses global sawDecode/sawPipe/sawShell/sawEval booleans and can route unrelated commands to the decision path (e.g.,
bash script.sh; echo x | base64 -d > outwould satisfy the condition even though the decoded bytes are not executed). This can create false denies and misleading reasons. Prefer either (a) structural detection tied to a specific pipeline edge, or (b) keeping this as an audit signal only (no decision) sinceshell reads stdinalready fail-closes the dangerous... | sh/bashcases.
if c.sawDecode && (c.sawPipe && (c.sawShell || c.sawEval)) {
c.signal(SignalEncodedWrapper)
c.decide("encoded payload decoded into a shell or eval")
}
core/pkg/shellscan/arity.go:169
- Prefix() currently includes flags/flag-values in the computed prefix (e.g. tokens
["git","-C","/repo","checkout","main"]will yield "git -C"), which contradicts the comment about stopping on flags and makes prefixes unstable/unhelpful for permit scoping and receipts. Prefix derivation should stop once a flag-like token is encountered (at minimum) so recorded prefixes don’t contain flags.
// Prefix returns the arity-derived, human-meaningful command prefix for a
// tokenized command (e.g. ["git","checkout","main"] -> "git checkout",
// ["npm","run","dev"] -> "npm run dev"). Longest matching prefix wins;
// unknown commands fall back to the first token. Tokens that look like flags
// stop prefix growth unless a longer dictionary entry matched first.
func Prefix(tokens []string) string {
core/pkg/shellscan/shellscan.go:835
commandis treated as a generic executor wrapper and passed through dropWrapperFlags(), but command’s common query modes (command -v/-V foo) do not executefoo. Today those flags are unmodeled, socommand -v gitwill route to the decision path (likely deny by default), which is a high-probability regression for benign “is tool installed?” probes. Handlecommandseparately: treat -v/-V as non-executing (record and return), allow -p/--, and fail-closed on unrecognized flags.
case name == "nice" || name == "nohup" || name == "time" || name == "command" || name == "builtin" || name == "stdbuf" || name == "setsid" || name == "exec":
c.signal(SignalEnvWrapper)
args = dropWrapperFlags(name, args[1:])
via = joinVia(via, name)
Signed-off-by: Mindburn Labs <mindburnlabs@users.noreply.github.com>
Summary
Replaces the evadable substring needle list in
core/cmd/helm-ai-kernel/hook_cmd.gowith a structural bash AST classifier (newcore/pkg/shellscan) as advisory input to the existing signed decision path. Verdict authority is unchanged —workstation.Decidestill produces the permit/receipt verdict, fail-closed; the classifier only decides whether a shell command reaches that path.Evidence base:
research/opencode-study/03-core-tools.md(opencode's tree-sitter shell scan + arity dictionary) andresearch/opencode-study/25-helm-map-agent-side.md(A1: needle list trivially evadable —rm -r -f,xargs rm,find -delete, variable indirection; A2: arity table; guidance to keep the needle list as a fallback layer).What shipped
mvdan.cc/sh/v3(pure Go, zero cgo; chosen over tree-sitter Go bindings to keep the TCB small and the build cgo-free; only new transitive deps aregolang.org/x/sys/golang.org/x/term, already in the module graph). Walks all command nodes including pipelines, subshells, and command substitutions.git checkout,npm run dev,docker compose up) via a Go port of opencode's arity dictionary — groundwork for permit scoping (A2).rm -r -f,rm --recursive --force, flags after operands),sudo/doas/env/nice/nohup/stdbuf/setsid/busybox/xargswrapper unwrapping, recursivesh -c/bash -c/evalpayload classification (depth-capped), base64/xxd/openssl decode-to-shell pipelines, path obfuscation (/bin/./rm),find -delete/find -exec rm -rf, and write-redirects to sensitive targets (>> .env) that bypassed Write-tool path checks.$(echo rm) -rf /), dynamic flags/operands on destructive families, and dynamiceval/sh -cpayloads all route to the decision path.Test evidence
go test ./pkg/shellscan/ -count=1→ ok — 52 decide cases (12 needle-parity + 40 evasion/fail-closed, ≥20 evasion required), 26 benign allowlist-regression cases, arity prefix table tests, signal/Via-chain tests.go test ./cmd/helm-ai-kernel/ -run 'TestHookPreTool' -count=1→ ok — all pre-existing hook golden tests pass unchanged; newTestHookPreToolDeniesEvasiveBashViaASTClassifier(10 evasive commands → deny + one signed receipt each) andTestHookPreToolStillAllowsBenignBashAfterASTClassifier(6 benign commands → silent passthrough, no receipts).go test ./pkg/shellscan/ ./cmd/helm-ai-kernel/ -count=1→ ok (full CLI package suite incl. golden tests).go build ./...andgo vet ./pkg/shellscan/ ./cmd/helm-ai-kernel/→ clean;gofmt -l→ clean.Risk notes
rm $f,bash -c $PAYLOAD, unparseable input) now reach the decision path and are denied under the default profile (OPERATE_PERMISSIONS_EMPTY). This is the fail-closed intent, but operators with existing permits may see new decision receipts.bash script.sh(static script file) remains passthrough-with-signal; script contents are opaque — noted for follow-up.Preflight (self-applied per .claude/skills/helm-pr-preflight)
core/(hook + new shellscan package + go.mod/go.sum). No unrelated dirty state.Gates
This PR still requires the repo's normal source-owned deterministic gates, distinct-provider 2-of-2 permit, and exact-head approval-only App interlock. It must not be merged on the strength of this PR body, reviews, or labels.
Linear: proposed key HELM-W1 (proposal only; Linear MCP unavailable in this runtime).