Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,20 @@ touch /tmp/demo-file
go run . chmod 777 /tmp/demo-file
```

You'll get:
You'll get an interactive confirmation prompt:

```
gynx: chmod 777 /tmp/demo-file
warning: Setting world-writable permissions
Proceed? [y/N]: y
╭──────────────────────────────────────────╮
│ gynx: chmod 777 /tmp/demo-file │
│ warning: Setting world-writable permissions │
│ Proceed? │
╰──────────────────────────────────────────╯
y/enter: proceed n/esc/ctrl+c: abort ↑/↓: scroll
```

the output for `y` ofcourse will just be whatever cmd result that binary that went through gives you, but for the `N` option, you'll be presented this:
For `cp`/`mv` commands that would overwrite an existing file, gynx shows a unified diff of what's about to change right inside that same prompt before you decide.

Pressing `y`/enter runs the real command and streams its normal output; pressing `n`/esc/ctrl+c aborts:

```
aborted: command not executed
Expand Down Expand Up @@ -83,7 +89,6 @@ gynx uninstall # remove all aliases
- Community watchlist presets (Docker, Kubernetes, database tooling)
- Dry-run mode — show what would be intercepted without prompting
- Audit log — keep a record of intercepted commands
- If it's cp or overwriting a file, show them diffs? or ask them if they meant to rewrite file `x` to `y`


Please be reminded, this is still in development, you might sometimes not get the desired outcome.
115 changes: 115 additions & 0 deletions cmd/proxy_execution_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import (
"io"
"os"
"os/exec"
"strings"
"testing"

"github.com/cod3ddy/gynx/internal/config"
"github.com/cod3ddy/gynx/internal/diffpreview"
"github.com/cod3ddy/gynx/internal/watchlist"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand All @@ -18,6 +20,7 @@ func resetProxyHooks(t *testing.T) {
origLoadConfig := loadConfigFn
origLoadWatchlist := loadWatchlistFn
origMatch := matchRuleFn
origDiffPreview := diffPreviewFn
origConfirm := confirmPromptFn
origCommandFactory := commandFactoryFn
origInteractive := isInteractiveFn
Expand All @@ -26,6 +29,7 @@ func resetProxyHooks(t *testing.T) {
loadConfigFn = origLoadConfig
loadWatchlistFn = origLoadWatchlist
matchRuleFn = origMatch
diffPreviewFn = origDiffPreview
confirmPromptFn = origConfirm
commandFactoryFn = origCommandFactory
isInteractiveFn = origInteractive
Expand Down Expand Up @@ -284,3 +288,114 @@ func TestExecuteProxy_ForwardsStdIOToCommand(t *testing.T) {

assert.Equal(t, "proxy-stdio\n", string(outBytes))
}

func TestExecuteProxy_PreviewApplicableIncludedInPrompt(t *testing.T) {
resetProxyHooks(t)

loadConfigFn = func(path string) (config.Config, error) {
return config.Default(), nil
}

loadWatchlistFn = func(path string) ([]watchlist.Rule, error) {
return []watchlist.Rule{{Command: "cp", Warning: "Copy can overwrite existing files"}}, nil
}

matchRuleFn = func(command string, args []string, rules []watchlist.Rule) (watchlist.Rule, bool) {
return watchlist.Rule{Command: "cp", Warning: "Copy can overwrite existing files"}, true
}

isInteractiveFn = func() bool { return true }
diffPreviewFn = func(command string, args []string) diffpreview.Result {
return diffpreview.Result{Applicable: true, Text: "--- dest\n+++ src\n@@ -1 +1 @@\n-old\n+new"}
}

var gotPrompt string
confirmPromptFn = func(message string) bool {
gotPrompt = message
return true
}

commandFactoryFn = func(ctx context.Context, name string, args ...string) *exec.Cmd {
return exec.Command("bash", "-lc", "true")
}

err := executeProxy("ignored", "ignored", []string{"cp", "src", "dest"})
require.NoError(t, err)

warnIdx := strings.Index(gotPrompt, "warning:")
diffIdx := strings.Index(gotPrompt, "--- dest")
proceedIdx := strings.Index(gotPrompt, "Proceed?")
require.True(t, warnIdx >= 0 && diffIdx >= 0 && proceedIdx >= 0)
assert.True(t, warnIdx < diffIdx && diffIdx < proceedIdx)
}

func TestExecuteProxy_PreviewNotApplicable_MessageUnchanged(t *testing.T) {
resetProxyHooks(t)

loadConfigFn = func(path string) (config.Config, error) {
return config.Default(), nil
}
loadWatchlistFn = func(path string) ([]watchlist.Rule, error) {
return []watchlist.Rule{{Command: "cp", Warning: "Copy can overwrite existing files"}}, nil
}
matchRuleFn = func(command string, args []string, rules []watchlist.Rule) (watchlist.Rule, bool) {
return watchlist.Rule{Command: "cp", Warning: "Copy can overwrite existing files"}, true
}
isInteractiveFn = func() bool { return true }
diffPreviewFn = func(command string, args []string) diffpreview.Result {
return diffpreview.Result{}
}

var gotPrompt string
confirmPromptFn = func(message string) bool {
gotPrompt = message
return true
}

commandFactoryFn = func(ctx context.Context, name string, args ...string) *exec.Cmd {
return exec.Command("bash", "-lc", "true")
}

err := executeProxy("ignored", "ignored", []string{"cp", "src", "dest"})
require.NoError(t, err)
assert.Equal(t, "gynx: cp src dest\nwarning: Copy can overwrite existing files\nProceed?", gotPrompt)
}

func TestExecuteProxy_RealDiffPreviewWiring_CpOverwritesExistingFile(t *testing.T) {
resetProxyHooks(t)

dir := t.TempDir()
dest := dir + "/dest.txt"
src := dir + "/src.txt"
require.NoError(t, os.WriteFile(dest, []byte("old content\n"), 0o644))
require.NoError(t, os.WriteFile(src, []byte("new content\n"), 0o644))

loadConfigFn = func(path string) (config.Config, error) {
return config.Default(), nil
}
loadWatchlistFn = func(path string) ([]watchlist.Rule, error) {
return []watchlist.Rule{{Command: "cp", Warning: "Copy can overwrite existing files"}}, nil
}
matchRuleFn = func(command string, args []string, rules []watchlist.Rule) (watchlist.Rule, bool) {
return watchlist.Rule{Command: "cp", Warning: "Copy can overwrite existing files"}, true
}
isInteractiveFn = func() bool { return true }

var gotPrompt string
confirmPromptFn = func(message string) bool {
gotPrompt = message
return true
}
commandFactoryFn = func(ctx context.Context, name string, args ...string) *exec.Cmd {
return exec.Command(name, args...)
}

err := executeProxy("ignored", "ignored", []string{"cp", src, dest})
require.NoError(t, err)
assert.Contains(t, gotPrompt, "-old content")
assert.Contains(t, gotPrompt, "+new content")

gotContent, err := os.ReadFile(dest)
require.NoError(t, err)
assert.Equal(t, "new content\n", string(gotContent))
}
13 changes: 12 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"time"

"github.com/cod3ddy/gynx/internal/config"
"github.com/cod3ddy/gynx/internal/diffpreview"
"github.com/cod3ddy/gynx/internal/executor"
"github.com/cod3ddy/gynx/internal/matcher"
"github.com/cod3ddy/gynx/internal/prompter"
Expand All @@ -30,6 +31,7 @@ var (
loadConfigFn = config.Load
loadWatchlistFn = watchlist.Load
matchRuleFn = matcher.MatchRule
diffPreviewFn = diffpreview.Build
confirmPromptFn = prompter.Confirm
commandFactoryFn = executor.Command
isInteractiveFn = isInteractiveSession
Expand Down Expand Up @@ -83,13 +85,22 @@ func executeProxy(configPath, watchlistPath string, args []string) error {

if isInteractiveFn() {
fullCommand := command

if len(commandArgs) > 0 {
fullCommand = fmt.Sprintf("%s %s", command, strings.Join(commandArgs, " "))
}
message := fmt.Sprintf("gynx: %s\nwarning: %s\nProceed?", fullCommand, warning)

message := fmt.Sprintf("gynx: %s\nwarning: %s", fullCommand, warning)
if preview := diffPreviewFn(command, commandArgs); preview.Applicable {
message = fmt.Sprintf("%s\n\n%s\n\nProceed?", message, preview.Text)
} else {
message = fmt.Sprintf("%s\nProceed?", message)
}

if !confirmPromptFn(strings.TrimSpace(message)) {
return fmt.Errorf("aborted: command not executed")
}

} else if !cfg.NonInteractive.Passthrough {
return fmt.Errorf("blocked in non-interactive mode: %s", command)
}
Expand Down
23 changes: 22 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ module github.com/cod3ddy/gynx
go 1.25.1

require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/pmezard/go-difflib v1.0.0
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
Expand All @@ -11,18 +15,35 @@ require (
)

require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.28.0 // indirect
Expand Down
46 changes: 46 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,6 +1,30 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
Expand All @@ -15,10 +39,26 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
Expand All @@ -41,8 +81,14 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
Expand Down
Loading
Loading