From affb391b63e9b74ed962ed10d7eadfe437eab0e3 Mon Sep 17 00:00:00 2001 From: Amol Mane <22643905+cx-amol-mane@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:40:20 +0530 Subject: [PATCH 1/6] Implement unified agent hooks for AI coding agents and enhance hooks command functionality - Added new `agenthooks.go` file to manage hooks for AI agents (Claude, Cursor, Windsurf, Factory Droid, Gemini). - Introduced unified event handlers for agent idle states, tool calls, file writes, and user prompts. - Updated `hooks.go` to include management commands for agent hooks and integrated hidden dispatch commands for routing. - Updated `go.mod` and `go.sum` to include new dependencies and version upgrades for existing packages. --- go.mod | 32 +-- go.sum | 65 +++--- internal/commands/agenthooks.go | 337 ++++++++++++++++++++++++++++++++ internal/commands/hooks.go | 15 +- 4 files changed, 398 insertions(+), 51 deletions(-) create mode 100644 internal/commands/agenthooks.go diff --git a/go.mod b/go.mod index e2c14ffe5..74f5b39f3 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,6 @@ module github.com/checkmarx/ast-cli go 1.25.8 - require ( github.com/Checkmarx/containers-resolver v1.0.33 github.com/Checkmarx/containers-types v1.0.9 @@ -14,6 +13,7 @@ require ( github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 github.com/bouk/monkey v1.0.0 github.com/checkmarx/2ms/v3 v3.21.0 + github.com/cx-amol-mane/hooks v0.0.0 github.com/gofrs/flock v0.12.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/gomarkdown/markdown v0.0.0-20241102151059-6bc1ffdc6e8c @@ -28,10 +28,10 @@ require ( github.com/stretchr/testify v1.11.1 github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 github.com/xeipuuv/gojsonschema v1.2.0 - golang.org/x/crypto v0.45.0 - golang.org/x/sync v0.18.0 - golang.org/x/text v0.31.0 - google.golang.org/grpc v1.75.0 + golang.org/x/crypto v0.46.0 + golang.org/x/sync v0.19.0 + golang.org/x/text v0.32.0 + google.golang.org/grpc v1.79.3 google.golang.org/protobuf v1.36.10 gopkg.in/yaml.v3 v3.0.1 gotest.tools v2.2.0+incompatible @@ -39,11 +39,11 @@ require ( require ( cyphar.com/go-pathrs v0.2.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/coreos/go-systemd/v22 v22.6.0 // indirect github.com/onsi/ginkgo/v2 v2.25.1 // indirect github.com/onsi/gomega v1.38.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect ) @@ -282,26 +282,26 @@ require ( github.com/zclconf/go-cty v1.16.2 // indirect github.com/zricethezav/gitleaks/v8 v8.18.2 // indirect go.opencensus.io v0.24.0 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect - go.opentelemetry.io/otel v1.37.0 // indirect - go.opentelemetry.io/otel/metric v1.37.0 // indirect - go.opentelemetry.io/otel/trace v1.37.0 // indirect + go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect go.uber.org/mock v0.5.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/mod v0.30.0 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.33.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/term v0.37.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/term v0.38.0 // indirect golang.org/x/time v0.12.0 // indirect golang.org/x/tools v0.39.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/genproto v0.0.0-20250324211829-b45e905df463 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect @@ -330,6 +330,8 @@ require ( sigs.k8s.io/yaml v1.6.0 // indirect ) +replace github.com/cx-amol-mane/hooks => ../hooks + replace github.com/containerd/containerd => github.com/containerd/containerd v1.7.29 replace github.com/opencontainers/selinux => github.com/opencontainers/selinux v1.13.0 diff --git a/go.sum b/go.sum index db2028549..e5cea4297 100644 --- a/go.sum +++ b/go.sum @@ -199,7 +199,6 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -1042,16 +1041,16 @@ go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 h1:UW0+QyeyBVhn+COBec3nGhfnFe5lwB0ic1JBVjzhk0w= go.opentelemetry.io/contrib/bridges/prometheus v0.57.0/go.mod h1:ppciCHRLsyCio54qbzQv0E4Jyth/fLWDTJYfvWpcSVk= go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 h1:jmTVJ86dP60C01K3slFQa2NQ/Aoi7zA+wy7vMOKD9H4= go.opentelemetry.io/contrib/exporters/autoexport v0.57.0/go.mod h1:EJBheUMttD/lABFyLXhce47Wr6DPWYReCzaZiXadH7g= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 h1:WzNab7hOOLzdDF/EoWCt4glhrbMPVMOO5JYTmpz36Ls= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0/go.mod h1:hKvJwTzJdp90Vh7p6q/9PAOd55dI6WA6sWj62a/JvSs= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 h1:S+LdBGiQXtJdowoJoQPEtI52syEP/JYBUpjO49EQhV8= @@ -1076,16 +1075,16 @@ go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 h1:cC2yDI3IQd0Udsu go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0/go.mod h1:2PD5Ex6z8CFzDbTdOlwyNIUywRr1DN0ospafJM1wJ+s= go.opentelemetry.io/otel/log v0.8.0 h1:egZ8vV5atrUWUbnSsHn6vB8R21G2wrKqNiDt3iWertk= go.opentelemetry.io/otel/log v0.8.0/go.mod h1:M9qvDdUTRCopJcGRKg57+JSQ9LgLBrwwfC32epk5NX8= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= -go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk/log v0.8.0 h1:zg7GUYXqxk1jnGF/dTdLPrK06xJdrXgqgFLnI4Crxvs= go.opentelemetry.io/otel/sdk/log v0.8.0/go.mod h1:50iXr0UVwQrYS45KbruFrEt4LvAdCaWWgIrsN3ZQggo= -go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= -go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= @@ -1118,8 +1117,8 @@ golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -1206,8 +1205,8 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1225,8 +1224,8 @@ golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= -golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1239,8 +1238,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1317,13 +1316,13 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1334,8 +1333,8 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1510,10 +1509,10 @@ google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ6 google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20250324211829-b45e905df463 h1:qEFnJI6AnfZk0NNe8YTyXQh5i//Zxi4gBHwRgp76qpw= google.golang.org/genproto v0.0.0-20250324211829-b45e905df463/go.mod h1:SqIx1NV9hcvqdLHo7uNZDS5lrUJybQ3evo3+z/WBfA0= -google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= -google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -1541,8 +1540,8 @@ google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnD google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= -google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= -google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= diff --git a/internal/commands/agenthooks.go b/internal/commands/agenthooks.go new file mode 100644 index 000000000..2724af9f8 --- /dev/null +++ b/internal/commands/agenthooks.go @@ -0,0 +1,337 @@ +package commands + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/MakeNowJust/heredoc" + agenthooks "github.com/cx-amol-mane/hooks" + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +// ============================================================================= +// Unified logic — written ONCE, runs for all 5 AI agents. +// +// Each function maps to one event type across the full agent matrix: +// +// cxWhenAgentIdle → Claude stop / Cursor stop / Windsurf cascade / +// Factory Droid stop / Gemini after-agent +// cxBeforeToolCall → Claude pre-tool-use / Cursor before-shell+mcp / +// Windsurf pre-run+mcp / Droid pre-tool-use / +// Gemini before-tool +// cxAfterFileWrite → Claude post-tool-use / Cursor after-file-edit / +// Windsurf post-write-code / Droid post-tool-use / +// Gemini after-file-tool +// cxBeforePrompt → Claude user-prompt / Cursor before-submit / +// Windsurf pre-user-prompt / Droid user-prompt / +// Gemini before-agent +// ============================================================================= + +// cxWhenAgentIdle is called when any agent finishes its response turn. +// ev.Agent identifies which IDE triggered it (claude / cursor / windsurf / droid / gemini). +func cxWhenAgentIdle(ev agenthooks.AgentIdleEvent) agenthooks.IdleVerdict { + // TODO: post-session audit log, compliance check, scan trigger + return agenthooks.Resume() +} + +// blockedShellPatterns lists shell command patterns that cx will always deny. +var blockedShellPatterns = []string{ + "rm -rf", + "rm -fr", + "rmdir /s", + "del /f /s /q", + "format ", + "mkfs.", + ":(){:|:&};:", // fork bomb + "curl | bash", + "curl | sh", + "wget -O- | bash", + "wget -O- | sh", +} + +// cxBeforeToolCall is called before any shell command or MCP tool executes. +// ev.IsShell() — bash/terminal command, ev.Command holds the command string. +// ev.IsMCP() — MCP tool call, ev.ToolName holds the tool identifier. +func cxBeforeToolCall(ev agenthooks.ToolCallEvent) agenthooks.ToolVerdict { + if ev.IsShell() { + lower := strings.ToLower(ev.Command) + for _, pattern := range blockedShellPatterns { + if strings.Contains(lower, strings.ToLower(pattern)) { + return agenthooks.Deny( + fmt.Sprintf("Blocked by Checkmarx policy: command contains dangerous pattern %q", pattern), + ) + } + } + } + // TODO: check ev.ToolName against MCP server allow-list + // TODO: call Checkmarx API for real-time secret scan before execution + return agenthooks.Allow() +} + +// cxAfterFileWrite is called after any file is written or edited by an agent. +// ev.FilePath holds the path of the written file. +// ev.Changes holds before/after diffs (where available). +func cxAfterFileWrite(ev agenthooks.FileWriteEvent) agenthooks.FileWriteVerdict { + // TODO: run Checkmarx secret detection on ev.FilePath + // TODO: run SAST/SCA quick scan on the modified file + return agenthooks.AcceptWrite() +} + +// cxBeforePrompt is called before a user prompt is sent to any agent. +// ev.Text holds the raw prompt text. +func cxBeforePrompt(ev agenthooks.PromptEvent) agenthooks.PromptVerdict { + lower := strings.ToLower(ev.Text) + for _, pattern := range blockedShellPatterns { + if strings.Contains(lower, strings.ToLower(pattern)) { + return agenthooks.RejectPrompt( + fmt.Sprintf("Blocked by Checkmarx policy: prompt contains dangerous pattern %q", pattern), + ) + } + } + // TODO: scan ev.Text for credentials / secret patterns + // TODO: enforce prompt policy (e.g. block PII, block internal data leakage) + return agenthooks.AcceptPrompt() +} + +// setupCXHooks registers all four unified handlers. +// Called once per dispatch — cheap, idempotent (map overwrites same values). +func setupCXHooks() { + agenthooks.WhenAgentIdle(cxWhenAgentIdle) + agenthooks.BeforeToolCall(cxBeforeToolCall) + agenthooks.AfterFileWrite(cxAfterFileWrite) + agenthooks.BeforePrompt(cxBeforePrompt) +} + +// ============================================================================= +// Cobra routing — 22 hidden commands, zero logic. +// +// These exist only because Cobra sits between os.Args and Dispatch(): +// os.Args = ["cx.exe", "hooks", "claude-pre-tool-use"] +// os.Args[1] = "hooks" ← Dispatch() would read this, not the route name +// +// Each command captures its own route name via cmd.Use and calls DispatchRoute, +// which looks up the route registered by setupCXHooks() and invokes it. +// ============================================================================= + +// HookDispatchCommands returns all 22 hidden route-dispatch subcommands. +// Registered under "cx hooks" — agents invoke: cx hooks +func HookDispatchCommands() []*cobra.Command { + type route struct{ use, short string } + + routes := []route{ + // ── WhenAgentIdle (5 routes) ───────────────────────────────────────── + {"claude-stop", "Claude agent finished responding"}, + {"cursor-stop", "Cursor agent finished responding"}, + {"windsurf-post-cascade-response", "Windsurf agent finished responding"}, + {"droid-stop", "Factory Droid agent finished responding"}, + {"gemini-after-agent", "Gemini agent finished responding"}, + + // ── BeforeToolCall (7 routes) ──────────────────────────────────────── + {"claude-pre-tool-use", "Gate Claude tool/shell execution"}, + {"cursor-before-shell", "Gate Cursor shell execution"}, + {"cursor-before-mcp", "Gate Cursor MCP tool execution"}, + {"windsurf-pre-run-command", "Gate Windsurf shell execution"}, + {"windsurf-pre-mcp-tool-use", "Gate Windsurf MCP tool execution"}, + {"droid-pre-tool-use", "Gate Factory Droid tool execution"}, + {"gemini-before-tool", "Gate Gemini tool execution"}, + + // ── AfterFileWrite (5 routes) ──────────────────────────────────────── + {"claude-after-file-write", "React after Claude writes a file"}, + {"cursor-after-file-edit", "React after Cursor edits a file"}, + {"windsurf-post-write-code", "React after Windsurf writes a file"}, + {"droid-after-file-write", "React after Factory Droid writes a file"}, + {"gemini-after-file-tool", "React after Gemini writes a file"}, + + // ── BeforePrompt (5 routes) ────────────────────────────────────────── + {"claude-user-prompt-submit", "Gate Claude prompt submission"}, + {"cursor-before-submit-prompt", "Gate Cursor prompt submission"}, + {"windsurf-pre-user-prompt", "Gate Windsurf prompt submission"}, + {"droid-user-prompt-submit", "Gate Factory Droid prompt submission"}, + {"gemini-before-agent", "Gate Gemini prompt submission"}, + } + + cmds := make([]*cobra.Command, len(routes)) + for i, r := range routes { + r := r + cmds[i] = &cobra.Command{ + Use: r.use, + Short: r.short, + Hidden: true, + // Override root PersistentPreRunE — CX credential/config loading must + // never run during hook dispatch: any stdout output corrupts the JSON + // response the agent reads. + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + return nil + }, + Run: func(cmd *cobra.Command, args []string) { + setupCXHooks() // register the 4 unified handlers + agenthooks.DispatchRoute(cmd.Use) // route to the right one + }, + } + } + return cmds +} + +// ============================================================================= +// Management command — cx hooks agenthooks +// ============================================================================= + +// NewAgentHooksCommand creates the visible "cx hooks agenthooks" management command. +func NewAgentHooksCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "agenthooks", + Short: "Manage AI coding agent hook configuration", + Long: "Configure AI coding agent hooks to invoke cx directly. No separate binary needed.", + Example: heredoc.Doc(` + $ cx hooks agenthooks install + `), + } + cmd.AddCommand(agentHooksInstallCommand()) + return cmd +} + +// ============================================================================= +// install +// ============================================================================= + +func agentHooksInstallCommand() *cobra.Command { + return &cobra.Command{ + Use: "install", + Short: "Write hook configs for all agents pointing to this cx binary", + Long: heredoc.Doc(` + Patches the settings files for Claude Code, Cursor, Windsurf Cascade, + and Factory Droid to invoke "cx hooks " for each hook event. + cx itself handles all dispatch — no separate binary needed. + `), + Example: heredoc.Doc(` + $ cx hooks agenthooks install + `), + RunE: func(cmd *cobra.Command, args []string) error { + return runAgentHooksInstall() + }, + } +} + +func runAgentHooksInstall() error { + cxPath, err := os.Executable() + if err != nil { + return errors.Wrapf(err, "resolving cx binary path") + } + + home, err := os.UserHomeDir() + if err != nil { + return errors.Wrapf(err, "finding home directory") + } + + agents := []struct { + name string + fn func(string, string) error + }{ + {"Claude Code", agentHooksInstallClaude}, + {"Cursor", agentHooksInstallCursor}, + {"Windsurf Cascade", agentHooksInstallWindsurf}, + {"Factory Droid", agentHooksInstallDroid}, + } + + for _, a := range agents { + if err := a.fn(home, cxPath); err != nil { + fmt.Fprintf(os.Stderr, "warning: %s: %v\n", a.name, err) + } else { + fmt.Fprintf(os.Stdout, "✓ %s configured\n", a.name) + } + } + return nil +} + +func agentHooksInstallClaude(home, cxPath string) error { + return agentHooksPatchJSON(filepath.Join(home, ".claude", "settings.json"), func(m map[string]any) { + h := agentHooksEnsureMap(m, "hooks") + h["Stop"] = cxHookEntries(cxPath, "claude-stop") + h["PreToolUse"] = cxHookEntries(cxPath, "claude-pre-tool-use") + h["PostToolUse"] = cxHookEntries(cxPath, "claude-after-file-write") + h["UserPromptSubmit"] = cxHookEntries(cxPath, "claude-user-prompt-submit") + }) +} + +func agentHooksInstallCursor(home, cxPath string) error { + return agentHooksPatchJSON(filepath.Join(home, ".cursor", "hooks.json"), func(m map[string]any) { + m["stop"] = cxCmd(cxPath, "cursor-stop") + m["beforeShellExecution"] = cxCmd(cxPath, "cursor-before-shell") + m["beforeMCPExecution"] = cxCmd(cxPath, "cursor-before-mcp") + m["afterFileEdit"] = cxCmd(cxPath, "cursor-after-file-edit") + m["beforeSubmitPrompt"] = cxCmd(cxPath, "cursor-before-submit-prompt") + }) +} + +func agentHooksInstallWindsurf(home, cxPath string) error { + return agentHooksPatchJSON(filepath.Join(home, ".codeium", "windsurf", "hooks.json"), func(m map[string]any) { + m["pre_run_command"] = cxCmd(cxPath, "windsurf-pre-run-command") + m["pre_mcp_tool_use"] = cxCmd(cxPath, "windsurf-pre-mcp-tool-use") + m["pre_user_prompt"] = cxCmd(cxPath, "windsurf-pre-user-prompt") + m["post_write_code"] = cxCmd(cxPath, "windsurf-post-write-code") + m["post_cascade_response"] = cxCmd(cxPath, "windsurf-post-cascade-response") + }) +} + +func agentHooksInstallDroid(home, cxPath string) error { + return agentHooksPatchJSON(filepath.Join(home, ".factory", "settings.json"), func(m map[string]any) { + h := agentHooksEnsureMap(m, "hooks") + h["Stop"] = cxHookEntries(cxPath, "droid-stop") + h["PreToolUse"] = cxHookEntries(cxPath, "droid-pre-tool-use") + h["PostToolUse"] = cxHookEntries(cxPath, "droid-after-file-write") + h["UserPromptSubmit"] = cxHookEntries(cxPath, "droid-user-prompt-submit") + }) +} + +// cxCommandString builds the shell command string pointing cx at a given route. +// Quotes the binary path if it contains spaces (e.g. C:\Program Files\cx.exe). +func cxCommandString(cxPath, route string) string { + p := cxPath + if strings.Contains(p, " ") { + p = `"` + p + `"` + } + return p + " hooks " + route +} + +// cxHookEntries builds a Claude/Droid-style hook entry: [{type, command}] +func cxHookEntries(cxPath, route string) []map[string]any { + return []map[string]any{ + {"type": "command", "command": cxCommandString(cxPath, route)}, + } +} + +// cxCmd builds a Cursor/Windsurf-style hook entry: {command} +func cxCmd(cxPath, route string) map[string]any { + return map[string]any{"command": cxCommandString(cxPath, route)} +} + +func agentHooksPatchJSON(path string, patch func(map[string]any)) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + m := map[string]any{} + if data, err := os.ReadFile(path); err == nil { + _ = json.Unmarshal(data, &m) + } + patch(m) + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(data, '\n'), 0o644) +} + +func agentHooksEnsureMap(m map[string]any, key string) map[string]any { + if v, ok := m[key]; ok { + if sub, ok := v.(map[string]any); ok { + return sub + } + } + sub := map[string]any{} + m[key] = sub + return sub +} diff --git a/internal/commands/hooks.go b/internal/commands/hooks.go index cfb304b19..5dc3ccf21 100644 --- a/internal/commands/hooks.go +++ b/internal/commands/hooks.go @@ -12,13 +12,14 @@ import ( func NewHooksCommand(jwtWrapper wrappers.JWTWrapper, featureFlagsWrapper wrappers.FeatureFlagsWrapper) *cobra.Command { hooksCmd := &cobra.Command{ Use: "hooks", - Short: "Manage Git hooks", - Long: "The hooks command enables the ability to manage Git hooks for Checkmarx One.", + Short: "Manage Git hooks and AI coding agent hooks", + Long: "The hooks command manages Git hooks for secret detection and AI coding agent hooks for Claude, Cursor, Windsurf, Factory Droid, and Gemini.", Example: heredoc.Doc( ` $ cx hooks pre-commit secrets-install-git-hook $ cx hooks pre-commit secrets-scan $ cx hooks pre-receive secrets-scan + $ cx hooks agenthooks install `, ), Annotations: map[string]string{ @@ -30,9 +31,17 @@ func NewHooksCommand(jwtWrapper wrappers.JWTWrapper, featureFlagsWrapper wrapper }, } - // Add pre-commit and pre-receive subcommand + // Add pre-commit, pre-receive, and agenthooks management subcommands hooksCmd.AddCommand(PreCommitCommand(jwtWrapper, featureFlagsWrapper)) hooksCmd.AddCommand(PreReceiveCommand(jwtWrapper, featureFlagsWrapper)) + hooksCmd.AddCommand(NewAgentHooksCommand()) + + // Register all hidden hook dispatch subcommands so that cx itself acts as + // the hook binary. Agents invoke: cx hooks + // e.g. cx hooks claude-pre-tool-use + for _, dispatchCmd := range HookDispatchCommands() { + hooksCmd.AddCommand(dispatchCmd) + } return hooksCmd } From d591b0523024def039a3381a36f4ce1ed0bbee75 Mon Sep 17 00:00:00 2001 From: Hitesh Madgulkar <212497904+cx-hitesh-madgulkar@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:14:50 +0530 Subject: [PATCH 2/6] promptGuardRail --- internal/commands/agenthooks.go | 386 +++++++++++++++++--------------- 1 file changed, 202 insertions(+), 184 deletions(-) diff --git a/internal/commands/agenthooks.go b/internal/commands/agenthooks.go index 2724af9f8..ac64bdb8a 100644 --- a/internal/commands/agenthooks.go +++ b/internal/commands/agenthooks.go @@ -8,150 +8,169 @@ import ( "strings" "github.com/MakeNowJust/heredoc" + scanner "github.com/checkmarx/2ms/v3/pkg" + "github.com/checkmarx/ast-cli/internal/params" + "github.com/checkmarx/ast-cli/internal/wrappers" + "github.com/checkmarx/ast-cli/internal/wrappers/configuration" agenthooks "github.com/cx-amol-mane/hooks" "github.com/pkg/errors" "github.com/spf13/cobra" ) // ============================================================================= -// Unified logic — written ONCE, runs for all 5 AI agents. -// -// Each function maps to one event type across the full agent matrix: -// -// cxWhenAgentIdle → Claude stop / Cursor stop / Windsurf cascade / -// Factory Droid stop / Gemini after-agent -// cxBeforeToolCall → Claude pre-tool-use / Cursor before-shell+mcp / -// Windsurf pre-run+mcp / Droid pre-tool-use / -// Gemini before-tool -// cxAfterFileWrite → Claude post-tool-use / Cursor after-file-edit / -// Windsurf post-write-code / Droid post-tool-use / -// Gemini after-file-tool -// cxBeforePrompt → Claude user-prompt / Cursor before-submit / -// Windsurf pre-user-prompt / Droid user-prompt / -// Gemini before-agent +// Guardrail handlers — written once, applied to all 5 AI agents. // ============================================================================= -// cxWhenAgentIdle is called when any agent finishes its response turn. -// ev.Agent identifies which IDE triggered it (claude / cursor / windsurf / droid / gemini). -func cxWhenAgentIdle(ev agenthooks.AgentIdleEvent) agenthooks.IdleVerdict { - // TODO: post-session audit log, compliance check, scan trigger +// cxWhenAgentIdle: agent finished its turn. Nothing to enforce yet. +func cxWhenAgentIdle(_ agenthooks.AgentIdleEvent) agenthooks.IdleVerdict { return agenthooks.Resume() } -// blockedShellPatterns lists shell command patterns that cx will always deny. -var blockedShellPatterns = []string{ - "rm -rf", - "rm -fr", - "rmdir /s", - "del /f /s /q", - "format ", - "mkfs.", - ":(){:|:&};:", // fork bomb - "curl | bash", - "curl | sh", - "wget -O- | bash", - "wget -O- | sh", +// cxBeforeToolCall: gate shell/MCP execution. Nothing to enforce yet. +func cxBeforeToolCall(_ agenthooks.ToolCallEvent) agenthooks.ToolVerdict { + return agenthooks.Allow() +} + +// cxAfterFileWrite: react to file edits. Nothing to enforce yet. +func cxAfterFileWrite(_ agenthooks.FileWriteEvent) agenthooks.FileWriteVerdict { + return agenthooks.AcceptWrite() +} + +// cxBeforePrompt scans the user's prompt for leaked secrets using the 2ms +// engine before it reaches the AI agent. This is the prompt guardrail. +func cxBeforePrompt(ev agenthooks.PromptEvent) agenthooks.PromptVerdict { + if reason := scanForSecrets(ev.Text); reason != "" { + return agenthooks.RejectPrompt(reason) + } + return agenthooks.AcceptPrompt() } -// cxBeforeToolCall is called before any shell command or MCP tool executes. -// ev.IsShell() — bash/terminal command, ev.Command holds the command string. -// ev.IsMCP() — MCP tool call, ev.ToolName holds the tool identifier. -func cxBeforeToolCall(ev agenthooks.ToolCallEvent) agenthooks.ToolVerdict { - if ev.IsShell() { - lower := strings.ToLower(ev.Command) - for _, pattern := range blockedShellPatterns { - if strings.Contains(lower, strings.ToLower(pattern)) { - return agenthooks.Deny( - fmt.Sprintf("Blocked by Checkmarx policy: command contains dangerous pattern %q", pattern), - ) - } +// ============================================================================= +// Secret scanning — powered by the same 2ms engine used in cx realtime scan. +// ============================================================================= + +// scanForSecrets runs the 2ms secret scanner on arbitrary text (e.g. a prompt). +// Returns a human-readable rejection reason, or "" when the text is clean. +func scanForSecrets(text string) string { + content := text + report, err := scanner.NewScanner().Scan( + []scanner.ScanItem{{Content: &content, Source: "prompt"}}, + scanner.ScanConfig{WithValidation: true}, + ) + if err != nil { + return "" // fail-open: scanner error should not block the developer + } + + var findings []string + for _, group := range report.Results { + for _, secret := range group { + severity := severityFromValidation(string(secret.ValidationStatus)) + findings = append(findings, fmt.Sprintf(" - %s (severity: %s)", secret.RuleID, severity)) } } - // TODO: check ev.ToolName against MCP server allow-list - // TODO: call Checkmarx API for real-time secret scan before execution - return agenthooks.Allow() + if len(findings) == 0 { + return "" + } + return fmt.Sprintf( + "Blocked by Checkmarx: prompt contains %d secret(s):\n%s\nRemove the secrets and try again.", + len(findings), strings.Join(findings, "\n"), + ) } -// cxAfterFileWrite is called after any file is written or edited by an agent. -// ev.FilePath holds the path of the written file. -// ev.Changes holds before/after diffs (where available). -func cxAfterFileWrite(ev agenthooks.FileWriteEvent) agenthooks.FileWriteVerdict { - // TODO: run Checkmarx secret detection on ev.FilePath - // TODO: run SAST/SCA quick scan on the modified file - return agenthooks.AcceptWrite() +// severityFromValidation maps 2ms validation status to a severity label. +func severityFromValidation(status string) string { + switch status { + case "Valid": + return "Critical" + case "Invalid": + return "Medium" + default: // "Unknown" or anything else + return "High" + } } -// cxBeforePrompt is called before a user prompt is sent to any agent. -// ev.Text holds the raw prompt text. -func cxBeforePrompt(ev agenthooks.PromptEvent) agenthooks.PromptVerdict { - lower := strings.ToLower(ev.Text) - for _, pattern := range blockedShellPatterns { - if strings.Contains(lower, strings.ToLower(pattern)) { - return agenthooks.RejectPrompt( - fmt.Sprintf("Blocked by Checkmarx policy: prompt contains dangerous pattern %q", pattern), - ) +// ============================================================================= +// License check +// ============================================================================= + +// isLicensed loads CLI config silently and checks whether the token carries +// a CxOne Assist, AI Protection, or Developer Assist license. +func isLicensed() bool { + _ = configuration.LoadConfiguration() + jwt := wrappers.NewJwtWrapper() + for _, engine := range []string{ + params.CheckmarxOneAssistType, + params.AIProtectionType, + params.CheckmarxDevAssistType, + } { + if ok, err := jwt.IsAllowedEngine(engine); err == nil && ok { + return true } } - // TODO: scan ev.Text for credentials / secret patterns - // TODO: enforce prompt policy (e.g. block PII, block internal data leakage) - return agenthooks.AcceptPrompt() + return false } -// setupCXHooks registers all four unified handlers. -// Called once per dispatch — cheap, idempotent (map overwrites same values). -func setupCXHooks() { +// ============================================================================= +// Hook registration +// ============================================================================= + +// registerGuardrails wires the four guardrail handlers. +func registerGuardrails() { agenthooks.WhenAgentIdle(cxWhenAgentIdle) agenthooks.BeforeToolCall(cxBeforeToolCall) agenthooks.AfterFileWrite(cxAfterFileWrite) agenthooks.BeforePrompt(cxBeforePrompt) } +// registerPassThrough wires no-op handlers that always allow the action. +// Used when the license check fails so we still emit valid JSON (fail-open). +func registerPassThrough() { + agenthooks.WhenAgentIdle(func(_ agenthooks.AgentIdleEvent) agenthooks.IdleVerdict { return agenthooks.Resume() }) + agenthooks.BeforeToolCall(func(_ agenthooks.ToolCallEvent) agenthooks.ToolVerdict { return agenthooks.Allow() }) + agenthooks.AfterFileWrite(func(_ agenthooks.FileWriteEvent) agenthooks.FileWriteVerdict { return agenthooks.AcceptWrite() }) + agenthooks.BeforePrompt(func(_ agenthooks.PromptEvent) agenthooks.PromptVerdict { return agenthooks.AcceptPrompt() }) +} + // ============================================================================= -// Cobra routing — 22 hidden commands, zero logic. -// -// These exist only because Cobra sits between os.Args and Dispatch(): -// os.Args = ["cx.exe", "hooks", "claude-pre-tool-use"] -// os.Args[1] = "hooks" ← Dispatch() would read this, not the route name +// Cobra routing — 22 hidden subcommands, one per agent×event combination. // -// Each command captures its own route name via cmd.Use and calls DispatchRoute, -// which looks up the route registered by setupCXHooks() and invokes it. +// Agents invoke: cx hooks +// Each route reads JSON from stdin and writes the verdict as JSON to stdout. // ============================================================================= -// HookDispatchCommands returns all 22 hidden route-dispatch subcommands. -// Registered under "cx hooks" — agents invoke: cx hooks func HookDispatchCommands() []*cobra.Command { type route struct{ use, short string } routes := []route{ - // ── WhenAgentIdle (5 routes) ───────────────────────────────────────── - {"claude-stop", "Claude agent finished responding"}, - {"cursor-stop", "Cursor agent finished responding"}, - {"windsurf-post-cascade-response", "Windsurf agent finished responding"}, - {"droid-stop", "Factory Droid agent finished responding"}, - {"gemini-after-agent", "Gemini agent finished responding"}, - - // ── BeforeToolCall (7 routes) ──────────────────────────────────────── - {"claude-pre-tool-use", "Gate Claude tool/shell execution"}, + // WhenAgentIdle + {"claude-stop", "Claude agent finished"}, + {"cursor-stop", "Cursor agent finished"}, + {"windsurf-post-cascade-response", "Windsurf agent finished"}, + {"droid-stop", "Factory Droid agent finished"}, + {"gemini-after-agent", "Gemini agent finished"}, + + // BeforeToolCall + {"claude-pre-tool-use", "Gate Claude tool execution"}, {"cursor-before-shell", "Gate Cursor shell execution"}, - {"cursor-before-mcp", "Gate Cursor MCP tool execution"}, + {"cursor-before-mcp", "Gate Cursor MCP execution"}, {"windsurf-pre-run-command", "Gate Windsurf shell execution"}, - {"windsurf-pre-mcp-tool-use", "Gate Windsurf MCP tool execution"}, - {"droid-pre-tool-use", "Gate Factory Droid tool execution"}, + {"windsurf-pre-mcp-tool-use", "Gate Windsurf MCP execution"}, + {"droid-pre-tool-use", "Gate Droid tool execution"}, {"gemini-before-tool", "Gate Gemini tool execution"}, - // ── AfterFileWrite (5 routes) ──────────────────────────────────────── - {"claude-after-file-write", "React after Claude writes a file"}, - {"cursor-after-file-edit", "React after Cursor edits a file"}, - {"windsurf-post-write-code", "React after Windsurf writes a file"}, - {"droid-after-file-write", "React after Factory Droid writes a file"}, - {"gemini-after-file-tool", "React after Gemini writes a file"}, - - // ── BeforePrompt (5 routes) ────────────────────────────────────────── - {"claude-user-prompt-submit", "Gate Claude prompt submission"}, - {"cursor-before-submit-prompt", "Gate Cursor prompt submission"}, - {"windsurf-pre-user-prompt", "Gate Windsurf prompt submission"}, - {"droid-user-prompt-submit", "Gate Factory Droid prompt submission"}, - {"gemini-before-agent", "Gate Gemini prompt submission"}, + // AfterFileWrite + {"claude-after-file-write", "React to Claude file write"}, + {"cursor-after-file-edit", "React to Cursor file edit"}, + {"windsurf-post-write-code", "React to Windsurf file write"}, + {"droid-after-file-write", "React to Droid file write"}, + {"gemini-after-file-tool", "React to Gemini file write"}, + + // BeforePrompt + {"claude-user-prompt-submit", "Gate Claude prompt"}, + {"cursor-before-submit-prompt", "Gate Cursor prompt"}, + {"windsurf-pre-user-prompt", "Gate Windsurf prompt"}, + {"droid-user-prompt-submit", "Gate Droid prompt"}, + {"gemini-before-agent", "Gate Gemini prompt"}, } cmds := make([]*cobra.Command, len(routes)) @@ -161,15 +180,19 @@ func HookDispatchCommands() []*cobra.Command { Use: r.use, Short: r.short, Hidden: true, - // Override root PersistentPreRunE — CX credential/config loading must - // never run during hook dispatch: any stdout output corrupts the JSON - // response the agent reads. - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - return nil - }, - Run: func(cmd *cobra.Command, args []string) { - setupCXHooks() // register the 4 unified handlers - agenthooks.DispatchRoute(cmd.Use) // route to the right one + // Override root PersistentPreRunE — any stdout from config loading + // would corrupt the JSON response the agent expects. + PersistentPreRunE: func(*cobra.Command, []string) error { return nil }, + Run: func(cmd *cobra.Command, _ []string) { + if isLicensed() { + registerGuardrails() + } else { + registerPassThrough() + } + // Cobra consumed the route name from os.Args, so Dispatch() + // would see "hooks" instead. Fix it before dispatching. + os.Args = []string{os.Args[0], cmd.Use} + agenthooks.Dispatch() }, } } @@ -177,10 +200,9 @@ func HookDispatchCommands() []*cobra.Command { } // ============================================================================= -// Management command — cx hooks agenthooks +// Management command — cx hooks agenthooks install // ============================================================================= -// NewAgentHooksCommand creates the visible "cx hooks agenthooks" management command. func NewAgentHooksCommand() *cobra.Command { cmd := &cobra.Command{ Use: "agenthooks", @@ -190,55 +212,47 @@ func NewAgentHooksCommand() *cobra.Command { $ cx hooks agenthooks install `), } - cmd.AddCommand(agentHooksInstallCommand()) + cmd.AddCommand(agentHooksInstallCmd()) return cmd } -// ============================================================================= -// install -// ============================================================================= - -func agentHooksInstallCommand() *cobra.Command { +func agentHooksInstallCmd() *cobra.Command { return &cobra.Command{ Use: "install", - Short: "Write hook configs for all agents pointing to this cx binary", + Short: "Write hook configs for all supported agents", Long: heredoc.Doc(` Patches the settings files for Claude Code, Cursor, Windsurf Cascade, - and Factory Droid to invoke "cx hooks " for each hook event. - cx itself handles all dispatch — no separate binary needed. + and Factory Droid so each agent invokes "cx hooks " on hook events. `), - Example: heredoc.Doc(` - $ cx hooks agenthooks install - `), - RunE: func(cmd *cobra.Command, args []string) error { - return runAgentHooksInstall() + Example: " $ cx hooks agenthooks install", + RunE: func(*cobra.Command, []string) error { + return runInstall() }, } } -func runAgentHooksInstall() error { +func runInstall() error { cxPath, err := os.Executable() if err != nil { - return errors.Wrapf(err, "resolving cx binary path") + return errors.Wrap(err, "resolving cx binary path") } - home, err := os.UserHomeDir() if err != nil { - return errors.Wrapf(err, "finding home directory") + return errors.Wrap(err, "finding home directory") } agents := []struct { - name string - fn func(string, string) error + name string + install func(home, cx string) error }{ - {"Claude Code", agentHooksInstallClaude}, - {"Cursor", agentHooksInstallCursor}, - {"Windsurf Cascade", agentHooksInstallWindsurf}, - {"Factory Droid", agentHooksInstallDroid}, + {"Claude Code", installClaude}, + {"Cursor", installCursor}, + {"Windsurf Cascade", installWindsurf}, + {"Factory Droid", installDroid}, } for _, a := range agents { - if err := a.fn(home, cxPath); err != nil { + if err := a.install(home, cxPath); err != nil { fmt.Fprintf(os.Stderr, "warning: %s: %v\n", a.name, err) } else { fmt.Fprintf(os.Stdout, "✓ %s configured\n", a.name) @@ -247,69 +261,73 @@ func runAgentHooksInstall() error { return nil } -func agentHooksInstallClaude(home, cxPath string) error { - return agentHooksPatchJSON(filepath.Join(home, ".claude", "settings.json"), func(m map[string]any) { - h := agentHooksEnsureMap(m, "hooks") - h["Stop"] = cxHookEntries(cxPath, "claude-stop") - h["PreToolUse"] = cxHookEntries(cxPath, "claude-pre-tool-use") - h["PostToolUse"] = cxHookEntries(cxPath, "claude-after-file-write") - h["UserPromptSubmit"] = cxHookEntries(cxPath, "claude-user-prompt-submit") +// ============================================================================= +// Per-agent install helpers +// ============================================================================= + +func installClaude(home, cx string) error { + return patchJSON(filepath.Join(home, ".claude", "settings.json"), func(m map[string]any) { + h := ensureMap(m, "hooks") + h["Stop"] = claudeHook(cx, "claude-stop") + h["PreToolUse"] = claudeHook(cx, "claude-pre-tool-use") + h["PostToolUse"] = claudeHook(cx, "claude-after-file-write") + h["UserPromptSubmit"] = claudeHook(cx, "claude-user-prompt-submit") }) } -func agentHooksInstallCursor(home, cxPath string) error { - return agentHooksPatchJSON(filepath.Join(home, ".cursor", "hooks.json"), func(m map[string]any) { - m["stop"] = cxCmd(cxPath, "cursor-stop") - m["beforeShellExecution"] = cxCmd(cxPath, "cursor-before-shell") - m["beforeMCPExecution"] = cxCmd(cxPath, "cursor-before-mcp") - m["afterFileEdit"] = cxCmd(cxPath, "cursor-after-file-edit") - m["beforeSubmitPrompt"] = cxCmd(cxPath, "cursor-before-submit-prompt") +func installCursor(home, cx string) error { + return patchJSON(filepath.Join(home, ".cursor", "hooks.json"), func(m map[string]any) { + m["stop"] = cursorHook(cx, "cursor-stop") + m["beforeShellExecution"] = cursorHook(cx, "cursor-before-shell") + m["beforeMCPExecution"] = cursorHook(cx, "cursor-before-mcp") + m["afterFileEdit"] = cursorHook(cx, "cursor-after-file-edit") + m["beforeSubmitPrompt"] = cursorHook(cx, "cursor-before-submit-prompt") }) } -func agentHooksInstallWindsurf(home, cxPath string) error { - return agentHooksPatchJSON(filepath.Join(home, ".codeium", "windsurf", "hooks.json"), func(m map[string]any) { - m["pre_run_command"] = cxCmd(cxPath, "windsurf-pre-run-command") - m["pre_mcp_tool_use"] = cxCmd(cxPath, "windsurf-pre-mcp-tool-use") - m["pre_user_prompt"] = cxCmd(cxPath, "windsurf-pre-user-prompt") - m["post_write_code"] = cxCmd(cxPath, "windsurf-post-write-code") - m["post_cascade_response"] = cxCmd(cxPath, "windsurf-post-cascade-response") +func installWindsurf(home, cx string) error { + return patchJSON(filepath.Join(home, ".codeium", "windsurf", "hooks.json"), func(m map[string]any) { + m["pre_run_command"] = cursorHook(cx, "windsurf-pre-run-command") + m["pre_mcp_tool_use"] = cursorHook(cx, "windsurf-pre-mcp-tool-use") + m["pre_user_prompt"] = cursorHook(cx, "windsurf-pre-user-prompt") + m["post_write_code"] = cursorHook(cx, "windsurf-post-write-code") + m["post_cascade_response"] = cursorHook(cx, "windsurf-post-cascade-response") }) } -func agentHooksInstallDroid(home, cxPath string) error { - return agentHooksPatchJSON(filepath.Join(home, ".factory", "settings.json"), func(m map[string]any) { - h := agentHooksEnsureMap(m, "hooks") - h["Stop"] = cxHookEntries(cxPath, "droid-stop") - h["PreToolUse"] = cxHookEntries(cxPath, "droid-pre-tool-use") - h["PostToolUse"] = cxHookEntries(cxPath, "droid-after-file-write") - h["UserPromptSubmit"] = cxHookEntries(cxPath, "droid-user-prompt-submit") +func installDroid(home, cx string) error { + return patchJSON(filepath.Join(home, ".factory", "settings.json"), func(m map[string]any) { + h := ensureMap(m, "hooks") + h["Stop"] = claudeHook(cx, "droid-stop") + h["PreToolUse"] = claudeHook(cx, "droid-pre-tool-use") + h["PostToolUse"] = claudeHook(cx, "droid-after-file-write") + h["UserPromptSubmit"] = claudeHook(cx, "droid-user-prompt-submit") }) } -// cxCommandString builds the shell command string pointing cx at a given route. -// Quotes the binary path if it contains spaces (e.g. C:\Program Files\cx.exe). -func cxCommandString(cxPath, route string) string { - p := cxPath - if strings.Contains(p, " ") { - p = `"` + p + `"` +// ============================================================================= +// JSON config helpers +// ============================================================================= + +// cmdString builds "cx hooks ", quoting the path if it has spaces. +func cmdString(cx, route string) string { + if strings.Contains(cx, " ") { + cx = `"` + cx + `"` } - return p + " hooks " + route + return cx + " hooks " + route } -// cxHookEntries builds a Claude/Droid-style hook entry: [{type, command}] -func cxHookEntries(cxPath, route string) []map[string]any { - return []map[string]any{ - {"type": "command", "command": cxCommandString(cxPath, route)}, - } +// claudeHook builds a Claude/Droid hook entry: [{type: "command", command: "..."}] +func claudeHook(cx, route string) []map[string]any { + return []map[string]any{{"type": "command", "command": cmdString(cx, route)}} } -// cxCmd builds a Cursor/Windsurf-style hook entry: {command} -func cxCmd(cxPath, route string) map[string]any { - return map[string]any{"command": cxCommandString(cxPath, route)} +// cursorHook builds a Cursor/Windsurf hook entry: {command: "..."} +func cursorHook(cx, route string) map[string]any { + return map[string]any{"command": cmdString(cx, route)} } -func agentHooksPatchJSON(path string, patch func(map[string]any)) error { +func patchJSON(path string, patch func(map[string]any)) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } @@ -325,7 +343,7 @@ func agentHooksPatchJSON(path string, patch func(map[string]any)) error { return os.WriteFile(path, append(data, '\n'), 0o644) } -func agentHooksEnsureMap(m map[string]any, key string) map[string]any { +func ensureMap(m map[string]any, key string) map[string]any { if v, ok := m[key]; ok { if sub, ok := v.(map[string]any); ok { return sub From 1e003cc711359f4ac9bb49ca0be143b03ff49a58 Mon Sep 17 00:00:00 2001 From: Hitesh Madgulkar <212497904+cx-hitesh-madgulkar@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:44:42 +0530 Subject: [PATCH 3/6] shellGuardRail --- internal/commands/agenthooks.go | 100 +++++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 2 deletions(-) diff --git a/internal/commands/agenthooks.go b/internal/commands/agenthooks.go index ac64bdb8a..e510211b3 100644 --- a/internal/commands/agenthooks.go +++ b/internal/commands/agenthooks.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" "github.com/MakeNowJust/heredoc" @@ -26,8 +27,103 @@ func cxWhenAgentIdle(_ agenthooks.AgentIdleEvent) agenthooks.IdleVerdict { return agenthooks.Resume() } -// cxBeforeToolCall: gate shell/MCP execution. Nothing to enforce yet. -func cxBeforeToolCall(_ agenthooks.ToolCallEvent) agenthooks.ToolVerdict { +// ============================================================================= +// Shell guardrail — blocks commands that match the organization's blocklist. +// ============================================================================= + +// shellPolicyPath returns the path to the policy file: ~/.checkmarx/policyhooks1.json +func shellPolicyPath() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".checkmarx", "policyhooks1.json") +} + +// shellPolicy is the minimal shape of the policy file we need. +type shellPolicy struct { + BlocklistTools struct { + Enabled bool `json:"enabled"` + Tools []blockedTool `json:"tools"` + } `json:"blocklist_tools"` +} + +// blockedTool is a single entry in the policy blocklist. +type blockedTool struct { + Name string `json:"name"` + OS []string `json:"os"` + Category string `json:"category"` + Risk string `json:"risk"` +} + +// denyMessage is the firm instruction appended to every denial. It tells the +// agent to stop — no retries, no workarounds, no alternative approaches. +const denyMessage = "\n\n" + + "IMPORTANT: This action was blocked by your organization's security policy. " + + "Do NOT attempt alternative commands, workarounds, or equivalent approaches to achieve the same result. " + + "Any alternative method to accomplish this blocked action may be equally harmful to the system and the developer. " + + "Instead, inform the user that this operation is restricted by organizational policy and suggest they contact their administrator if they need this action performed." + +// loadBlockedCommands reads the policy file and returns all command names +// (lowercased) that are blocked on the current OS, together with their metadata. +func loadBlockedCommands() map[string]blockedTool { + blocked := map[string]blockedTool{} + + data, err := os.ReadFile(shellPolicyPath()) + if err != nil { + return blocked // fail-open: missing policy should not block the developer + } + var policy shellPolicy + if err := json.Unmarshal(data, &policy); err != nil { + return blocked // fail-open + } + if !policy.BlocklistTools.Enabled { + return blocked + } + + currentOS := runtime.GOOS + for _, t := range policy.BlocklistTools.Tools { + if !matchesOS(t.OS, currentOS) { + continue + } + blocked[strings.ToLower(t.Name)] = t + } + return blocked +} + +// matchesOS returns true when any of the tool's OS labels match the current OS. +func matchesOS(toolOS []string, currentOS string) bool { + for _, o := range toolOS { + mapped := o + if o == "mac" { + mapped = "darwin" + } + if mapped == currentOS { + return true + } + } + return false +} + +// cxBeforeToolCall gates shell execution against the organization's blocklist. +// Detection is simple and strong: if any blocked command name appears anywhere +// in the full command string (case-insensitive), the command is denied. +func cxBeforeToolCall(ev agenthooks.ToolCallEvent) agenthooks.ToolVerdict { + if !ev.IsShell() { + return agenthooks.Allow() + } + + blocked := loadBlockedCommands() + cmdLower := strings.ToLower(ev.Command) + + for name, tool := range blocked { + if strings.Contains(cmdLower, name) { + return agenthooks.Deny(fmt.Sprintf( + "Blocked by Checkmarx: command %q is not allowed.\nCategory: %s\nReason: %s%s", + name, tool.Category, tool.Risk, denyMessage, + )) + } + } return agenthooks.Allow() } From 72b30fa214a1c4eb8e8fb60ba5fd6ed29abac949 Mon Sep 17 00:00:00 2001 From: Amol Mane <22643905+cx-amol-mane@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:59:43 +0530 Subject: [PATCH 4/6] Integrate MCP server functionality with security guardrails - Added new MCP command to start a Model Context Protocol server for AI assistant integration. - Implemented shell and prompt guard tools to check commands and scan for secrets, respectively. - Updated `agenthooks.go` to utilize the new guardrail functions. - Modified `go.mod` and `go.sum` to include new dependencies for MCP functionality. --- go.mod | 9 +- go.sum | 8 ++ internal/commands/agenthooks.go | 40 ++++--- internal/commands/mcp/mcp.go | 113 ++++++++++++++++++++ internal/commands/mcp/tools/handler.go | 31 ++++++ internal/commands/mcp/tools/prompt_guard.go | 52 +++++++++ internal/commands/mcp/tools/shell_guard.go | 52 +++++++++ internal/commands/root.go | 6 ++ 8 files changed, 292 insertions(+), 19 deletions(-) create mode 100644 internal/commands/mcp/mcp.go create mode 100644 internal/commands/mcp/tools/handler.go create mode 100644 internal/commands/mcp/tools/prompt_guard.go create mode 100644 internal/commands/mcp/tools/shell_guard.go diff --git a/go.mod b/go.mod index 74f5b39f3..5d4060d41 100644 --- a/go.mod +++ b/go.mod @@ -9,11 +9,11 @@ require ( github.com/Checkmarx/gen-ai-wrapper v1.0.3 github.com/Checkmarx/manifest-parser v0.1.2 github.com/Checkmarx/secret-detection v1.2.1 + github.com/CheckmarxDev/ast-cx-hooks v1.0.0 github.com/MakeNowJust/heredoc v1.0.0 github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 github.com/bouk/monkey v1.0.0 github.com/checkmarx/2ms/v3 v3.21.0 - github.com/cx-amol-mane/hooks v0.0.0 github.com/gofrs/flock v0.12.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/gomarkdown/markdown v0.0.0-20241102151059-6bc1ffdc6e8c @@ -21,6 +21,7 @@ require ( github.com/gookit/color v1.5.4 github.com/jcmturner/gokrb5/v8 v8.4.4 github.com/jsumners/go-getport v1.0.0 + github.com/mark3labs/mcp-go v0.46.0 github.com/mssola/user_agent v0.6.0 github.com/pkg/errors v0.9.1 github.com/spf13/cobra v1.10.1 @@ -41,8 +42,10 @@ require ( cyphar.com/go-pathrs v0.2.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/coreos/go-systemd/v22 v22.6.0 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/onsi/ginkgo/v2 v2.25.1 // indirect github.com/onsi/gomega v1.38.1 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect ) @@ -238,7 +241,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/rs/zerolog v1.34.0 // indirect + github.com/rs/zerolog v1.34.0 github.com/rubenv/sql-migrate v1.8.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/rust-secure-code/go-rustaudit v0.0.0-20250226111315-e20ec32e963c // indirect @@ -330,8 +333,6 @@ require ( sigs.k8s.io/yaml v1.6.0 // indirect ) -replace github.com/cx-amol-mane/hooks => ../hooks - replace github.com/containerd/containerd => github.com/containerd/containerd v1.7.29 replace github.com/opencontainers/selinux => github.com/opencontainers/selinux v1.13.0 diff --git a/go.sum b/go.sum index e5cea4297..ac96a7b57 100644 --- a/go.sum +++ b/go.sum @@ -81,6 +81,8 @@ github.com/Checkmarx/manifest-parser v0.1.2 h1:Sh2xkpeOWKu56Y7wo+ljckNGHAQX1uITE github.com/Checkmarx/manifest-parser v0.1.2/go.mod h1:hh5FX5FdDieU8CKQEkged4hfOaSylpJzub8PRFXa4kA= github.com/Checkmarx/secret-detection v1.2.1 h1:Hzpz74dcN/L14Q86ARvPOZpKBnERzGTpy6sl1RXKOTo= github.com/Checkmarx/secret-detection v1.2.1/go.mod h1:kbXbtIQisDdB/TNuV7r9HPclEznUyBHLQ5yr7IX7vBQ= +github.com/CheckmarxDev/ast-cx-hooks v1.0.0 h1:nkeTOZtpO8No3XbKgyb85Mq3FQEoqjttnjOztPFtlgA= +github.com/CheckmarxDev/ast-cx-hooks v1.0.0/go.mod h1:XY4JTAhmgRPFbXyTr/G0kNFkG4oil4DaAUT4IPFDSg4= github.com/CycloneDX/cyclonedx-go v0.9.2 h1:688QHn2X/5nRezKe2ueIVCt+NRqf7fl3AVQk+vaFcIo= github.com/CycloneDX/cyclonedx-go v0.9.2/go.mod h1:vcK6pKgO1WanCdd61qx4bFnSsDJQ6SbM2ZuMIgq86Jg= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= @@ -506,6 +508,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/go-containerregistry v0.20.7 h1:24VGNpS0IwrOZ2ms2P1QE3Xa5X9p4phx0aUgzYzHW6I= github.com/google/go-containerregistry v0.20.7/go.mod h1:Lx5LCZQjLH1QBaMPeGwsME9biPeo1lPx6lbGj/UmzgM= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/licensecheck v0.3.1 h1:QoxgoDkaeC4nFrtGN1jV7IPmDCHFNIVh54e5hSt6sPs= github.com/google/licensecheck v0.3.1/go.mod h1:ORkR35t/JjW+emNKtfJDII0zlciG9JgbT7SmsohlHmY= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= @@ -695,6 +699,8 @@ github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3v github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mark3labs/mcp-go v0.46.0 h1:8KRibF4wcKejbLsHxCA/QBVUr5fQ9nwz/n8lGqmaALo= +github.com/mark3labs/mcp-go v0.46.0/go.mod h1:JKTC7R2LLVagkEWK7Kwu7DbmA6iIvnNAod6yrHiQMag= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= @@ -1015,6 +1021,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/internal/commands/agenthooks.go b/internal/commands/agenthooks.go index e510211b3..842292706 100644 --- a/internal/commands/agenthooks.go +++ b/internal/commands/agenthooks.go @@ -13,7 +13,7 @@ import ( "github.com/checkmarx/ast-cli/internal/params" "github.com/checkmarx/ast-cli/internal/wrappers" "github.com/checkmarx/ast-cli/internal/wrappers/configuration" - agenthooks "github.com/cx-amol-mane/hooks" + agenthooks "github.com/CheckmarxDev/ast-cx-hooks" "github.com/pkg/errors" "github.com/spf13/cobra" ) @@ -105,25 +105,34 @@ func matchesOS(toolOS []string, currentOS string) bool { return false } -// cxBeforeToolCall gates shell execution against the organization's blocklist. -// Detection is simple and strong: if any blocked command name appears anywhere -// in the full command string (case-insensitive), the command is denied. -func cxBeforeToolCall(ev agenthooks.ToolCallEvent) agenthooks.ToolVerdict { - if !ev.IsShell() { - return agenthooks.Allow() - } - +// CheckShellCommand checks a shell command against the organization's blocklist. +// Returns (true, reason) if the command is blocked, (false, "") if allowed. +// This is the core matching logic shared by the agent hook guardrail and the MCP tool. +func CheckShellCommand(command string) (bool, string) { blocked := loadBlockedCommands() - cmdLower := strings.ToLower(ev.Command) + cmdLower := strings.ToLower(command) for name, tool := range blocked { if strings.Contains(cmdLower, name) { - return agenthooks.Deny(fmt.Sprintf( + return true, fmt.Sprintf( "Blocked by Checkmarx: command %q is not allowed.\nCategory: %s\nReason: %s%s", name, tool.Category, tool.Risk, denyMessage, - )) + ) } } + return false, "" +} + +// cxBeforeToolCall gates shell execution against the organization's blocklist. +// Detection is simple and strong: if any blocked command name appears anywhere +// in the full command string (case-insensitive), the command is denied. +func cxBeforeToolCall(ev agenthooks.ToolCallEvent) agenthooks.ToolVerdict { + if !ev.IsShell() { + return agenthooks.Allow() + } + if blocked, reason := CheckShellCommand(ev.Command); blocked { + return agenthooks.Deny(reason) + } return agenthooks.Allow() } @@ -135,7 +144,7 @@ func cxAfterFileWrite(_ agenthooks.FileWriteEvent) agenthooks.FileWriteVerdict { // cxBeforePrompt scans the user's prompt for leaked secrets using the 2ms // engine before it reaches the AI agent. This is the prompt guardrail. func cxBeforePrompt(ev agenthooks.PromptEvent) agenthooks.PromptVerdict { - if reason := scanForSecrets(ev.Text); reason != "" { + if reason := ScanForSecrets(ev.Text); reason != "" { return agenthooks.RejectPrompt(reason) } return agenthooks.AcceptPrompt() @@ -145,9 +154,10 @@ func cxBeforePrompt(ev agenthooks.PromptEvent) agenthooks.PromptVerdict { // Secret scanning — powered by the same 2ms engine used in cx realtime scan. // ============================================================================= -// scanForSecrets runs the 2ms secret scanner on arbitrary text (e.g. a prompt). +// ScanForSecrets runs the 2ms secret scanner on arbitrary text (e.g. a prompt). // Returns a human-readable rejection reason, or "" when the text is clean. -func scanForSecrets(text string) string { +// Exported for reuse by the MCP server tool. +func ScanForSecrets(text string) string { content := text report, err := scanner.NewScanner().Scan( []scanner.ScanItem{{Content: &content, Source: "prompt"}}, diff --git a/internal/commands/mcp/mcp.go b/internal/commands/mcp/mcp.go new file mode 100644 index 000000000..2b473cf89 --- /dev/null +++ b/internal/commands/mcp/mcp.go @@ -0,0 +1,113 @@ +package mcp + +import ( + "context" + "os" + "os/signal" + "syscall" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/rs/zerolog" + "github.com/spf13/cobra" + + "github.com/checkmarx/ast-cli/internal/commands/mcp/tools" + "github.com/checkmarx/ast-cli/internal/params" +) + +// NewMCPCommand creates the "cx mcp" cobra command. +// +// The guardrail functions are injected from the commands package where the +// existing implementations live — no duplication, no circular imports. +// +// - shellGuard: checks a shell command against the org blocklist. +// Returns (true, reason) if blocked, (false, "") if allowed. +// - promptGuard: scans text for leaked secrets via 2ms. +// Returns a reason string if secrets found, "" if clean. +func NewMCPCommand( + shellGuard func(command string) (blocked bool, reason string), + promptGuard func(text string) (reason string), +) *cobra.Command { + return &cobra.Command{ + Use: "mcp", + Short: "Start MCP server for AI assistant integration", + Long: `Start a Model Context Protocol (MCP) server that exposes Checkmarx +security guardrails as tools for AI coding assistants. + +Tools: + cx_shell_guard — Check shell commands against the organization's blocklist + cx_prompt_guard — Scan prompts for leaked secrets before they reach the AI + +Transport: stdio (compatible with Claude Desktop, Cursor, VS Code Copilot, Windsurf)`, + Example: ` # Start MCP server + cx mcp + + # Claude Desktop config (claude_desktop_config.json): + { + "mcpServers": { + "checkmarx": { "command": "cx", "args": ["mcp"] } + } + }`, + RunE: func(_ *cobra.Command, _ []string) error { + return run(shellGuard, promptGuard) + }, + } +} + +func run( + shellGuard func(string) (bool, string), + promptGuard func(string) string, +) error { + logger := zerolog.New(os.Stderr).With().Timestamp().Str("component", "mcp").Logger() + + s := server.NewMCPServer( + "Checkmarx Security", + params.Version, + server.WithToolCapabilities(true), + server.WithLogging(), + server.WithHooks(newHooks(logger)), + ) + + shellTool := tools.NewShellGuardTool(shellGuard, logger) + s.AddTool(tools.ShellGuardDef(), shellTool.Handle) + + promptTool := tools.NewPromptGuardTool(promptGuard, logger) + s.AddTool(tools.PromptGuardDef(), promptTool.Handle) + + logger.Info(). + Str("version", params.Version). + Str("transport", "stdio"). + Int("tools", 2). + Msg("starting MCP server") + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT) + go func() { + <-sigChan + logger.Info().Msg("shutting down") + os.Exit(0) + }() + + return server.ServeStdio(s) +} + +func newHooks(logger zerolog.Logger) *server.Hooks { + h := &server.Hooks{} + + h.AddAfterInitialize(func(_ context.Context, _ any, msg *mcplib.InitializeRequest, _ *mcplib.InitializeResult) { + logger.Info(). + Str("client", msg.Params.ClientInfo.Name). + Str("version", msg.Params.ClientInfo.Version). + Msg("client connected") + }) + + h.AddOnError(func(_ context.Context, _ any, method mcplib.MCPMethod, _ any, err error) { + logger.Error().Err(err).Str("method", string(method)).Msg("error") + }) + + h.AddAfterCallTool(func(_ context.Context, _ any, msg *mcplib.CallToolRequest, _ any) { + logger.Info().Str("tool", msg.Params.Name).Msg("tool called") + }) + + return h +} diff --git a/internal/commands/mcp/tools/handler.go b/internal/commands/mcp/tools/handler.go new file mode 100644 index 000000000..22992b772 --- /dev/null +++ b/internal/commands/mcp/tools/handler.go @@ -0,0 +1,31 @@ +package tools + +import ( + "encoding/json" + "fmt" + + mcplib "github.com/mark3labs/mcp-go/mcp" +) + +// textResult serializes any value to a JSON MCP text response. +func textResult(data interface{}) (*mcplib.CallToolResult, error) { + b, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("marshal result: %w", err) + } + return &mcplib.CallToolResult{ + Content: []mcplib.Content{ + mcplib.TextContent{Type: "text", Text: string(b)}, + }, + }, nil +} + +// errorResult returns an MCP error response with isError=true. +func errorResult(msg string) (*mcplib.CallToolResult, error) { + return &mcplib.CallToolResult{ + IsError: true, + Content: []mcplib.Content{ + mcplib.TextContent{Type: "text", Text: msg}, + }, + }, nil +} diff --git a/internal/commands/mcp/tools/prompt_guard.go b/internal/commands/mcp/tools/prompt_guard.go new file mode 100644 index 000000000..bbc8e7fb4 --- /dev/null +++ b/internal/commands/mcp/tools/prompt_guard.go @@ -0,0 +1,52 @@ +package tools + +import ( + "context" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/rs/zerolog" +) + +// PromptGuardTool wraps the existing prompt guardrail as an MCP tool. +type PromptGuardTool struct { + guard func(string) string + logger zerolog.Logger +} + +func NewPromptGuardTool(guard func(string) string, logger zerolog.Logger) *PromptGuardTool { + return &PromptGuardTool{guard: guard, logger: logger} +} + +func (t *PromptGuardTool) Handle(_ context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + text, _ := req.GetArguments()["text"].(string) + if text == "" { + return errorResult("text is required") + } + + t.logger.Info().Int("length", len(text)).Msg("cx_prompt_guard invoked") + + reason := t.guard(text) + + result := map[string]interface{}{ + "clean": reason == "", + } + if reason != "" { + result["blocked"] = true + result["reason"] = reason + } + + return textResult(result) +} + +// PromptGuardDef defines the MCP tool schema for cx_prompt_guard. +func PromptGuardDef() mcplib.Tool { + return mcplib.NewTool( + "cx_prompt_guard", + mcplib.WithDescription( + "Scan text for leaked secrets (API keys, tokens, passwords) using the "+ + "Checkmarx 2ms secret detection engine. Returns whether the text is clean "+ + "or contains secrets that should be removed before sending to an AI agent.", + ), + mcplib.WithString("text", mcplib.Required(), mcplib.Description("The text to scan for secrets")), + ) +} diff --git a/internal/commands/mcp/tools/shell_guard.go b/internal/commands/mcp/tools/shell_guard.go new file mode 100644 index 000000000..d86f9bb2f --- /dev/null +++ b/internal/commands/mcp/tools/shell_guard.go @@ -0,0 +1,52 @@ +package tools + +import ( + "context" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/rs/zerolog" +) + +// ShellGuardTool wraps the existing shell guardrail as an MCP tool. +type ShellGuardTool struct { + guard func(string) (bool, string) + logger zerolog.Logger +} + +func NewShellGuardTool(guard func(string) (bool, string), logger zerolog.Logger) *ShellGuardTool { + return &ShellGuardTool{guard: guard, logger: logger} +} + +func (t *ShellGuardTool) Handle(_ context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + command, _ := req.GetArguments()["command"].(string) + if command == "" { + return errorResult("command is required") + } + + t.logger.Info().Str("command", command).Msg("cx_shell_guard invoked") + + blocked, reason := t.guard(command) + + result := map[string]interface{}{ + "command": command, + "allowed": !blocked, + } + if blocked { + result["reason"] = reason + } + + return textResult(result) +} + +// ShellGuardDef defines the MCP tool schema for cx_shell_guard. +func ShellGuardDef() mcplib.Tool { + return mcplib.NewTool( + "cx_shell_guard", + mcplib.WithDescription( + "Check a shell command against the organization's security blocklist. "+ + "Returns whether the command is allowed or blocked with a reason. "+ + "Use before executing any shell command to enforce security policy.", + ), + mcplib.WithString("command", mcplib.Required(), mcplib.Description("The shell command to check")), + ) +} diff --git a/internal/commands/root.go b/internal/commands/root.go index 6f3503036..3b972cd35 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -11,6 +11,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/checkmarx/ast-cli/internal/commands/dast" + mcpcmd "github.com/checkmarx/ast-cli/internal/commands/mcp" "github.com/checkmarx/ast-cli/internal/commands/util" "github.com/checkmarx/ast-cli/internal/commands/util/printer" "github.com/checkmarx/ast-cli/internal/logger" @@ -248,6 +249,10 @@ func NewAstCLI( chatCmd := NewChatCommand(chatWrapper, tenantWrapper) hooksCmd := NewHooksCommand(jwtWrapper, featureFlagsWrapper) telemetryCmd := NewTelemetryCommand(telemetryWrapper) + + // MCP server — directly uses the exported guardrail functions from agenthooks.go. + mcpServerCmd := mcpcmd.NewMCPCommand(CheckShellCommand, ScanForSecrets) + rootCmd.AddCommand( scanCmd, projectCmd, @@ -261,6 +266,7 @@ func NewAstCLI( chatCmd, hooksCmd, telemetryCmd, + mcpServerCmd, ) rootCmd.SilenceUsage = true From cef4083b3f3f2cdcb197601b4253153007170c3f Mon Sep 17 00:00:00 2001 From: Amol Mane <22643905+cx-amol-mane@users.noreply.github.com> Date: Fri, 27 Mar 2026 20:04:11 +0530 Subject: [PATCH 5/6] Refactor agenthooks.go to focus on Cursor agent hooks - Updated comments and command descriptions to reflect that the hook functionality is specific to the Cursor agent. - Removed unused agent installation functions for Claude, Windsurf, and Factory Droid, streamlining the installation process to only configure Cursor hooks. - Adjusted the hook dispatch commands to exclusively handle Cursor events, enhancing clarity and maintainability. --- internal/commands/agenthooks.go | 102 ++++---------------------------- 1 file changed, 13 insertions(+), 89 deletions(-) diff --git a/internal/commands/agenthooks.go b/internal/commands/agenthooks.go index 842292706..cf3f697aa 100644 --- a/internal/commands/agenthooks.go +++ b/internal/commands/agenthooks.go @@ -19,7 +19,7 @@ import ( ) // ============================================================================= -// Guardrail handlers — written once, applied to all 5 AI agents. +// Guardrail handlers — applied to Cursor agent hooks. // ============================================================================= // cxWhenAgentIdle: agent finished its turn. Nothing to enforce yet. @@ -238,9 +238,9 @@ func registerPassThrough() { } // ============================================================================= -// Cobra routing — 22 hidden subcommands, one per agent×event combination. +// Cobra routing — hidden subcommands for Cursor hook events. // -// Agents invoke: cx hooks +// Cursor invokes: cx hooks // Each route reads JSON from stdin and writes the verdict as JSON to stdout. // ============================================================================= @@ -249,34 +249,17 @@ func HookDispatchCommands() []*cobra.Command { routes := []route{ // WhenAgentIdle - {"claude-stop", "Claude agent finished"}, {"cursor-stop", "Cursor agent finished"}, - {"windsurf-post-cascade-response", "Windsurf agent finished"}, - {"droid-stop", "Factory Droid agent finished"}, - {"gemini-after-agent", "Gemini agent finished"}, // BeforeToolCall - {"claude-pre-tool-use", "Gate Claude tool execution"}, {"cursor-before-shell", "Gate Cursor shell execution"}, {"cursor-before-mcp", "Gate Cursor MCP execution"}, - {"windsurf-pre-run-command", "Gate Windsurf shell execution"}, - {"windsurf-pre-mcp-tool-use", "Gate Windsurf MCP execution"}, - {"droid-pre-tool-use", "Gate Droid tool execution"}, - {"gemini-before-tool", "Gate Gemini tool execution"}, // AfterFileWrite - {"claude-after-file-write", "React to Claude file write"}, {"cursor-after-file-edit", "React to Cursor file edit"}, - {"windsurf-post-write-code", "React to Windsurf file write"}, - {"droid-after-file-write", "React to Droid file write"}, - {"gemini-after-file-tool", "React to Gemini file write"}, // BeforePrompt - {"claude-user-prompt-submit", "Gate Claude prompt"}, {"cursor-before-submit-prompt", "Gate Cursor prompt"}, - {"windsurf-pre-user-prompt", "Gate Windsurf prompt"}, - {"droid-user-prompt-submit", "Gate Droid prompt"}, - {"gemini-before-agent", "Gate Gemini prompt"}, } cmds := make([]*cobra.Command, len(routes)) @@ -312,8 +295,8 @@ func HookDispatchCommands() []*cobra.Command { func NewAgentHooksCommand() *cobra.Command { cmd := &cobra.Command{ Use: "agenthooks", - Short: "Manage AI coding agent hook configuration", - Long: "Configure AI coding agent hooks to invoke cx directly. No separate binary needed.", + Short: "Manage Cursor hook configuration", + Long: "Configure Cursor hooks to invoke cx directly. No separate binary needed.", Example: heredoc.Doc(` $ cx hooks agenthooks install `), @@ -325,10 +308,10 @@ func NewAgentHooksCommand() *cobra.Command { func agentHooksInstallCmd() *cobra.Command { return &cobra.Command{ Use: "install", - Short: "Write hook configs for all supported agents", + Short: "Write hook config for Cursor", Long: heredoc.Doc(` - Patches the settings files for Claude Code, Cursor, Windsurf Cascade, - and Factory Droid so each agent invokes "cx hooks " on hook events. + Patches ~/.cursor/hooks.json so Cursor invokes + "cx hooks " on hook events. `), Example: " $ cx hooks agenthooks install", RunE: func(*cobra.Command, []string) error { @@ -347,40 +330,17 @@ func runInstall() error { return errors.Wrap(err, "finding home directory") } - agents := []struct { - name string - install func(home, cx string) error - }{ - {"Claude Code", installClaude}, - {"Cursor", installCursor}, - {"Windsurf Cascade", installWindsurf}, - {"Factory Droid", installDroid}, - } - - for _, a := range agents { - if err := a.install(home, cxPath); err != nil { - fmt.Fprintf(os.Stderr, "warning: %s: %v\n", a.name, err) - } else { - fmt.Fprintf(os.Stdout, "✓ %s configured\n", a.name) - } + if err := installCursor(home, cxPath); err != nil { + return fmt.Errorf("Cursor: %w", err) } + fmt.Fprintf(os.Stdout, "✓ Cursor configured\n") return nil } // ============================================================================= -// Per-agent install helpers +// Cursor install helper // ============================================================================= -func installClaude(home, cx string) error { - return patchJSON(filepath.Join(home, ".claude", "settings.json"), func(m map[string]any) { - h := ensureMap(m, "hooks") - h["Stop"] = claudeHook(cx, "claude-stop") - h["PreToolUse"] = claudeHook(cx, "claude-pre-tool-use") - h["PostToolUse"] = claudeHook(cx, "claude-after-file-write") - h["UserPromptSubmit"] = claudeHook(cx, "claude-user-prompt-submit") - }) -} - func installCursor(home, cx string) error { return patchJSON(filepath.Join(home, ".cursor", "hooks.json"), func(m map[string]any) { m["stop"] = cursorHook(cx, "cursor-stop") @@ -391,26 +351,6 @@ func installCursor(home, cx string) error { }) } -func installWindsurf(home, cx string) error { - return patchJSON(filepath.Join(home, ".codeium", "windsurf", "hooks.json"), func(m map[string]any) { - m["pre_run_command"] = cursorHook(cx, "windsurf-pre-run-command") - m["pre_mcp_tool_use"] = cursorHook(cx, "windsurf-pre-mcp-tool-use") - m["pre_user_prompt"] = cursorHook(cx, "windsurf-pre-user-prompt") - m["post_write_code"] = cursorHook(cx, "windsurf-post-write-code") - m["post_cascade_response"] = cursorHook(cx, "windsurf-post-cascade-response") - }) -} - -func installDroid(home, cx string) error { - return patchJSON(filepath.Join(home, ".factory", "settings.json"), func(m map[string]any) { - h := ensureMap(m, "hooks") - h["Stop"] = claudeHook(cx, "droid-stop") - h["PreToolUse"] = claudeHook(cx, "droid-pre-tool-use") - h["PostToolUse"] = claudeHook(cx, "droid-after-file-write") - h["UserPromptSubmit"] = claudeHook(cx, "droid-user-prompt-submit") - }) -} - // ============================================================================= // JSON config helpers // ============================================================================= @@ -423,12 +363,7 @@ func cmdString(cx, route string) string { return cx + " hooks " + route } -// claudeHook builds a Claude/Droid hook entry: [{type: "command", command: "..."}] -func claudeHook(cx, route string) []map[string]any { - return []map[string]any{{"type": "command", "command": cmdString(cx, route)}} -} - -// cursorHook builds a Cursor/Windsurf hook entry: {command: "..."} +// cursorHook builds a Cursor hook entry: {command: "..."} func cursorHook(cx, route string) map[string]any { return map[string]any{"command": cmdString(cx, route)} } @@ -448,14 +383,3 @@ func patchJSON(path string, patch func(map[string]any)) error { } return os.WriteFile(path, append(data, '\n'), 0o644) } - -func ensureMap(m map[string]any, key string) map[string]any { - if v, ok := m[key]; ok { - if sub, ok := v.(map[string]any); ok { - return sub - } - } - sub := map[string]any{} - m[key] = sub - return sub -} From 19443e472181e97c0e338429d75d30a415864b3f Mon Sep 17 00:00:00 2001 From: cx-kedar-bhujade <206036177+cx-kedar-bhujade@users.noreply.github.com> Date: Wed, 22 Apr 2026 18:29:09 +0530 Subject: [PATCH 6/6] Refactor agent hooks to delegate to ast-cx-hooks library and expand multi-agent support Moves guardrail logic (shell guard, secret scanning, MCP server) out of ast-cli into the shared ast-cx-hooks library, and expands hook support from Cursor-only to Claude Code, Cursor, Windsurf, Factory Droid, and Gemini CLI. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- go.mod | 24 +- internal/commands/agenthooks.go | 369 ++++++-------------- internal/commands/mcp/mcp.go | 113 ------ internal/commands/mcp/tools/handler.go | 31 -- internal/commands/mcp/tools/prompt_guard.go | 52 --- internal/commands/mcp/tools/shell_guard.go | 52 --- internal/commands/root.go | 4 +- 7 files changed, 115 insertions(+), 530 deletions(-) delete mode 100644 internal/commands/mcp/mcp.go delete mode 100644 internal/commands/mcp/tools/handler.go delete mode 100644 internal/commands/mcp/tools/prompt_guard.go delete mode 100644 internal/commands/mcp/tools/shell_guard.go diff --git a/go.mod b/go.mod index 5d4060d41..c82e2b79a 100644 --- a/go.mod +++ b/go.mod @@ -15,13 +15,12 @@ require ( github.com/bouk/monkey v1.0.0 github.com/checkmarx/2ms/v3 v3.21.0 github.com/gofrs/flock v0.12.1 - github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/gomarkdown/markdown v0.0.0-20241102151059-6bc1ffdc6e8c github.com/google/uuid v1.6.0 github.com/gookit/color v1.5.4 github.com/jcmturner/gokrb5/v8 v8.4.4 github.com/jsumners/go-getport v1.0.0 - github.com/mark3labs/mcp-go v0.46.0 github.com/mssola/user_agent v0.6.0 github.com/pkg/errors v0.9.1 github.com/spf13/cobra v1.10.1 @@ -29,9 +28,9 @@ require ( github.com/stretchr/testify v1.11.1 github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 github.com/xeipuuv/gojsonschema v1.2.0 - golang.org/x/crypto v0.46.0 + golang.org/x/crypto v0.48.0 golang.org/x/sync v0.19.0 - golang.org/x/text v0.32.0 + golang.org/x/text v0.34.0 google.golang.org/grpc v1.79.3 google.golang.org/protobuf v1.36.10 gopkg.in/yaml.v3 v3.0.1 @@ -43,8 +42,11 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/coreos/go-systemd/v22 v22.6.0 // indirect github.com/google/jsonschema-go v0.4.2 // indirect + github.com/modelcontextprotocol/go-sdk v1.5.0 // indirect github.com/onsi/ginkgo/v2 v2.25.1 // indirect github.com/onsi/gomega v1.38.1 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect @@ -241,7 +243,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/rs/zerolog v1.34.0 + github.com/rs/zerolog v1.34.0 // indirect github.com/rubenv/sql-migrate v1.8.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/rust-secure-code/go-rustaudit v0.0.0-20250226111315-e20ec32e963c // indirect @@ -295,13 +297,13 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect - golang.org/x/mod v0.30.0 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/term v0.38.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.39.0 // indirect + golang.org/x/tools v0.42.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/genproto v0.0.0-20250324211829-b45e905df463 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect diff --git a/internal/commands/agenthooks.go b/internal/commands/agenthooks.go index cf3f697aa..7b921af92 100644 --- a/internal/commands/agenthooks.go +++ b/internal/commands/agenthooks.go @@ -1,200 +1,19 @@ package commands import ( - "encoding/json" "fmt" "os" - "path/filepath" - "runtime" - "strings" + agenthooks "github.com/CheckmarxDev/ast-cx-hooks" + cxhooks "github.com/CheckmarxDev/ast-cx-hooks/cx" "github.com/MakeNowJust/heredoc" - scanner "github.com/checkmarx/2ms/v3/pkg" "github.com/checkmarx/ast-cli/internal/params" "github.com/checkmarx/ast-cli/internal/wrappers" "github.com/checkmarx/ast-cli/internal/wrappers/configuration" - agenthooks "github.com/CheckmarxDev/ast-cx-hooks" "github.com/pkg/errors" "github.com/spf13/cobra" ) -// ============================================================================= -// Guardrail handlers — applied to Cursor agent hooks. -// ============================================================================= - -// cxWhenAgentIdle: agent finished its turn. Nothing to enforce yet. -func cxWhenAgentIdle(_ agenthooks.AgentIdleEvent) agenthooks.IdleVerdict { - return agenthooks.Resume() -} - -// ============================================================================= -// Shell guardrail — blocks commands that match the organization's blocklist. -// ============================================================================= - -// shellPolicyPath returns the path to the policy file: ~/.checkmarx/policyhooks1.json -func shellPolicyPath() string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - return filepath.Join(home, ".checkmarx", "policyhooks1.json") -} - -// shellPolicy is the minimal shape of the policy file we need. -type shellPolicy struct { - BlocklistTools struct { - Enabled bool `json:"enabled"` - Tools []blockedTool `json:"tools"` - } `json:"blocklist_tools"` -} - -// blockedTool is a single entry in the policy blocklist. -type blockedTool struct { - Name string `json:"name"` - OS []string `json:"os"` - Category string `json:"category"` - Risk string `json:"risk"` -} - -// denyMessage is the firm instruction appended to every denial. It tells the -// agent to stop — no retries, no workarounds, no alternative approaches. -const denyMessage = "\n\n" + - "IMPORTANT: This action was blocked by your organization's security policy. " + - "Do NOT attempt alternative commands, workarounds, or equivalent approaches to achieve the same result. " + - "Any alternative method to accomplish this blocked action may be equally harmful to the system and the developer. " + - "Instead, inform the user that this operation is restricted by organizational policy and suggest they contact their administrator if they need this action performed." - -// loadBlockedCommands reads the policy file and returns all command names -// (lowercased) that are blocked on the current OS, together with their metadata. -func loadBlockedCommands() map[string]blockedTool { - blocked := map[string]blockedTool{} - - data, err := os.ReadFile(shellPolicyPath()) - if err != nil { - return blocked // fail-open: missing policy should not block the developer - } - var policy shellPolicy - if err := json.Unmarshal(data, &policy); err != nil { - return blocked // fail-open - } - if !policy.BlocklistTools.Enabled { - return blocked - } - - currentOS := runtime.GOOS - for _, t := range policy.BlocklistTools.Tools { - if !matchesOS(t.OS, currentOS) { - continue - } - blocked[strings.ToLower(t.Name)] = t - } - return blocked -} - -// matchesOS returns true when any of the tool's OS labels match the current OS. -func matchesOS(toolOS []string, currentOS string) bool { - for _, o := range toolOS { - mapped := o - if o == "mac" { - mapped = "darwin" - } - if mapped == currentOS { - return true - } - } - return false -} - -// CheckShellCommand checks a shell command against the organization's blocklist. -// Returns (true, reason) if the command is blocked, (false, "") if allowed. -// This is the core matching logic shared by the agent hook guardrail and the MCP tool. -func CheckShellCommand(command string) (bool, string) { - blocked := loadBlockedCommands() - cmdLower := strings.ToLower(command) - - for name, tool := range blocked { - if strings.Contains(cmdLower, name) { - return true, fmt.Sprintf( - "Blocked by Checkmarx: command %q is not allowed.\nCategory: %s\nReason: %s%s", - name, tool.Category, tool.Risk, denyMessage, - ) - } - } - return false, "" -} - -// cxBeforeToolCall gates shell execution against the organization's blocklist. -// Detection is simple and strong: if any blocked command name appears anywhere -// in the full command string (case-insensitive), the command is denied. -func cxBeforeToolCall(ev agenthooks.ToolCallEvent) agenthooks.ToolVerdict { - if !ev.IsShell() { - return agenthooks.Allow() - } - if blocked, reason := CheckShellCommand(ev.Command); blocked { - return agenthooks.Deny(reason) - } - return agenthooks.Allow() -} - -// cxAfterFileWrite: react to file edits. Nothing to enforce yet. -func cxAfterFileWrite(_ agenthooks.FileWriteEvent) agenthooks.FileWriteVerdict { - return agenthooks.AcceptWrite() -} - -// cxBeforePrompt scans the user's prompt for leaked secrets using the 2ms -// engine before it reaches the AI agent. This is the prompt guardrail. -func cxBeforePrompt(ev agenthooks.PromptEvent) agenthooks.PromptVerdict { - if reason := ScanForSecrets(ev.Text); reason != "" { - return agenthooks.RejectPrompt(reason) - } - return agenthooks.AcceptPrompt() -} - -// ============================================================================= -// Secret scanning — powered by the same 2ms engine used in cx realtime scan. -// ============================================================================= - -// ScanForSecrets runs the 2ms secret scanner on arbitrary text (e.g. a prompt). -// Returns a human-readable rejection reason, or "" when the text is clean. -// Exported for reuse by the MCP server tool. -func ScanForSecrets(text string) string { - content := text - report, err := scanner.NewScanner().Scan( - []scanner.ScanItem{{Content: &content, Source: "prompt"}}, - scanner.ScanConfig{WithValidation: true}, - ) - if err != nil { - return "" // fail-open: scanner error should not block the developer - } - - var findings []string - for _, group := range report.Results { - for _, secret := range group { - severity := severityFromValidation(string(secret.ValidationStatus)) - findings = append(findings, fmt.Sprintf(" - %s (severity: %s)", secret.RuleID, severity)) - } - } - if len(findings) == 0 { - return "" - } - return fmt.Sprintf( - "Blocked by Checkmarx: prompt contains %d secret(s):\n%s\nRemove the secrets and try again.", - len(findings), strings.Join(findings, "\n"), - ) -} - -// severityFromValidation maps 2ms validation status to a severity label. -func severityFromValidation(status string) string { - switch status { - case "Valid": - return "Critical" - case "Invalid": - return "Medium" - default: // "Unknown" or anything else - return "High" - } -} - // ============================================================================= // License check // ============================================================================= @@ -217,30 +36,9 @@ func isLicensed() bool { } // ============================================================================= -// Hook registration -// ============================================================================= - -// registerGuardrails wires the four guardrail handlers. -func registerGuardrails() { - agenthooks.WhenAgentIdle(cxWhenAgentIdle) - agenthooks.BeforeToolCall(cxBeforeToolCall) - agenthooks.AfterFileWrite(cxAfterFileWrite) - agenthooks.BeforePrompt(cxBeforePrompt) -} - -// registerPassThrough wires no-op handlers that always allow the action. -// Used when the license check fails so we still emit valid JSON (fail-open). -func registerPassThrough() { - agenthooks.WhenAgentIdle(func(_ agenthooks.AgentIdleEvent) agenthooks.IdleVerdict { return agenthooks.Resume() }) - agenthooks.BeforeToolCall(func(_ agenthooks.ToolCallEvent) agenthooks.ToolVerdict { return agenthooks.Allow() }) - agenthooks.AfterFileWrite(func(_ agenthooks.FileWriteEvent) agenthooks.FileWriteVerdict { return agenthooks.AcceptWrite() }) - agenthooks.BeforePrompt(func(_ agenthooks.PromptEvent) agenthooks.PromptVerdict { return agenthooks.AcceptPrompt() }) -} - -// ============================================================================= -// Cobra routing — hidden subcommands for Cursor hook events. +// Hook dispatch commands — hidden subcommands for all AI agent hook events. // -// Cursor invokes: cx hooks +// Agents invoke: cx hooks // Each route reads JSON from stdin and writes the verdict as JSON to stdout. // ============================================================================= @@ -248,18 +46,37 @@ func HookDispatchCommands() []*cobra.Command { type route struct{ use, short string } routes := []route{ - // WhenAgentIdle - {"cursor-stop", "Cursor agent finished"}, + // Claude Code + {"claude-stop", "Claude Code agent finished"}, + {"claude-pre-tool-use", "Gate Claude Code tool use"}, + {"claude-after-file-write", "React to Claude Code file write"}, + {"claude-user-prompt-submit", "Gate Claude Code prompt"}, - // BeforeToolCall + // Cursor + {"cursor-stop", "Cursor agent finished"}, {"cursor-before-shell", "Gate Cursor shell execution"}, {"cursor-before-mcp", "Gate Cursor MCP execution"}, - - // AfterFileWrite {"cursor-after-file-edit", "React to Cursor file edit"}, - - // BeforePrompt {"cursor-before-submit-prompt", "Gate Cursor prompt"}, + + // Windsurf + {"windsurf-pre-run-command", "Gate Windsurf shell execution"}, + {"windsurf-pre-mcp-tool-use", "Gate Windsurf MCP execution"}, + {"windsurf-pre-user-prompt", "Gate Windsurf prompt"}, + {"windsurf-post-write-code", "React to Windsurf file write"}, + {"windsurf-post-cascade-response", "Windsurf agent finished"}, + + // Factory Droid + {"droid-stop", "Factory Droid agent finished"}, + {"droid-pre-tool-use", "Gate Factory Droid tool use"}, + {"droid-after-file-write", "React to Factory Droid file write"}, + {"droid-user-prompt-submit", "Gate Factory Droid prompt"}, + + // Gemini CLI + {"gemini-before-agent", "Gemini CLI agent starting"}, + {"gemini-before-tool", "Gate Gemini CLI tool execution"}, + {"gemini-after-file-tool", "React to Gemini CLI file write"}, + {"gemini-after-agent", "Gemini CLI agent finished"}, } cmds := make([]*cobra.Command, len(routes)) @@ -274,9 +91,9 @@ func HookDispatchCommands() []*cobra.Command { PersistentPreRunE: func(*cobra.Command, []string) error { return nil }, Run: func(cmd *cobra.Command, _ []string) { if isLicensed() { - registerGuardrails() + cxhooks.RegisterGuardrails() } else { - registerPassThrough() + cxhooks.RegisterPassThrough() } // Cobra consumed the route name from os.Args, so Dispatch() // would see "hooks" instead. Fix it before dispatching. @@ -289,16 +106,27 @@ func HookDispatchCommands() []*cobra.Command { } // ============================================================================= -// Management command — cx hooks agenthooks install +// Management command — cx hooks agenthooks install [agent] // ============================================================================= +// agentDisplayNames maps agent IDs to human-readable names for output. +var agentDisplayNames = map[string]string{ + "claude": "Claude Code", + "cursor": "Cursor", + "windsurf": "Windsurf", + "droid": "Factory Droid", + "gemini": "Gemini CLI", +} + func NewAgentHooksCommand() *cobra.Command { cmd := &cobra.Command{ Use: "agenthooks", - Short: "Manage Cursor hook configuration", - Long: "Configure Cursor hooks to invoke cx directly. No separate binary needed.", + Short: "Manage AI coding agent hook configuration", + Long: "Configure AI coding agent hooks to invoke cx directly. Supports Claude Code, Cursor, Windsurf, Factory Droid, and Gemini CLI.", Example: heredoc.Doc(` - $ cx hooks agenthooks install + $ cx hooks agenthooks install # install for all agents + $ cx hooks agenthooks install cursor # install for Cursor only + $ cx hooks agenthooks install claude # install for Claude Code only `), } cmd.AddCommand(agentHooksInstallCmd()) @@ -306,21 +134,43 @@ func NewAgentHooksCommand() *cobra.Command { } func agentHooksInstallCmd() *cobra.Command { - return &cobra.Command{ + installCmd := &cobra.Command{ Use: "install", - Short: "Write hook config for Cursor", + Short: "Write hook config for all AI coding agents", Long: heredoc.Doc(` - Patches ~/.cursor/hooks.json so Cursor invokes - "cx hooks " on hook events. + Patches the hook configuration for all supported AI coding agents + so they invoke "cx hooks " on hook events. + + Supported agents and their config files: + claude ~/.claude/settings.json + cursor ~/.cursor/hooks.json + windsurf ~/.codeium/windsurf/hooks.json + droid ~/.factory/settings.json + gemini ~/.gemini/settings.json `), Example: " $ cx hooks agenthooks install", RunE: func(*cobra.Command, []string) error { - return runInstall() + return runInstallAll() }, } + + // Per-agent subcommands. + for _, agent := range []string{"claude", "cursor", "windsurf", "droid", "gemini"} { + agent := agent + installCmd.AddCommand(&cobra.Command{ + Use: agent, + Short: "Write hook config for " + agentDisplayNames[agent], + Example: " $ cx hooks agenthooks install " + agent, + RunE: func(*cobra.Command, []string) error { + return runInstallAgent(agent) + }, + }) + } + + return installCmd } -func runInstall() error { +func runInstallAll() error { cxPath, err := os.Executable() if err != nil { return errors.Wrap(err, "resolving cx binary path") @@ -330,56 +180,37 @@ func runInstall() error { return errors.Wrap(err, "finding home directory") } - if err := installCursor(home, cxPath); err != nil { - return fmt.Errorf("Cursor: %w", err) + var failed int + for _, agent := range []string{"claude", "cursor", "windsurf", "droid", "gemini"} { + if err := cxhooks.Installers[agent](home, cxPath); err != nil { + fmt.Fprintf(os.Stderr, "✗ %s: %v\n", agentDisplayNames[agent], err) + failed++ + } else { + fmt.Fprintf(os.Stdout, "✓ %s configured\n", agentDisplayNames[agent]) + } } - fmt.Fprintf(os.Stdout, "✓ Cursor configured\n") - return nil -} - -// ============================================================================= -// Cursor install helper -// ============================================================================= - -func installCursor(home, cx string) error { - return patchJSON(filepath.Join(home, ".cursor", "hooks.json"), func(m map[string]any) { - m["stop"] = cursorHook(cx, "cursor-stop") - m["beforeShellExecution"] = cursorHook(cx, "cursor-before-shell") - m["beforeMCPExecution"] = cursorHook(cx, "cursor-before-mcp") - m["afterFileEdit"] = cursorHook(cx, "cursor-after-file-edit") - m["beforeSubmitPrompt"] = cursorHook(cx, "cursor-before-submit-prompt") - }) -} - -// ============================================================================= -// JSON config helpers -// ============================================================================= - -// cmdString builds "cx hooks ", quoting the path if it has spaces. -func cmdString(cx, route string) string { - if strings.Contains(cx, " ") { - cx = `"` + cx + `"` + if failed > 0 { + return fmt.Errorf("%d agent(s) failed to configure", failed) } - return cx + " hooks " + route -} - -// cursorHook builds a Cursor hook entry: {command: "..."} -func cursorHook(cx, route string) map[string]any { - return map[string]any{"command": cmdString(cx, route)} + return nil } -func patchJSON(path string, patch func(map[string]any)) error { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err +func runInstallAgent(agent string) error { + fn, ok := cxhooks.Installers[agent] + if !ok { + return fmt.Errorf("unknown agent %q", agent) } - m := map[string]any{} - if data, err := os.ReadFile(path); err == nil { - _ = json.Unmarshal(data, &m) + cxPath, err := os.Executable() + if err != nil { + return errors.Wrap(err, "resolving cx binary path") } - patch(m) - data, err := json.MarshalIndent(m, "", " ") + home, err := os.UserHomeDir() if err != nil { - return err + return errors.Wrap(err, "finding home directory") + } + if err := fn(home, cxPath); err != nil { + return fmt.Errorf("%s: %w", agentDisplayNames[agent], err) } - return os.WriteFile(path, append(data, '\n'), 0o644) + fmt.Fprintf(os.Stdout, "✓ %s configured\n", agentDisplayNames[agent]) + return nil } diff --git a/internal/commands/mcp/mcp.go b/internal/commands/mcp/mcp.go deleted file mode 100644 index 2b473cf89..000000000 --- a/internal/commands/mcp/mcp.go +++ /dev/null @@ -1,113 +0,0 @@ -package mcp - -import ( - "context" - "os" - "os/signal" - "syscall" - - mcplib "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" - "github.com/rs/zerolog" - "github.com/spf13/cobra" - - "github.com/checkmarx/ast-cli/internal/commands/mcp/tools" - "github.com/checkmarx/ast-cli/internal/params" -) - -// NewMCPCommand creates the "cx mcp" cobra command. -// -// The guardrail functions are injected from the commands package where the -// existing implementations live — no duplication, no circular imports. -// -// - shellGuard: checks a shell command against the org blocklist. -// Returns (true, reason) if blocked, (false, "") if allowed. -// - promptGuard: scans text for leaked secrets via 2ms. -// Returns a reason string if secrets found, "" if clean. -func NewMCPCommand( - shellGuard func(command string) (blocked bool, reason string), - promptGuard func(text string) (reason string), -) *cobra.Command { - return &cobra.Command{ - Use: "mcp", - Short: "Start MCP server for AI assistant integration", - Long: `Start a Model Context Protocol (MCP) server that exposes Checkmarx -security guardrails as tools for AI coding assistants. - -Tools: - cx_shell_guard — Check shell commands against the organization's blocklist - cx_prompt_guard — Scan prompts for leaked secrets before they reach the AI - -Transport: stdio (compatible with Claude Desktop, Cursor, VS Code Copilot, Windsurf)`, - Example: ` # Start MCP server - cx mcp - - # Claude Desktop config (claude_desktop_config.json): - { - "mcpServers": { - "checkmarx": { "command": "cx", "args": ["mcp"] } - } - }`, - RunE: func(_ *cobra.Command, _ []string) error { - return run(shellGuard, promptGuard) - }, - } -} - -func run( - shellGuard func(string) (bool, string), - promptGuard func(string) string, -) error { - logger := zerolog.New(os.Stderr).With().Timestamp().Str("component", "mcp").Logger() - - s := server.NewMCPServer( - "Checkmarx Security", - params.Version, - server.WithToolCapabilities(true), - server.WithLogging(), - server.WithHooks(newHooks(logger)), - ) - - shellTool := tools.NewShellGuardTool(shellGuard, logger) - s.AddTool(tools.ShellGuardDef(), shellTool.Handle) - - promptTool := tools.NewPromptGuardTool(promptGuard, logger) - s.AddTool(tools.PromptGuardDef(), promptTool.Handle) - - logger.Info(). - Str("version", params.Version). - Str("transport", "stdio"). - Int("tools", 2). - Msg("starting MCP server") - - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT) - go func() { - <-sigChan - logger.Info().Msg("shutting down") - os.Exit(0) - }() - - return server.ServeStdio(s) -} - -func newHooks(logger zerolog.Logger) *server.Hooks { - h := &server.Hooks{} - - h.AddAfterInitialize(func(_ context.Context, _ any, msg *mcplib.InitializeRequest, _ *mcplib.InitializeResult) { - logger.Info(). - Str("client", msg.Params.ClientInfo.Name). - Str("version", msg.Params.ClientInfo.Version). - Msg("client connected") - }) - - h.AddOnError(func(_ context.Context, _ any, method mcplib.MCPMethod, _ any, err error) { - logger.Error().Err(err).Str("method", string(method)).Msg("error") - }) - - h.AddAfterCallTool(func(_ context.Context, _ any, msg *mcplib.CallToolRequest, _ any) { - logger.Info().Str("tool", msg.Params.Name).Msg("tool called") - }) - - return h -} diff --git a/internal/commands/mcp/tools/handler.go b/internal/commands/mcp/tools/handler.go deleted file mode 100644 index 22992b772..000000000 --- a/internal/commands/mcp/tools/handler.go +++ /dev/null @@ -1,31 +0,0 @@ -package tools - -import ( - "encoding/json" - "fmt" - - mcplib "github.com/mark3labs/mcp-go/mcp" -) - -// textResult serializes any value to a JSON MCP text response. -func textResult(data interface{}) (*mcplib.CallToolResult, error) { - b, err := json.Marshal(data) - if err != nil { - return nil, fmt.Errorf("marshal result: %w", err) - } - return &mcplib.CallToolResult{ - Content: []mcplib.Content{ - mcplib.TextContent{Type: "text", Text: string(b)}, - }, - }, nil -} - -// errorResult returns an MCP error response with isError=true. -func errorResult(msg string) (*mcplib.CallToolResult, error) { - return &mcplib.CallToolResult{ - IsError: true, - Content: []mcplib.Content{ - mcplib.TextContent{Type: "text", Text: msg}, - }, - }, nil -} diff --git a/internal/commands/mcp/tools/prompt_guard.go b/internal/commands/mcp/tools/prompt_guard.go deleted file mode 100644 index bbc8e7fb4..000000000 --- a/internal/commands/mcp/tools/prompt_guard.go +++ /dev/null @@ -1,52 +0,0 @@ -package tools - -import ( - "context" - - mcplib "github.com/mark3labs/mcp-go/mcp" - "github.com/rs/zerolog" -) - -// PromptGuardTool wraps the existing prompt guardrail as an MCP tool. -type PromptGuardTool struct { - guard func(string) string - logger zerolog.Logger -} - -func NewPromptGuardTool(guard func(string) string, logger zerolog.Logger) *PromptGuardTool { - return &PromptGuardTool{guard: guard, logger: logger} -} - -func (t *PromptGuardTool) Handle(_ context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { - text, _ := req.GetArguments()["text"].(string) - if text == "" { - return errorResult("text is required") - } - - t.logger.Info().Int("length", len(text)).Msg("cx_prompt_guard invoked") - - reason := t.guard(text) - - result := map[string]interface{}{ - "clean": reason == "", - } - if reason != "" { - result["blocked"] = true - result["reason"] = reason - } - - return textResult(result) -} - -// PromptGuardDef defines the MCP tool schema for cx_prompt_guard. -func PromptGuardDef() mcplib.Tool { - return mcplib.NewTool( - "cx_prompt_guard", - mcplib.WithDescription( - "Scan text for leaked secrets (API keys, tokens, passwords) using the "+ - "Checkmarx 2ms secret detection engine. Returns whether the text is clean "+ - "or contains secrets that should be removed before sending to an AI agent.", - ), - mcplib.WithString("text", mcplib.Required(), mcplib.Description("The text to scan for secrets")), - ) -} diff --git a/internal/commands/mcp/tools/shell_guard.go b/internal/commands/mcp/tools/shell_guard.go deleted file mode 100644 index d86f9bb2f..000000000 --- a/internal/commands/mcp/tools/shell_guard.go +++ /dev/null @@ -1,52 +0,0 @@ -package tools - -import ( - "context" - - mcplib "github.com/mark3labs/mcp-go/mcp" - "github.com/rs/zerolog" -) - -// ShellGuardTool wraps the existing shell guardrail as an MCP tool. -type ShellGuardTool struct { - guard func(string) (bool, string) - logger zerolog.Logger -} - -func NewShellGuardTool(guard func(string) (bool, string), logger zerolog.Logger) *ShellGuardTool { - return &ShellGuardTool{guard: guard, logger: logger} -} - -func (t *ShellGuardTool) Handle(_ context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { - command, _ := req.GetArguments()["command"].(string) - if command == "" { - return errorResult("command is required") - } - - t.logger.Info().Str("command", command).Msg("cx_shell_guard invoked") - - blocked, reason := t.guard(command) - - result := map[string]interface{}{ - "command": command, - "allowed": !blocked, - } - if blocked { - result["reason"] = reason - } - - return textResult(result) -} - -// ShellGuardDef defines the MCP tool schema for cx_shell_guard. -func ShellGuardDef() mcplib.Tool { - return mcplib.NewTool( - "cx_shell_guard", - mcplib.WithDescription( - "Check a shell command against the organization's security blocklist. "+ - "Returns whether the command is allowed or blocked with a reason. "+ - "Use before executing any shell command to enforce security policy.", - ), - mcplib.WithString("command", mcplib.Required(), mcplib.Description("The shell command to check")), - ) -} diff --git a/internal/commands/root.go b/internal/commands/root.go index 3b972cd35..aa7ac8e31 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -11,7 +11,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/checkmarx/ast-cli/internal/commands/dast" - mcpcmd "github.com/checkmarx/ast-cli/internal/commands/mcp" + cxmcp "github.com/CheckmarxDev/ast-cx-hooks/mcp" "github.com/checkmarx/ast-cli/internal/commands/util" "github.com/checkmarx/ast-cli/internal/commands/util/printer" "github.com/checkmarx/ast-cli/internal/logger" @@ -251,7 +251,7 @@ func NewAstCLI( telemetryCmd := NewTelemetryCommand(telemetryWrapper) // MCP server — directly uses the exported guardrail functions from agenthooks.go. - mcpServerCmd := mcpcmd.NewMCPCommand(CheckShellCommand, ScanForSecrets) + mcpServerCmd := cxmcp.NewMCPCommand(params.Version, isLicensed) rootCmd.AddCommand( scanCmd,