feat: add native Auggie hook integration
This commit is contained in:
1 parent
a0fe657959
commit
9d03a68477
8 files changed
+1429
No files matched your search
@@ -0,0 +1,376 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
augmentAgentKey = "augment"
|
||||
augmentGuardScript = "guard.js"
|
||||
augmentAuditScript = "hook.js"
|
||||
augmentHookTimeout = float64(10_000)
|
||||
augmentPOSIXApostrophe = `'"'"'`
|
||||
)
|
||||
|
||||
var (
|
||||
augmentToolEvents = map[string]struct{}{
|
||||
"PreToolUse": {},
|
||||
"PostToolUse": {},
|
||||
}
|
||||
augmentSessionEvents = map[string]struct{}{
|
||||
"Stop": {},
|
||||
"SessionStart": {},
|
||||
"SessionEnd": {},
|
||||
"Notification": {},
|
||||
"PromptSubmit": {},
|
||||
}
|
||||
)
|
||||
|
||||
type augmentGroup struct {
|
||||
object map[string]any
|
||||
handlers []map[string]any
|
||||
}
|
||||
|
||||
type augmentHooks map[string][]augmentGroup
|
||||
|
||||
type augmentDocument struct {
|
||||
exists bool
|
||||
configPath string
|
||||
root map[string]any
|
||||
hooks augmentHooks
|
||||
}
|
||||
|
||||
type augmentWrapperPaths struct {
|
||||
guard string
|
||||
audit string
|
||||
}
|
||||
|
||||
type augmentRuntimeContract struct {
|
||||
agentID string
|
||||
guardPath string
|
||||
auditPath string
|
||||
guardWrapper string
|
||||
auditWrapper string
|
||||
}
|
||||
|
||||
func cloneAugmentObject(value map[string]any) map[string]any {
|
||||
clone := make(map[string]any, len(value))
|
||||
for key, item := range value {
|
||||
clone[key] = item
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func validateAugmentHandler(
|
||||
value any,
|
||||
event string,
|
||||
groupIndex, handlerIndex int,
|
||||
) (map[string]any, error) {
|
||||
label := fmt.Sprintf(
|
||||
"Auggie settings handler hooks.%s[%d].hooks[%d]",
|
||||
event, groupIndex, handlerIndex,
|
||||
)
|
||||
handler, ok := value.(map[string]any)
|
||||
if !ok || handler == nil {
|
||||
return nil, fmt.Errorf("%s must be an object", label)
|
||||
}
|
||||
handlerType, ok := handler["type"].(string)
|
||||
if !ok || handlerType != "command" {
|
||||
return nil, fmt.Errorf(`%s type must be "command"`, label)
|
||||
}
|
||||
command, ok := handler["command"].(string)
|
||||
if !ok || command == "" {
|
||||
return nil, fmt.Errorf("%s requires a non-empty command", label)
|
||||
}
|
||||
if arguments, exists := handler["args"]; exists {
|
||||
values, ok := arguments.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s args must be an array of strings", label)
|
||||
}
|
||||
for _, argument := range values {
|
||||
if _, ok := argument.(string); !ok {
|
||||
return nil, fmt.Errorf("%s args must be an array of strings", label)
|
||||
}
|
||||
}
|
||||
}
|
||||
if timeoutValue, exists := handler["timeout"]; exists {
|
||||
timeout, ok := timeoutValue.(float64)
|
||||
if !ok || math.IsNaN(timeout) || math.IsInf(timeout, 0) || timeout <= 0 {
|
||||
return nil, fmt.Errorf("%s timeout must be a positive finite number", label)
|
||||
}
|
||||
}
|
||||
return cloneAugmentObject(handler), nil
|
||||
}
|
||||
|
||||
func validateAugmentMetadata(value any, label string) error {
|
||||
metadata, ok := value.(map[string]any)
|
||||
if !ok || metadata == nil {
|
||||
return fmt.Errorf("%s metadata must be an object", label)
|
||||
}
|
||||
for _, key := range []string{
|
||||
"includeConversationData",
|
||||
"includeMCPMetadata",
|
||||
"includeUserContext",
|
||||
} {
|
||||
if field, exists := metadata[key]; exists {
|
||||
if _, ok := field.(bool); !ok {
|
||||
return fmt.Errorf("%s metadata.%s must be a boolean", label, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAugmentGroup(value any, event string, groupIndex int) (augmentGroup, error) {
|
||||
label := fmt.Sprintf("Auggie settings group hooks.%s[%d]", event, groupIndex)
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok || object == nil {
|
||||
return augmentGroup{}, fmt.Errorf("%s must be an object", label)
|
||||
}
|
||||
if _, sessionEvent := augmentSessionEvents[event]; sessionEvent {
|
||||
if _, hasMatcher := object["matcher"]; hasMatcher {
|
||||
return augmentGroup{}, fmt.Errorf("%s matcher is only supported for tool events", label)
|
||||
}
|
||||
}
|
||||
if matcher, exists := object["matcher"]; exists {
|
||||
if _, ok := matcher.(string); !ok {
|
||||
return augmentGroup{}, fmt.Errorf("%s matcher must be a string", label)
|
||||
}
|
||||
}
|
||||
if metadata, exists := object["metadata"]; exists {
|
||||
if err := validateAugmentMetadata(metadata, label); err != nil {
|
||||
return augmentGroup{}, err
|
||||
}
|
||||
}
|
||||
values, ok := object["hooks"].([]any)
|
||||
if !ok {
|
||||
return augmentGroup{}, fmt.Errorf("%s must contain a hooks array", label)
|
||||
}
|
||||
handlers := make([]map[string]any, 0, len(values))
|
||||
for handlerIndex, value := range values {
|
||||
handler, err := validateAugmentHandler(value, event, groupIndex, handlerIndex)
|
||||
if err != nil {
|
||||
return augmentGroup{}, err
|
||||
}
|
||||
handlers = append(handlers, handler)
|
||||
}
|
||||
return augmentGroup{object: cloneAugmentObject(object), handlers: handlers}, nil
|
||||
}
|
||||
|
||||
func readAugmentHooks(root map[string]any) (augmentHooks, error) {
|
||||
value, exists := root["hooks"]
|
||||
if !exists {
|
||||
return augmentHooks{}, nil
|
||||
}
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok || object == nil {
|
||||
return nil, fmt.Errorf(`Auggie settings field "hooks" must be an object`)
|
||||
}
|
||||
hooks := make(augmentHooks, len(object))
|
||||
for event, groupValue := range object {
|
||||
if _, toolEvent := augmentToolEvents[event]; !toolEvent {
|
||||
if _, sessionEvent := augmentSessionEvents[event]; !sessionEvent {
|
||||
return nil, fmt.Errorf(
|
||||
`Auggie settings field "hooks.%s" uses an unsupported event`,
|
||||
event,
|
||||
)
|
||||
}
|
||||
}
|
||||
values, ok := groupValue.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf(
|
||||
`Auggie settings field "hooks.%s" must be an array`,
|
||||
event,
|
||||
)
|
||||
}
|
||||
groups := make([]augmentGroup, 0, len(values))
|
||||
for groupIndex, value := range values {
|
||||
group, err := validateAugmentGroup(value, event, groupIndex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups = append(groups, group)
|
||||
}
|
||||
hooks[event] = groups
|
||||
}
|
||||
return hooks, nil
|
||||
}
|
||||
|
||||
func renderAugmentHooks(hooks augmentHooks) map[string]any {
|
||||
result := make(map[string]any, len(hooks))
|
||||
for event, groups := range hooks {
|
||||
values := make([]any, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
object := cloneAugmentObject(group.object)
|
||||
handlers := make([]any, 0, len(group.handlers))
|
||||
for _, handler := range group.handlers {
|
||||
handlers = append(handlers, handler)
|
||||
}
|
||||
object["hooks"] = handlers
|
||||
values = append(values, object)
|
||||
}
|
||||
result[event] = values
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func readAugmentWindowsArgument(command string) (string, bool) {
|
||||
if len(command) < 2 || command[0] != '"' {
|
||||
return "", false
|
||||
}
|
||||
var value strings.Builder
|
||||
for index := 1; index < len(command); index++ {
|
||||
if command[index] == '\\' && index+1 < len(command) && command[index+1] == '"' {
|
||||
value.WriteByte('"')
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if command[index] == '"' {
|
||||
return value.String(), index == len(command)-1 && value.Len() > 0
|
||||
}
|
||||
value.WriteByte(command[index])
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func readAugmentPOSIXArgument(command string) (string, bool) {
|
||||
if len(command) < 2 || command[0] != '\'' {
|
||||
return "", false
|
||||
}
|
||||
var value strings.Builder
|
||||
for index := 1; index < len(command); {
|
||||
if strings.HasPrefix(command[index:], augmentPOSIXApostrophe) {
|
||||
value.WriteByte('\'')
|
||||
index += len(augmentPOSIXApostrophe)
|
||||
continue
|
||||
}
|
||||
if command[index] == '\'' {
|
||||
return value.String(), index == len(command)-1 && value.Len() > 0
|
||||
}
|
||||
value.WriteByte(command[index])
|
||||
index++
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func parseAugmentCommand(command string) (string, bool) {
|
||||
if runtime.GOOS == "windows" {
|
||||
return readAugmentWindowsArgument(command)
|
||||
}
|
||||
return readAugmentPOSIXArgument(command)
|
||||
}
|
||||
|
||||
func normalizeAugmentPath(value string) string {
|
||||
absolute, err := filepath.Abs(value)
|
||||
if err == nil {
|
||||
value = absolute
|
||||
}
|
||||
value = filepath.ToSlash(filepath.Clean(value))
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.ToLower(value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func sameAugmentPath(left, right string) bool {
|
||||
return normalizeAugmentPath(left) == normalizeAugmentPath(right)
|
||||
}
|
||||
|
||||
func sameAugmentAgentID(left, right string) bool {
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.EqualFold(left, right)
|
||||
}
|
||||
return left == right
|
||||
}
|
||||
|
||||
func sameAugmentFileName(left, right string) bool {
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.EqualFold(left, right)
|
||||
}
|
||||
return left == right
|
||||
}
|
||||
|
||||
func managedAugmentAgentID(
|
||||
handler map[string]any,
|
||||
wrapperName, runtimeRoot string,
|
||||
) (string, bool) {
|
||||
if handler["type"] != "command" || handler["timeout"] != augmentHookTimeout {
|
||||
return "", false
|
||||
}
|
||||
if _, hasArguments := handler["args"]; hasArguments {
|
||||
return "", false
|
||||
}
|
||||
command, ok := handler["command"].(string)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
wrapperPath, ok := parseAugmentCommand(command)
|
||||
if !ok || !sameAugmentFileName(filepath.Base(wrapperPath), wrapperName) {
|
||||
return "", false
|
||||
}
|
||||
agentDirectory := filepath.Dir(wrapperPath)
|
||||
if !sameAugmentPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
||||
return "", false
|
||||
}
|
||||
agentID := filepath.Base(agentDirectory)
|
||||
return agentID, agentID != "" && agentID != "." && agentID != ".."
|
||||
}
|
||||
|
||||
func removeManagedAugmentGroups(
|
||||
groups []augmentGroup,
|
||||
wrapperName, agentID, runtimeRoot string,
|
||||
) ([]augmentGroup, bool) {
|
||||
result := make([]augmentGroup, 0, len(groups))
|
||||
changed := false
|
||||
for _, group := range groups {
|
||||
handlers := make([]map[string]any, 0, len(group.handlers))
|
||||
groupChanged := false
|
||||
for _, handler := range group.handlers {
|
||||
managedID, managed := managedAugmentAgentID(handler, wrapperName, runtimeRoot)
|
||||
remove := managed && (agentID == "" || sameAugmentAgentID(managedID, agentID))
|
||||
if remove {
|
||||
changed = true
|
||||
groupChanged = true
|
||||
continue
|
||||
}
|
||||
handlers = append(handlers, handler)
|
||||
}
|
||||
if len(handlers) > 0 || !groupChanged {
|
||||
result = append(result, augmentGroup{object: group.object, handlers: handlers})
|
||||
}
|
||||
}
|
||||
return result, changed
|
||||
}
|
||||
|
||||
func removeManagedAugmentHooks(
|
||||
hooks augmentHooks,
|
||||
agentID, runtimeRoot string,
|
||||
) (augmentHooks, bool) {
|
||||
result := make(augmentHooks, len(hooks))
|
||||
for event, groups := range hooks {
|
||||
result[event] = groups
|
||||
}
|
||||
changed := false
|
||||
for _, contract := range []struct{ event, wrapper string }{
|
||||
{"PreToolUse", augmentGuardWrapperName()},
|
||||
{"PostToolUse", augmentAuditWrapperName()},
|
||||
} {
|
||||
groups, eventChanged := removeManagedAugmentGroups(
|
||||
result[contract.event], contract.wrapper, agentID, runtimeRoot,
|
||||
)
|
||||
if !eventChanged {
|
||||
continue
|
||||
}
|
||||
changed = true
|
||||
if len(groups) == 0 {
|
||||
delete(result, contract.event)
|
||||
} else {
|
||||
result[contract.event] = groups
|
||||
}
|
||||
}
|
||||
return result, changed
|
||||
}
|
||||
Reference in new issue
Block a user