From 631563ebea8a23b7cca4956caa74213920fbc2e4 Mon Sep 17 00:00:00 2001 From: Rene Date: Mon, 5 Jan 2026 20:06:43 -0300 Subject: [PATCH] feat: add enterprise security hooks for compliance Add comprehensive enterprise security hook for GDPR/SOC2/HIPAA compliance. Features: - File Protection: Blocks access to sensitive files (.env, SSH keys, secrets) - Compliance Check: Detects PII, hardcoded credentials, insecure logging - Bash Validation: Blocks dangerous bash commands - Configurable via disabled_hooks Implements createEnterpriseSecurityHook() with full documentation. --- assets/oh-my-opencode.schema.json | 3 +- src/config/schema.ts | 476 +++++----- src/hooks/enterprise-security/AGENTS.md | 194 +++++ src/hooks/enterprise-security/constants.ts | 135 +++ src/hooks/enterprise-security/index.ts | 152 ++++ src/hooks/enterprise-security/types.ts | 42 + src/hooks/enterprise-security/utils.ts | 134 +++ src/hooks/index.ts | 55 +- src/index.ts | 957 +++++++++++---------- 9 files changed, 1429 insertions(+), 719 deletions(-) create mode 100644 src/hooks/enterprise-security/AGENTS.md create mode 100644 src/hooks/enterprise-security/constants.ts create mode 100644 src/hooks/enterprise-security/index.ts create mode 100644 src/hooks/enterprise-security/types.ts create mode 100644 src/hooks/enterprise-security/utils.ts diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index bafe3703ca..8bd5663fed 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -71,7 +71,8 @@ "compaction-context-injector", "claude-code-hooks", "auto-slash-command", - "edit-error-recovery" + "edit-error-recovery", + "enterprise-security" ] } }, diff --git a/src/config/schema.ts b/src/config/schema.ts index 6f2097ca94..e54c1e8e4f 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -1,268 +1,290 @@ -import { z } from "zod" -import { AnyMcpNameSchema, McpNameSchema } from "../mcp/types" +import { z } from "zod"; +import { AnyMcpNameSchema, McpNameSchema } from "../mcp/types"; -const PermissionValue = z.enum(["ask", "allow", "deny"]) +const PermissionValue = z.enum(["ask", "allow", "deny"]); const BashPermission = z.union([ - PermissionValue, - z.record(z.string(), PermissionValue), -]) + PermissionValue, + z.record(z.string(), PermissionValue), +]); const AgentPermissionSchema = z.object({ - edit: PermissionValue.optional(), - bash: BashPermission.optional(), - webfetch: PermissionValue.optional(), - doom_loop: PermissionValue.optional(), - external_directory: PermissionValue.optional(), -}) + edit: PermissionValue.optional(), + bash: BashPermission.optional(), + webfetch: PermissionValue.optional(), + doom_loop: PermissionValue.optional(), + external_directory: PermissionValue.optional(), +}); export const BuiltinAgentNameSchema = z.enum([ - "Sisyphus", - "oracle", - "librarian", - "explore", - "frontend-ui-ux-engineer", - "document-writer", - "multimodal-looker", -]) + "Sisyphus", + "oracle", + "librarian", + "explore", + "frontend-ui-ux-engineer", + "document-writer", + "multimodal-looker", +]); -export const BuiltinSkillNameSchema = z.enum([ - "playwright", -]) +export const BuiltinSkillNameSchema = z.enum(["playwright"]); export const OverridableAgentNameSchema = z.enum([ - "build", - "plan", - "Sisyphus", - "OpenCode-Builder", - "Planner-Sisyphus", - "oracle", - "librarian", - "explore", - "frontend-ui-ux-engineer", - "document-writer", - "multimodal-looker", -]) + "build", + "plan", + "Sisyphus", + "OpenCode-Builder", + "Planner-Sisyphus", + "oracle", + "librarian", + "explore", + "frontend-ui-ux-engineer", + "document-writer", + "multimodal-looker", +]); -export const AgentNameSchema = BuiltinAgentNameSchema +export const AgentNameSchema = BuiltinAgentNameSchema; export const HookNameSchema = z.enum([ - "todo-continuation-enforcer", - "context-window-monitor", - "session-recovery", - "session-notification", - "comment-checker", - "grep-output-truncator", - "tool-output-truncator", - "directory-agents-injector", - "directory-readme-injector", - "empty-task-response-detector", - "think-mode", - "anthropic-context-window-limit-recovery", - "rules-injector", - "background-notification", - "auto-update-checker", - "startup-toast", - "keyword-detector", - "agent-usage-reminder", - "non-interactive-env", - "interactive-bash-session", - "empty-message-sanitizer", - "thinking-block-validator", - "ralph-loop", - "preemptive-compaction", - "compaction-context-injector", - "claude-code-hooks", - "auto-slash-command", - "edit-error-recovery", -]) + "todo-continuation-enforcer", + "context-window-monitor", + "session-recovery", + "session-notification", + "comment-checker", + "grep-output-truncator", + "tool-output-truncator", + "directory-agents-injector", + "directory-readme-injector", + "empty-task-response-detector", + "think-mode", + "anthropic-context-window-limit-recovery", + "rules-injector", + "background-notification", + "auto-update-checker", + "startup-toast", + "keyword-detector", + "agent-usage-reminder", + "non-interactive-env", + "interactive-bash-session", + "empty-message-sanitizer", + "thinking-block-validator", + "ralph-loop", + "preemptive-compaction", + "compaction-context-injector", + "claude-code-hooks", + "auto-slash-command", + "edit-error-recovery", + "enterprise-security", +]); -export const BuiltinCommandNameSchema = z.enum([ - "init-deep", -]) +export const BuiltinCommandNameSchema = z.enum(["init-deep"]); export const AgentOverrideConfigSchema = z.object({ - model: z.string().optional(), - temperature: z.number().min(0).max(2).optional(), - top_p: z.number().min(0).max(1).optional(), - prompt: z.string().optional(), - prompt_append: z.string().optional(), - tools: z.record(z.string(), z.boolean()).optional(), - disable: z.boolean().optional(), - description: z.string().optional(), - mode: z.enum(["subagent", "primary", "all"]).optional(), - color: z - .string() - .regex(/^#[0-9A-Fa-f]{6}$/) - .optional(), - permission: AgentPermissionSchema.optional(), -}) + model: z.string().optional(), + temperature: z.number().min(0).max(2).optional(), + top_p: z.number().min(0).max(1).optional(), + prompt: z.string().optional(), + prompt_append: z.string().optional(), + tools: z.record(z.string(), z.boolean()).optional(), + disable: z.boolean().optional(), + description: z.string().optional(), + mode: z.enum(["subagent", "primary", "all"]).optional(), + color: z + .string() + .regex(/^#[0-9A-Fa-f]{6}$/) + .optional(), + permission: AgentPermissionSchema.optional(), +}); export const AgentOverridesSchema = z.object({ - build: AgentOverrideConfigSchema.optional(), - plan: AgentOverrideConfigSchema.optional(), - Sisyphus: AgentOverrideConfigSchema.optional(), - "OpenCode-Builder": AgentOverrideConfigSchema.optional(), - "Planner-Sisyphus": AgentOverrideConfigSchema.optional(), - oracle: AgentOverrideConfigSchema.optional(), - librarian: AgentOverrideConfigSchema.optional(), - explore: AgentOverrideConfigSchema.optional(), - "frontend-ui-ux-engineer": AgentOverrideConfigSchema.optional(), - "document-writer": AgentOverrideConfigSchema.optional(), - "multimodal-looker": AgentOverrideConfigSchema.optional(), -}) + build: AgentOverrideConfigSchema.optional(), + plan: AgentOverrideConfigSchema.optional(), + Sisyphus: AgentOverrideConfigSchema.optional(), + "OpenCode-Builder": AgentOverrideConfigSchema.optional(), + "Planner-Sisyphus": AgentOverrideConfigSchema.optional(), + oracle: AgentOverrideConfigSchema.optional(), + librarian: AgentOverrideConfigSchema.optional(), + explore: AgentOverrideConfigSchema.optional(), + "frontend-ui-ux-engineer": AgentOverrideConfigSchema.optional(), + "document-writer": AgentOverrideConfigSchema.optional(), + "multimodal-looker": AgentOverrideConfigSchema.optional(), +}); export const ClaudeCodeConfigSchema = z.object({ - mcp: z.boolean().optional(), - commands: z.boolean().optional(), - skills: z.boolean().optional(), - agents: z.boolean().optional(), - hooks: z.boolean().optional(), - plugins: z.boolean().optional(), - plugins_override: z.record(z.string(), z.boolean()).optional(), -}) + mcp: z.boolean().optional(), + commands: z.boolean().optional(), + skills: z.boolean().optional(), + agents: z.boolean().optional(), + hooks: z.boolean().optional(), + plugins: z.boolean().optional(), + plugins_override: z.record(z.string(), z.boolean()).optional(), +}); export const SisyphusAgentConfigSchema = z.object({ - disabled: z.boolean().optional(), - default_builder_enabled: z.boolean().optional(), - planner_enabled: z.boolean().optional(), - replace_plan: z.boolean().optional(), -}) + disabled: z.boolean().optional(), + default_builder_enabled: z.boolean().optional(), + planner_enabled: z.boolean().optional(), + replace_plan: z.boolean().optional(), +}); export const CommentCheckerConfigSchema = z.object({ - /** Custom prompt to replace the default warning message. Use {{comments}} placeholder for detected comments XML. */ - custom_prompt: z.string().optional(), -}) + /** Custom prompt to replace the default warning message. Use {{comments}} placeholder for detected comments XML. */ + custom_prompt: z.string().optional(), +}); export const DynamicContextPruningConfigSchema = z.object({ - /** Enable dynamic context pruning (default: false) */ - enabled: z.boolean().default(false), - /** Notification level: off, minimal, or detailed (default: detailed) */ - notification: z.enum(["off", "minimal", "detailed"]).default("detailed"), - /** Turn protection - prevent pruning recent tool outputs */ - turn_protection: z.object({ - enabled: z.boolean().default(true), - turns: z.number().min(1).max(10).default(3), - }).optional(), - /** Tools that should never be pruned */ - protected_tools: z.array(z.string()).default([ - "task", "todowrite", "todoread", - "lsp_rename", "lsp_code_action_resolve", - "session_read", "session_write", "session_search", - ]), - /** Pruning strategies configuration */ - strategies: z.object({ - /** Remove duplicate tool calls (same tool + same args) */ - deduplication: z.object({ - enabled: z.boolean().default(true), - }).optional(), - /** Prune write inputs when file subsequently read */ - supersede_writes: z.object({ - enabled: z.boolean().default(true), - /** Aggressive mode: prune any write if ANY subsequent read */ - aggressive: z.boolean().default(false), - }).optional(), - /** Prune errored tool inputs after N turns */ - purge_errors: z.object({ - enabled: z.boolean().default(true), - turns: z.number().min(1).max(20).default(5), - }).optional(), - }).optional(), -}) + /** Enable dynamic context pruning (default: false) */ + enabled: z.boolean().default(false), + /** Notification level: off, minimal, or detailed (default: detailed) */ + notification: z.enum(["off", "minimal", "detailed"]).default("detailed"), + /** Turn protection - prevent pruning recent tool outputs */ + turn_protection: z + .object({ + enabled: z.boolean().default(true), + turns: z.number().min(1).max(10).default(3), + }) + .optional(), + /** Tools that should never be pruned */ + protected_tools: z + .array(z.string()) + .default([ + "task", + "todowrite", + "todoread", + "lsp_rename", + "lsp_code_action_resolve", + "session_read", + "session_write", + "session_search", + ]), + /** Pruning strategies configuration */ + strategies: z + .object({ + /** Remove duplicate tool calls (same tool + same args) */ + deduplication: z + .object({ + enabled: z.boolean().default(true), + }) + .optional(), + /** Prune write inputs when file subsequently read */ + supersede_writes: z + .object({ + enabled: z.boolean().default(true), + /** Aggressive mode: prune any write if ANY subsequent read */ + aggressive: z.boolean().default(false), + }) + .optional(), + /** Prune errored tool inputs after N turns */ + purge_errors: z + .object({ + enabled: z.boolean().default(true), + turns: z.number().min(1).max(20).default(5), + }) + .optional(), + }) + .optional(), +}); export const ExperimentalConfigSchema = z.object({ - aggressive_truncation: z.boolean().optional(), - auto_resume: z.boolean().optional(), - /** Enable preemptive compaction at threshold (default: true since v2.9.0) */ - preemptive_compaction: z.boolean().optional(), - /** Threshold percentage to trigger preemptive compaction (default: 0.80) */ - preemptive_compaction_threshold: z.number().min(0.5).max(0.95).optional(), - /** Truncate all tool outputs, not just whitelisted tools (default: false). Tool output truncator is enabled by default - disable via disabled_hooks. */ - truncate_all_tool_outputs: z.boolean().optional(), - /** Dynamic context pruning configuration */ - dynamic_context_pruning: DynamicContextPruningConfigSchema.optional(), - /** Enable DCP (Dynamic Context Pruning) for compaction - runs first when token limit exceeded (default: false) */ - dcp_for_compaction: z.boolean().optional(), -}) + aggressive_truncation: z.boolean().optional(), + auto_resume: z.boolean().optional(), + /** Enable preemptive compaction at threshold (default: true since v2.9.0) */ + preemptive_compaction: z.boolean().optional(), + /** Threshold percentage to trigger preemptive compaction (default: 0.80) */ + preemptive_compaction_threshold: z.number().min(0.5).max(0.95).optional(), + /** Truncate all tool outputs, not just whitelisted tools (default: false). Tool output truncator is enabled by default - disable via disabled_hooks. */ + truncate_all_tool_outputs: z.boolean().optional(), + /** Dynamic context pruning configuration */ + dynamic_context_pruning: DynamicContextPruningConfigSchema.optional(), + /** Enable DCP (Dynamic Context Pruning) for compaction - runs first when token limit exceeded (default: false) */ + dcp_for_compaction: z.boolean().optional(), +}); export const SkillSourceSchema = z.union([ - z.string(), - z.object({ - path: z.string(), - recursive: z.boolean().optional(), - glob: z.string().optional(), - }), -]) + z.string(), + z.object({ + path: z.string(), + recursive: z.boolean().optional(), + glob: z.string().optional(), + }), +]); export const SkillDefinitionSchema = z.object({ - description: z.string().optional(), - template: z.string().optional(), - from: z.string().optional(), - model: z.string().optional(), - agent: z.string().optional(), - subtask: z.boolean().optional(), - "argument-hint": z.string().optional(), - license: z.string().optional(), - compatibility: z.string().optional(), - metadata: z.record(z.string(), z.unknown()).optional(), - "allowed-tools": z.array(z.string()).optional(), - disable: z.boolean().optional(), -}) + description: z.string().optional(), + template: z.string().optional(), + from: z.string().optional(), + model: z.string().optional(), + agent: z.string().optional(), + subtask: z.boolean().optional(), + "argument-hint": z.string().optional(), + license: z.string().optional(), + compatibility: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + "allowed-tools": z.array(z.string()).optional(), + disable: z.boolean().optional(), +}); -export const SkillEntrySchema = z.union([ - z.boolean(), - SkillDefinitionSchema, -]) +export const SkillEntrySchema = z.union([z.boolean(), SkillDefinitionSchema]); export const SkillsConfigSchema = z.union([ - z.array(z.string()), - z.record(z.string(), SkillEntrySchema).and(z.object({ - sources: z.array(SkillSourceSchema).optional(), - enable: z.array(z.string()).optional(), - disable: z.array(z.string()).optional(), - }).partial()), -]) + z.array(z.string()), + z.record(z.string(), SkillEntrySchema).and( + z + .object({ + sources: z.array(SkillSourceSchema).optional(), + enable: z.array(z.string()).optional(), + disable: z.array(z.string()).optional(), + }) + .partial(), + ), +]); export const RalphLoopConfigSchema = z.object({ - /** Enable ralph loop functionality (default: false - opt-in feature) */ - enabled: z.boolean().default(false), - /** Default max iterations if not specified in command (default: 100) */ - default_max_iterations: z.number().min(1).max(1000).default(100), - /** Custom state file directory relative to project root (default: .opencode/) */ - state_dir: z.string().optional(), -}) + /** Enable ralph loop functionality (default: false - opt-in feature) */ + enabled: z.boolean().default(false), + /** Default max iterations if not specified in command (default: 100) */ + default_max_iterations: z.number().min(1).max(1000).default(100), + /** Custom state file directory relative to project root (default: .opencode/) */ + state_dir: z.string().optional(), +}); export const OhMyOpenCodeConfigSchema = z.object({ - $schema: z.string().optional(), - disabled_mcps: z.array(AnyMcpNameSchema).optional(), - disabled_agents: z.array(BuiltinAgentNameSchema).optional(), - disabled_skills: z.array(BuiltinSkillNameSchema).optional(), - disabled_hooks: z.array(HookNameSchema).optional(), - disabled_commands: z.array(BuiltinCommandNameSchema).optional(), - agents: AgentOverridesSchema.optional(), - claude_code: ClaudeCodeConfigSchema.optional(), - google_auth: z.boolean().optional(), - sisyphus_agent: SisyphusAgentConfigSchema.optional(), - comment_checker: CommentCheckerConfigSchema.optional(), - experimental: ExperimentalConfigSchema.optional(), - auto_update: z.boolean().optional(), - skills: SkillsConfigSchema.optional(), - ralph_loop: RalphLoopConfigSchema.optional(), -}) + $schema: z.string().optional(), + disabled_mcps: z.array(AnyMcpNameSchema).optional(), + disabled_agents: z.array(BuiltinAgentNameSchema).optional(), + disabled_skills: z.array(BuiltinSkillNameSchema).optional(), + disabled_hooks: z.array(HookNameSchema).optional(), + disabled_commands: z.array(BuiltinCommandNameSchema).optional(), + agents: AgentOverridesSchema.optional(), + claude_code: ClaudeCodeConfigSchema.optional(), + google_auth: z.boolean().optional(), + sisyphus_agent: SisyphusAgentConfigSchema.optional(), + comment_checker: CommentCheckerConfigSchema.optional(), + experimental: ExperimentalConfigSchema.optional(), + auto_update: z.boolean().optional(), + skills: SkillsConfigSchema.optional(), + ralph_loop: RalphLoopConfigSchema.optional(), +}); -export type OhMyOpenCodeConfig = z.infer -export type AgentOverrideConfig = z.infer -export type AgentOverrides = z.infer -export type AgentName = z.infer -export type HookName = z.infer -export type BuiltinCommandName = z.infer -export type BuiltinSkillName = z.infer -export type SisyphusAgentConfig = z.infer -export type CommentCheckerConfig = z.infer -export type ExperimentalConfig = z.infer -export type DynamicContextPruningConfig = z.infer -export type SkillsConfig = z.infer -export type SkillDefinition = z.infer -export type RalphLoopConfig = z.infer +export type OhMyOpenCodeConfig = z.infer; +export type AgentOverrideConfig = z.infer; +export type AgentOverrides = z.infer; +export type AgentName = z.infer; +export type HookName = z.infer; +export type BuiltinCommandName = z.infer; +export type BuiltinSkillName = z.infer; +export type SisyphusAgentConfig = z.infer; +export type CommentCheckerConfig = z.infer; +export type ExperimentalConfig = z.infer; +export type DynamicContextPruningConfig = z.infer< + typeof DynamicContextPruningConfigSchema +>; +export type SkillsConfig = z.infer; +export type SkillDefinition = z.infer; +export type RalphLoopConfig = z.infer; -export { AnyMcpNameSchema, type AnyMcpName, McpNameSchema, type McpName } from "../mcp/types" +export { + type AnyMcpName, + AnyMcpNameSchema, + type McpName, + McpNameSchema, +} from "../mcp/types"; diff --git a/src/hooks/enterprise-security/AGENTS.md b/src/hooks/enterprise-security/AGENTS.md new file mode 100644 index 0000000000..157705dcd3 --- /dev/null +++ b/src/hooks/enterprise-security/AGENTS.md @@ -0,0 +1,194 @@ +# Enterprise Security Hook + +Comprehensive security and compliance hook that protects sensitive files, detects hardcoded credentials, validates PII exposure, and blocks dangerous bash commands. + +## Features + +### 1. File Protection +Blocks read/write/edit access to sensitive files: +- Environment files (`.env`, `.env.*`) +- SSH keys (`.ssh/*`, `*.pem`, `*.key`) +- AWS credentials (`.aws/credentials`) +- Secrets directories (`secrets/*`, `.secrets/*`) +- Git credentials (`.git-credentials`) +- API keys (`api-keys.*`, `.npmrc`) +- Docker/Kubernetes secrets +- Terraform state files (`*.tfstate`, `terraform.tfvars`) +- Service account credentials (`credentials.json`, `service-account.json`) + +**Allowed paths** (exempt from protection): +- `.claude/*` - Claude Code configuration +- `.config/opencode/*` - OpenCode configuration +- `.opencode/*` - Project OpenCode configuration + +### 2. Compliance Check (GDPR, SOC2, HIPAA) +Scans file content for: +- **PII (Personally Identifiable Information)**: + - Social Security Numbers (SSN) + - Credit card numbers + - Phone numbers + - IP addresses + - Email addresses with passwords +- **Hardcoded Credentials**: + - API keys + - Passwords + - Tokens + - AWS access keys + - Private keys +- **Insecure Logging**: + - Sensitive data in console.log/print statements + +**Behavior**: +- **Errors**: Blocks operation completely (hardcoded credentials) +- **Warnings**: Allows operation but warns user (PII, insecure logging) + +### 3. Bash Command Validation +Blocks dangerous bash commands: +- `rm -rf /` (except `/tmp`) +- Fork bombs `:(){ :|:& };:` +- Disk wipe commands (`dd if=/dev/zero`) +- Filesystem formatting (`mkfs.*`) +- Direct disk writes (`> /dev/sd*`) +- Dangerous chmod (`chmod -R 777 /`) +- Curl/wget piped to bash/sh (`curl ... | bash`) + +**Warnings** (not blocked): +- Database exports without encryption +- HTTP requests with passwords in command line + +## Usage + +The hook is **enabled by default**. No configuration required. + +### Disable +```jsonc +{ + "disabled_hooks": ["enterprise-security"] +} +``` + +### Override Protection (Development Only) +If you need to access protected files for legitimate reasons: +1. Add the file path to `ALLOWED_PATHS` in `constants.ts` +2. Or temporarily disable the hook with `disabled_hooks` + +## Examples + +### Protected File Access (Blocked) +```typescript +// Read tool on .env file +{ + tool: "Read", + args: { filePath: ".env" } +} +// Result: BLOCKED +// Message: "SECURITY: Access to protected file denied. File: .env. Reason: Pattern match: .env" +``` + +### Hardcoded Credential (Blocked) +```typescript +// Write tool with hardcoded password +{ + tool: "Write", + args: { + filePath: "config.ts", + content: 'const password = "mySecretPassword123"' + } +} +// Result: BLOCKED +// Message: "COMPLIANCE WARNING: Potential security issues detected. +// ERRORS (must fix): +// - Hardcoded Credential: password\\s*=\\s*[\"'][^\"']{4,}[\"']" +``` + +### PII Detection (Warning) +```typescript +// Write tool with SSN +{ + tool: "Write", + args: { + filePath: "user.ts", + content: 'const ssn = "123-45-6789"' + } +} +// Result: ALLOWED (with warning) +// Warning: "COMPLIANCE WARNING: Potential security issues detected. +// WARNINGS (review recommended): +// - PII" +``` + +### Dangerous Bash Command (Blocked) +```typescript +// Bash tool with rm -rf / +{ + tool: "Bash", + args: { command: "rm -rf /" } +} +// Result: BLOCKED +// Message: "SECURITY: Dangerous bash command blocked. +// Command: rm -rf / +// Reason: Dangerous command pattern detected: rm\\s+-rf\\s+\\/(?!tmp|var\\/tmp)" +``` + +## Compliance Standards + +This hook helps meet requirements for: +- **GDPR**: PII detection and protection +- **SOC2**: Access control and audit logging +- **HIPAA**: PHI protection +- **PCI DSS**: Credit card data protection + +## Implementation Details + +### File Protection (`utils.ts:isProtectedFile`) +Uses `minimatch` for glob pattern matching against `PROTECTED_FILE_PATTERNS`. + +### Compliance Check (`utils.ts:checkComplianceViolations`) +Uses regex patterns to detect PII and credentials in file content. + +### Bash Validation (`utils.ts:checkDangerousBashCommand`) +Uses regex patterns to detect dangerous command patterns. + +### Hook Points +- **`tool.execute.before`**: File protection, compliance check, bash validation (blocking) +- **`tool.execute.after`**: Tool output scanning for sensitive data (warning only) + +## Patterns + +All patterns are configurable in `constants.ts`: +- `PROTECTED_FILE_PATTERNS`: File glob patterns +- `BLOCKED_PATHS`: Absolute paths always blocked +- `ALLOWED_PATHS`: Paths exempt from protection +- `PII_PATTERNS`: Regex patterns for PII detection +- `CREDENTIAL_PATTERNS`: Regex patterns for credentials +- `INSECURE_LOGGING_PATTERNS`: Regex patterns for insecure logging +- `DANGEROUS_BASH_PATTERNS`: Regex patterns for dangerous commands + +## Testing + +Test the hook with: +```bash +bun test src/hooks/enterprise-security/ +``` + +## FAQ + +**Q: Why is my `.env` file blocked?** +A: Environment files contain secrets. Use environment variables or secure vaults instead. + +**Q: I need to read credentials for migration. How?** +A: Temporarily disable the hook with `disabled_hooks: ["enterprise-security"]` or add path to `ALLOWED_PATHS`. + +**Q: False positive on PII detection?** +A: PII patterns are heuristic. Review the warning and ensure no real PII is present. + +**Q: How do I add custom patterns?** +A: Edit `constants.ts` and add your patterns to the appropriate array. + +## Contributing + +When adding new patterns: +1. Add to `constants.ts` with clear comment explaining what it detects +2. Update this AGENTS.md documentation +3. Add test cases to verify detection +4. Consider false positive rate diff --git a/src/hooks/enterprise-security/constants.ts b/src/hooks/enterprise-security/constants.ts new file mode 100644 index 0000000000..8b745abf29 --- /dev/null +++ b/src/hooks/enterprise-security/constants.ts @@ -0,0 +1,135 @@ +/** + * Enterprise Security Hook Constants + * Patterns for protected files, blocked commands, and compliance checks + */ + +// Protected file patterns (glob patterns) +export const PROTECTED_FILE_PATTERNS = [ + // Environment files + ".env", + ".env.*", + ".env.local", + ".env.production", + ".env.development", + "*.env", + + // AWS credentials + ".aws/credentials", + ".aws/config", + + // SSH keys + ".ssh/id_*", + ".ssh/known_hosts", + "*_rsa", + "*_ed25519", + "*.pem", + "*.key", + + // Secrets directories + "secrets/*.json", + "secrets/*.yaml", + "secrets/*.yml", + ".secrets/*.json", + ".secrets/*.yaml", + + // Git credentials + ".git-credentials", + + // NPM tokens + ".npmrc", + + // Docker secrets + ".docker/config.json", + + // Kubernetes secrets + "k8s/secrets/*.yaml", + "k8s/secrets/*.yml", + "kubernetes/secrets/*.yaml", + "kubernetes/secrets/*.yml", + + // Terraform state (contains secrets) + "*.tfstate", + "*.tfstate.backup", + "terraform.tfvars", + "*.auto.tfvars", + + // Database configs with passwords + "credentials.json", + "service-account.json", + "gcp-credentials.json", + "firebase-adminsdk*.json", + + // API keys files + "api-keys.json", + "api-keys.yaml", + ".api-keys", +]; + +// Paths that are always blocked (exact match) +export const BLOCKED_PATHS = [ + "/etc/passwd", + "/etc/shadow", + "/etc/sudoers", + "~/.bash_history", + "~/.zsh_history", +]; + +// Paths to ALLOW even if they might match patterns +export const ALLOWED_PATHS = [ + ".claude/settings.json", + ".claude/agents/", + ".claude/skills/", + ".claude/hooks/", + ".claude/commands/", + ".claude/templates/", + ".claude/teams/", + ".config/opencode/", + ".opencode/", +]; + +// PII patterns (Personally Identifiable Information) +export const PII_PATTERNS = [ + // SSN (Social Security Number) + /\b\d{3}-\d{2}-\d{4}\b/, + // Credit Card + /\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b/, + // Email with password in same line + /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b.*password/i, + // Phone US + /\b\(?\d{3}\)?[-. ]?\d{3}[-. ]?\d{4}\b/, + // IP Address (can be PII in GDPR context) + /\b(?:\d{1,3}\.){3}\d{1,3}\b/, +]; + +// Hardcoded credentials patterns +export const CREDENTIAL_PATTERNS = [ + /password\s*=\s*["'][^"']{4,}["']/i, + /api[_-]?key\s*=\s*["'][^"']{8,}["']/i, + /secret\s*=\s*["'][^"']{8,}["']/i, + /token\s*=\s*["'][^"']{16,}["']/i, + /private[_-]?key\s*=\s*["']/i, + /AWS[_-]?ACCESS[_-]?KEY[_-]?ID\s*=\s*["']?AKI/i, + /AKIA[0-9A-Z]{16}/, +]; + +// Insecure logging patterns +export const INSECURE_LOGGING_PATTERNS = [ + /console\.log.*password/i, + /console\.log.*token/i, + /console\.log.*secret/i, + /console\.log.*key/i, + /print.*password/i, + /logger\..*password/i, +]; + +// Dangerous bash commands +export const DANGEROUS_BASH_PATTERNS = [ + /rm\s+-rf\s+\/(?!tmp|var\/tmp)/i, // rm -rf / (except /tmp) + /:\(\)\{\s*:\|:&\s*\};:/, // Fork bomb + /dd\s+if=\/dev\/zero/i, // Disk wipe + /mkfs\./i, // Format filesystem + /> \/dev\/sd[a-z]/i, // Direct disk write + /chmod\s+-R\s+777\s+\//i, // Chmod 777 on root + /curl.*\|\s*bash/i, // Curl | bash (potential security risk) + /wget.*\|\s*sh/i, // Wget | sh (potential security risk) +]; diff --git a/src/hooks/enterprise-security/index.ts b/src/hooks/enterprise-security/index.ts new file mode 100644 index 0000000000..f5c7cc7b1f --- /dev/null +++ b/src/hooks/enterprise-security/index.ts @@ -0,0 +1,152 @@ +import type { PluginInput } from "@opencode-ai/plugin"; +import type { + BashToolArgs, + EditToolArgs, + ReadToolArgs, + ToolExecuteBeforeOutput, + ToolExecuteInput, + ToolExecuteOutput, + WriteToolArgs, +} from "./types"; +import { + checkComplianceViolations, + checkDangerousBashCommand, + isProtectedFile, +} from "./utils"; + +export function createEnterpriseSecurityHook(_ctx: PluginInput) { + const toolExecuteBefore = async ( + input: ToolExecuteInput, + output: ToolExecuteBeforeOutput, + ) => { + const toolName = input.tool.toLowerCase(); + + if (toolName === "read" || toolName === "write" || toolName === "edit") { + const args = output.args as ReadToolArgs | WriteToolArgs | EditToolArgs; + const filePath = args.filePath; + + if (filePath) { + const protection = isProtectedFile(filePath); + if (protection.blocked) { + output.blocked = true; + output.message = `SECURITY: Access to protected file denied.\nFile: ${filePath}\nReason: ${protection.reason}\n\nProtected files include: .env, .ssh/*, secrets/, credentials, API keys, etc.`; + return; + } + } + } + + if (toolName === "write" || toolName === "edit") { + const args = output.args as WriteToolArgs | EditToolArgs; + const content = + "content" in args + ? args.content + : "newString" in args + ? args.newString + : ""; + + if (content) { + const violations = checkComplianceViolations(content); + if (violations.length > 0) { + const errorViolations = violations.filter( + (v) => v.severity === "error", + ); + const warningViolations = violations.filter( + (v) => v.severity === "warning", + ); + + let message = + "COMPLIANCE WARNING: Potential security issues detected.\n\n"; + + if (errorViolations.length > 0) { + message += "ERRORS (must fix):\n"; + for (const violation of errorViolations) { + message += ` - ${violation.type}: ${violation.pattern}\n`; + } + } + + if (warningViolations.length > 0) { + message += "\nWARNINGS (review recommended):\n"; + for (const violation of warningViolations) { + message += ` - ${violation.type}\n`; + } + } + + message += "\nFor SOC2/GDPR/HIPAA compliance:\n"; + message += " - Remove hardcoded credentials\n"; + message += " - Use environment variables for secrets\n"; + message += " - Ensure PII is not logged or exposed\n"; + message += " - Use proper data encryption\n"; + + if (errorViolations.length > 0) { + output.blocked = true; + output.message = message; + return; + } + + console.warn(message); + } + } + } + + if (toolName === "bash") { + const args = output.args as BashToolArgs; + const command = args.command; + + if (command) { + const check = checkDangerousBashCommand(command); + if (check.dangerous) { + output.blocked = true; + output.message = `SECURITY: Dangerous bash command blocked.\nCommand: ${command}\nReason: ${check.reason}\n\nDangerous commands include: rm -rf /, fork bombs, disk wiping, curl|bash, etc.`; + return; + } + + if ( + command.includes("curl") && + command.includes("-d") && + command.includes("password") + ) { + console.warn( + "WARNING: Potential credential exposure in HTTP request. Consider using secure methods.", + ); + } + + if ( + (command.includes("mysqldump") || + command.includes("pg_dump") || + command.includes("mongodump")) && + !command.includes("ssl") && + !command.includes("encryption") && + !command.includes("gpg") + ) { + console.warn( + "WARNING: Database export without encryption detected. Consider encrypting sensitive data exports for compliance.", + ); + } + } + } + }; + + const toolExecuteAfter = async ( + _input: ToolExecuteInput, + output: ToolExecuteOutput, + ) => { + if (output.output) { + const violations = checkComplianceViolations(output.output); + if (violations.length > 0) { + const warningViolations = violations.filter( + (v) => v.severity === "warning", + ); + if (warningViolations.length > 0) { + console.warn( + `COMPLIANCE: Tool output may contain sensitive data (${warningViolations.map((v) => v.type).join(", ")}). Review before sharing.`, + ); + } + } + } + }; + + return { + "tool.execute.before": toolExecuteBefore, + "tool.execute.after": toolExecuteAfter, + }; +} diff --git a/src/hooks/enterprise-security/types.ts b/src/hooks/enterprise-security/types.ts new file mode 100644 index 0000000000..5960ebcf5a --- /dev/null +++ b/src/hooks/enterprise-security/types.ts @@ -0,0 +1,42 @@ +export interface ToolExecuteInput { + tool: string; + sessionID: string; + callID: string; +} + +export interface ToolExecuteOutput { + title: string; + output: string; + metadata: unknown; +} + +export interface ToolExecuteBeforeOutput { + args: unknown; + blocked?: boolean; + message?: string; +} + +export interface ReadToolArgs { + filePath?: string; +} + +export interface WriteToolArgs { + filePath?: string; + content?: string; +} + +export interface EditToolArgs { + filePath?: string; + oldString?: string; + newString?: string; +} + +export interface BashToolArgs { + command?: string; +} + +export interface ComplianceViolation { + type: string; + pattern: string; + severity: "warning" | "error"; +} diff --git a/src/hooks/enterprise-security/utils.ts b/src/hooks/enterprise-security/utils.ts new file mode 100644 index 0000000000..e62303095a --- /dev/null +++ b/src/hooks/enterprise-security/utils.ts @@ -0,0 +1,134 @@ +import { homedir } from "node:os"; +import { basename, resolve } from "node:path"; +import { minimatch } from "minimatch"; +import { + ALLOWED_PATHS, + BLOCKED_PATHS, + CREDENTIAL_PATTERNS, + DANGEROUS_BASH_PATTERNS, + INSECURE_LOGGING_PATTERNS, + PII_PATTERNS, + PROTECTED_FILE_PATTERNS, +} from "./constants"; +import type { ComplianceViolation } from "./types"; + +function normalizePath(path: string): string { + let normalized = path; + if (normalized.startsWith("~/")) { + normalized = resolve(homedir(), normalized.slice(2)); + } + if (normalized.startsWith("./")) { + normalized = normalized.slice(2); + } + return normalized; +} + +function isAllowedPath(filePath: string): boolean { + const normalized = normalizePath(filePath); + for (const allowed of ALLOWED_PATHS) { + const allowedNormalized = normalizePath(allowed); + if ( + normalized.endsWith(allowedNormalized) || + normalized.includes(allowedNormalized) + ) { + return true; + } + } + return false; +} + +export function isProtectedFile(filePath: string): { + blocked: boolean; + reason: string; +} { + const normalized = normalizePath(filePath); + + if (isAllowedPath(filePath)) { + return { blocked: false, reason: "Explicitly allowed" }; + } + + for (const blocked of BLOCKED_PATHS) { + const blockedNormalized = normalizePath(blocked); + if ( + normalized === blockedNormalized || + normalized.endsWith(blockedNormalized) + ) { + return { blocked: true, reason: `Exact match: ${blocked}` }; + } + } + + for (const pattern of PROTECTED_FILE_PATTERNS) { + const patternNormalized = normalizePath(pattern); + + if (minimatch(normalized, patternNormalized)) { + return { blocked: true, reason: `Pattern match: ${pattern}` }; + } + + if (minimatch(normalized, `**/${patternNormalized}`)) { + return { blocked: true, reason: `Pattern match: ${pattern}` }; + } + + if ( + !pattern.includes("/") && + minimatch(basename(normalized), patternNormalized) + ) { + return { blocked: true, reason: `Basename pattern match: ${pattern}` }; + } + } + + return { blocked: false, reason: "" }; +} + +export function checkComplianceViolations( + content: string, +): ComplianceViolation[] { + const violations: ComplianceViolation[] = []; + + for (const pattern of PII_PATTERNS) { + if (pattern.test(content)) { + violations.push({ + type: "PII", + pattern: pattern.source, + severity: "warning", + }); + } + } + + for (const pattern of CREDENTIAL_PATTERNS) { + if (pattern.test(content)) { + violations.push({ + type: "Hardcoded Credential", + pattern: pattern.source, + severity: "error", + }); + } + } + + for (const pattern of INSECURE_LOGGING_PATTERNS) { + if (pattern.test(content)) { + violations.push({ + type: "Insecure Logging", + pattern: "sensitive data in logs", + severity: "warning", + }); + } + } + + return violations; +} + +export function checkDangerousBashCommand(command: string): { + dangerous: boolean; + reason: string; +} { + for (const pattern of DANGEROUS_BASH_PATTERNS) { + if (pattern.test(command)) { + return { + dangerous: true, + reason: `Dangerous command pattern detected: ${pattern.source}`, + }; + } + } + + return { dangerous: false, reason: "" }; +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 36ea9c4f0a..a4197fa7ba 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,27 +1,42 @@ -export { createTodoContinuationEnforcer, type TodoContinuationEnforcer } from "./todo-continuation-enforcer"; -export { createContextWindowMonitorHook } from "./context-window-monitor"; -export { createSessionNotification } from "./session-notification"; -export { createSessionRecoveryHook, type SessionRecoveryHook, type SessionRecoveryOptions } from "./session-recovery"; +export { createAgentUsageReminderHook } from "./agent-usage-reminder"; +export { + type AnthropicContextWindowLimitRecoveryOptions, + createAnthropicContextWindowLimitRecoveryHook, +} from "./anthropic-context-window-limit-recovery"; +export { createAutoSlashCommandHook } from "./auto-slash-command"; +export { createAutoUpdateCheckerHook } from "./auto-update-checker"; +export { createBackgroundNotificationHook } from "./background-notification"; +export { createClaudeCodeHooksHook } from "./claude-code-hooks"; export { createCommentCheckerHooks } from "./comment-checker"; -export { createToolOutputTruncatorHook } from "./tool-output-truncator"; +export { createCompactionContextInjector } from "./compaction-context-injector"; +export { createContextWindowMonitorHook } from "./context-window-monitor"; export { createDirectoryAgentsInjectorHook } from "./directory-agents-injector"; export { createDirectoryReadmeInjectorHook } from "./directory-readme-injector"; +export { createEditErrorRecoveryHook } from "./edit-error-recovery"; +export { createEmptyMessageSanitizerHook } from "./empty-message-sanitizer"; export { createEmptyTaskResponseDetectorHook } from "./empty-task-response-detector"; -export { createAnthropicContextWindowLimitRecoveryHook, type AnthropicContextWindowLimitRecoveryOptions } from "./anthropic-context-window-limit-recovery"; -export { createPreemptiveCompactionHook, type PreemptiveCompactionOptions, type SummarizeContext, type BeforeSummarizeCallback } from "./preemptive-compaction"; -export { createCompactionContextInjector } from "./compaction-context-injector"; -export { createThinkModeHook } from "./think-mode"; -export { createClaudeCodeHooksHook } from "./claude-code-hooks"; -export { createRulesInjectorHook } from "./rules-injector"; -export { createBackgroundNotificationHook } from "./background-notification" -export { createAutoUpdateCheckerHook } from "./auto-update-checker"; - -export { createAgentUsageReminderHook } from "./agent-usage-reminder"; +export { createEnterpriseSecurityHook } from "./enterprise-security"; +export { createInteractiveBashSessionHook } from "./interactive-bash-session"; export { createKeywordDetectorHook } from "./keyword-detector"; export { createNonInteractiveEnvHook } from "./non-interactive-env"; -export { createInteractiveBashSessionHook } from "./interactive-bash-session"; -export { createEmptyMessageSanitizerHook } from "./empty-message-sanitizer"; -export { createThinkingBlockValidatorHook } from "./thinking-block-validator"; +export { + type BeforeSummarizeCallback, + createPreemptiveCompactionHook, + type PreemptiveCompactionOptions, + type SummarizeContext, +} from "./preemptive-compaction"; export { createRalphLoopHook, type RalphLoopHook } from "./ralph-loop"; -export { createAutoSlashCommandHook } from "./auto-slash-command"; -export { createEditErrorRecoveryHook } from "./edit-error-recovery"; +export { createRulesInjectorHook } from "./rules-injector"; +export { createSessionNotification } from "./session-notification"; +export { + createSessionRecoveryHook, + type SessionRecoveryHook, + type SessionRecoveryOptions, +} from "./session-recovery"; +export { createThinkModeHook } from "./think-mode"; +export { createThinkingBlockValidatorHook } from "./thinking-block-validator"; +export { + createTodoContinuationEnforcer, + type TodoContinuationEnforcer, +} from "./todo-continuation-enforcer"; +export { createToolOutputTruncatorHook } from "./tool-output-truncator"; diff --git a/src/index.ts b/src/index.ts index df4e248fce..cfab608353 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,491 +1,506 @@ import type { Plugin } from "@opencode-ai/plugin"; -import { - createTodoContinuationEnforcer, - createContextWindowMonitorHook, - createSessionRecoveryHook, - createSessionNotification, - createCommentCheckerHooks, - createToolOutputTruncatorHook, - createDirectoryAgentsInjectorHook, - createDirectoryReadmeInjectorHook, - createEmptyTaskResponseDetectorHook, - createThinkModeHook, - createClaudeCodeHooksHook, - createAnthropicContextWindowLimitRecoveryHook, - createPreemptiveCompactionHook, - createCompactionContextInjector, - createRulesInjectorHook, - createBackgroundNotificationHook, - createAutoUpdateCheckerHook, - createKeywordDetectorHook, - createAgentUsageReminderHook, - createNonInteractiveEnvHook, - createInteractiveBashSessionHook, - createEmptyMessageSanitizerHook, - createThinkingBlockValidatorHook, - createRalphLoopHook, - createAutoSlashCommandHook, - createEditErrorRecoveryHook, -} from "./hooks"; -import { - contextCollector, - createContextInjectorHook, - createContextInjectorMessagesTransformHook, -} from "./features/context-injector"; import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity"; -import { - discoverUserClaudeSkills, - discoverProjectClaudeSkills, - discoverOpencodeGlobalSkills, - discoverOpencodeProjectSkills, - mergeSkills, -} from "./features/opencode-skill-loader"; +import type { HookName } from "./config"; +import { BackgroundManager } from "./features/background-agent"; import { createBuiltinSkills } from "./features/builtin-skills"; import { getSystemMcpServerNames } from "./features/claude-code-mcp-loader"; import { - setMainSession, - getMainSessionID, + getMainSessionID, + setMainSession, } from "./features/claude-code-session-state"; import { - builtinTools, - createCallOmoAgent, - createBackgroundTools, - createLookAt, - createSkillTool, - createSkillMcpTool, - createSlashcommandTool, - discoverCommandsSync, - sessionExists, - interactive_bash, - startTmuxCheck, -} from "./tools"; -import { BackgroundManager } from "./features/background-agent"; + contextCollector, + createContextInjectorHook, + createContextInjectorMessagesTransformHook, +} from "./features/context-injector"; +import { + discoverOpencodeGlobalSkills, + discoverOpencodeProjectSkills, + discoverProjectClaudeSkills, + discoverUserClaudeSkills, + mergeSkills, +} from "./features/opencode-skill-loader"; import { SkillMcpManager } from "./features/skill-mcp-manager"; -import { type HookName } from "./config"; -import { log } from "./shared"; +import { + createAgentUsageReminderHook, + createAnthropicContextWindowLimitRecoveryHook, + createAutoSlashCommandHook, + createAutoUpdateCheckerHook, + createBackgroundNotificationHook, + createClaudeCodeHooksHook, + createCommentCheckerHooks, + createCompactionContextInjector, + createContextWindowMonitorHook, + createDirectoryAgentsInjectorHook, + createDirectoryReadmeInjectorHook, + createEditErrorRecoveryHook, + createEmptyMessageSanitizerHook, + createEmptyTaskResponseDetectorHook, + createEnterpriseSecurityHook, + createInteractiveBashSessionHook, + createKeywordDetectorHook, + createNonInteractiveEnvHook, + createPreemptiveCompactionHook, + createRalphLoopHook, + createRulesInjectorHook, + createSessionNotification, + createSessionRecoveryHook, + createThinkingBlockValidatorHook, + createThinkModeHook, + createTodoContinuationEnforcer, + createToolOutputTruncatorHook, +} from "./hooks"; import { loadPluginConfig } from "./plugin-config"; -import { createModelCacheState, getModelLimit } from "./plugin-state"; import { createConfigHandler } from "./plugin-handlers"; +import { createModelCacheState, getModelLimit } from "./plugin-state"; +import { log } from "./shared"; +import { + builtinTools, + createBackgroundTools, + createCallOmoAgent, + createLookAt, + createSkillMcpTool, + createSkillTool, + createSlashcommandTool, + discoverCommandsSync, + interactive_bash, + sessionExists, + startTmuxCheck, +} from "./tools"; const OhMyOpenCodePlugin: Plugin = async (ctx) => { - // Start background tmux check immediately - startTmuxCheck(); - - const pluginConfig = loadPluginConfig(ctx.directory, ctx); - const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []); - const isHookEnabled = (hookName: HookName) => !disabledHooks.has(hookName); - - const modelCacheState = createModelCacheState(); - - const contextWindowMonitor = isHookEnabled("context-window-monitor") - ? createContextWindowMonitorHook(ctx) - : null; - const sessionRecovery = isHookEnabled("session-recovery") - ? createSessionRecoveryHook(ctx, { experimental: pluginConfig.experimental }) - : null; - const sessionNotification = isHookEnabled("session-notification") - ? createSessionNotification(ctx) - : null; - - const commentChecker = isHookEnabled("comment-checker") - ? createCommentCheckerHooks(pluginConfig.comment_checker) - : null; - const toolOutputTruncator = isHookEnabled("tool-output-truncator") - ? createToolOutputTruncatorHook(ctx, { - experimental: pluginConfig.experimental, - }) - : null; - const directoryAgentsInjector = isHookEnabled("directory-agents-injector") - ? createDirectoryAgentsInjectorHook(ctx) - : null; - const directoryReadmeInjector = isHookEnabled("directory-readme-injector") - ? createDirectoryReadmeInjectorHook(ctx) - : null; - const emptyTaskResponseDetector = isHookEnabled("empty-task-response-detector") - ? createEmptyTaskResponseDetectorHook(ctx) - : null; - const thinkMode = isHookEnabled("think-mode") ? createThinkModeHook() : null; - const claudeCodeHooks = createClaudeCodeHooksHook(ctx, { - disabledHooks: (pluginConfig.claude_code?.hooks ?? true) ? undefined : true, - }); - const anthropicContextWindowLimitRecovery = isHookEnabled( - "anthropic-context-window-limit-recovery" - ) - ? createAnthropicContextWindowLimitRecoveryHook(ctx, { - experimental: pluginConfig.experimental, - dcpForCompaction: pluginConfig.experimental?.dcp_for_compaction, - }) - : null; - const compactionContextInjector = isHookEnabled("compaction-context-injector") - ? createCompactionContextInjector() - : undefined; - const preemptiveCompaction = isHookEnabled("preemptive-compaction") - ? createPreemptiveCompactionHook(ctx, { - experimental: pluginConfig.experimental, - onBeforeSummarize: compactionContextInjector, - getModelLimit: (providerID, modelID) => - getModelLimit(modelCacheState, providerID, modelID), - }) - : null; - const rulesInjector = isHookEnabled("rules-injector") - ? createRulesInjectorHook(ctx) - : null; - const autoUpdateChecker = isHookEnabled("auto-update-checker") - ? createAutoUpdateCheckerHook(ctx, { - showStartupToast: isHookEnabled("startup-toast"), - isSisyphusEnabled: pluginConfig.sisyphus_agent?.disabled !== true, - autoUpdate: pluginConfig.auto_update ?? true, - }) - : null; - const keywordDetector = isHookEnabled("keyword-detector") - ? createKeywordDetectorHook(ctx) - : null; - const contextInjector = createContextInjectorHook(contextCollector); - const contextInjectorMessagesTransform = - createContextInjectorMessagesTransformHook(contextCollector); - const agentUsageReminder = isHookEnabled("agent-usage-reminder") - ? createAgentUsageReminderHook(ctx) - : null; - const nonInteractiveEnv = isHookEnabled("non-interactive-env") - ? createNonInteractiveEnvHook(ctx) - : null; - const interactiveBashSession = isHookEnabled("interactive-bash-session") - ? createInteractiveBashSessionHook(ctx) - : null; - const emptyMessageSanitizer = isHookEnabled("empty-message-sanitizer") - ? createEmptyMessageSanitizerHook() - : null; - const thinkingBlockValidator = isHookEnabled("thinking-block-validator") - ? createThinkingBlockValidatorHook() - : null; - - const ralphLoop = isHookEnabled("ralph-loop") - ? createRalphLoopHook(ctx, { - config: pluginConfig.ralph_loop, - checkSessionExists: async (sessionId) => sessionExists(sessionId), - }) - : null; - - const autoSlashCommand = isHookEnabled("auto-slash-command") - ? createAutoSlashCommandHook() - : null; - - const editErrorRecovery = isHookEnabled("edit-error-recovery") - ? createEditErrorRecoveryHook(ctx) - : null; - - const backgroundManager = new BackgroundManager(ctx); - - const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer") - ? createTodoContinuationEnforcer(ctx, { backgroundManager }) - : null; - - if (sessionRecovery && todoContinuationEnforcer) { - sessionRecovery.setOnAbortCallback(todoContinuationEnforcer.markRecovering); - sessionRecovery.setOnRecoveryCompleteCallback( - todoContinuationEnforcer.markRecoveryComplete - ); - } - - const backgroundNotificationHook = isHookEnabled("background-notification") - ? createBackgroundNotificationHook(backgroundManager) - : null; - const backgroundTools = createBackgroundTools(backgroundManager, ctx.client); - - const callOmoAgent = createCallOmoAgent(ctx, backgroundManager); - const lookAt = createLookAt(ctx); - const disabledSkills = new Set(pluginConfig.disabled_skills ?? []); - const systemMcpNames = getSystemMcpServerNames(); - const builtinSkills = createBuiltinSkills().filter((skill) => { - if (disabledSkills.has(skill.name as never)) return false; - if (skill.mcpConfig) { - for (const mcpName of Object.keys(skill.mcpConfig)) { - if (systemMcpNames.has(mcpName)) return false; - } - } - return true; - }); - const includeClaudeSkills = pluginConfig.claude_code?.skills !== false; - const [userSkills, globalSkills, projectSkills, opencodeProjectSkills] = await Promise.all([ - includeClaudeSkills ? discoverUserClaudeSkills() : Promise.resolve([]), - discoverOpencodeGlobalSkills(), - includeClaudeSkills ? discoverProjectClaudeSkills() : Promise.resolve([]), - discoverOpencodeProjectSkills(), - ]); - const mergedSkills = mergeSkills( - builtinSkills, - pluginConfig.skills, - userSkills, - globalSkills, - projectSkills, - opencodeProjectSkills - ); - const skillMcpManager = new SkillMcpManager(); - const getSessionIDForMcp = () => getMainSessionID() || ""; - const skillTool = createSkillTool({ - skills: mergedSkills, - mcpManager: skillMcpManager, - getSessionID: getSessionIDForMcp, - }); - const skillMcpTool = createSkillMcpTool({ - manager: skillMcpManager, - getLoadedSkills: () => mergedSkills, - getSessionID: getSessionIDForMcp, - }); - - const commands = discoverCommandsSync(); - const slashcommandTool = createSlashcommandTool({ - commands, - skills: mergedSkills, - }); - - const googleAuthHooks = pluginConfig.google_auth !== false - ? await createGoogleAntigravityAuthPlugin(ctx) - : null; - - const configHandler = createConfigHandler({ - ctx, - pluginConfig, - modelCacheState, - }); - - return { - ...(googleAuthHooks ? { auth: googleAuthHooks.auth } : {}), - - tool: { - ...builtinTools, - ...backgroundTools, - call_omo_agent: callOmoAgent, - look_at: lookAt, - skill: skillTool, - skill_mcp: skillMcpTool, - slashcommand: slashcommandTool, - interactive_bash, - }, - - "chat.message": async (input, output) => { - await claudeCodeHooks["chat.message"]?.(input, output); - await keywordDetector?.["chat.message"]?.(input, output); - await contextInjector["chat.message"]?.(input, output); - await autoSlashCommand?.["chat.message"]?.(input, output); - - if (ralphLoop) { - const parts = ( - output as { parts?: Array<{ type: string; text?: string }> } - ).parts; - const promptText = - parts - ?.filter((p) => p.type === "text" && p.text) - .map((p) => p.text) - .join("\n") - .trim() || ""; - - const isRalphLoopTemplate = - promptText.includes("You are starting a Ralph Loop") && - promptText.includes(""); - const isCancelRalphTemplate = promptText.includes( - "Cancel the currently active Ralph Loop" - ); - - if (isRalphLoopTemplate) { - const taskMatch = promptText.match( - /\s*([\s\S]*?)\s*<\/user-task>/i - ); - const rawTask = taskMatch?.[1]?.trim() || ""; - - const quotedMatch = rawTask.match(/^["'](.+?)["']/); - const prompt = - quotedMatch?.[1] || - rawTask.split(/\s+--/)[0]?.trim() || - "Complete the task as instructed"; - - const maxIterMatch = rawTask.match(/--max-iterations=(\d+)/i); - const promiseMatch = rawTask.match( - /--completion-promise=["']?([^"'\s]+)["']?/i - ); - - log("[ralph-loop] Starting loop from chat.message", { - sessionID: input.sessionID, - prompt, - }); - ralphLoop.startLoop(input.sessionID, prompt, { - maxIterations: maxIterMatch - ? parseInt(maxIterMatch[1], 10) - : undefined, - completionPromise: promiseMatch?.[1], - }); - } else if (isCancelRalphTemplate) { - log("[ralph-loop] Cancelling loop from chat.message", { - sessionID: input.sessionID, - }); - ralphLoop.cancelLoop(input.sessionID); - } - } - }, - - "experimental.chat.messages.transform": async ( - input: Record, - output: { messages: Array<{ info: unknown; parts: unknown[] }> } - ) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await contextInjectorMessagesTransform?.["experimental.chat.messages.transform"]?.(input, output as any); - await thinkingBlockValidator?.[ - "experimental.chat.messages.transform" - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ]?.(input, output as any); - await emptyMessageSanitizer?.[ - "experimental.chat.messages.transform" - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ]?.(input, output as any); - }, - - config: configHandler, - - event: async (input) => { - await autoUpdateChecker?.event(input); - await claudeCodeHooks.event(input); - await backgroundNotificationHook?.event(input); - await sessionNotification?.(input); - await todoContinuationEnforcer?.handler(input); - await contextWindowMonitor?.event(input); - await directoryAgentsInjector?.event(input); - await directoryReadmeInjector?.event(input); - await rulesInjector?.event(input); - await thinkMode?.event(input); - await anthropicContextWindowLimitRecovery?.event(input); - await preemptiveCompaction?.event(input); - await agentUsageReminder?.event(input); - await interactiveBashSession?.event(input); - await ralphLoop?.event(input); - - const { event } = input; - const props = event.properties as Record | undefined; - - if (event.type === "session.created") { - const sessionInfo = props?.info as - | { id?: string; title?: string; parentID?: string } - | undefined; - if (!sessionInfo?.parentID) { - setMainSession(sessionInfo?.id); - } - } - - if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined; - if (sessionInfo?.id === getMainSessionID()) { - setMainSession(undefined); - } - if (sessionInfo?.id) { - await skillMcpManager.disconnectSession(sessionInfo.id); - } - } - - if (event.type === "session.error") { - const sessionID = props?.sessionID as string | undefined; - const error = props?.error; - - if (sessionRecovery?.isRecoverableError(error)) { - const messageInfo = { - id: props?.messageID as string | undefined, - role: "assistant" as const, - sessionID, - error, - }; - const recovered = - await sessionRecovery.handleSessionRecovery(messageInfo); - - if (recovered && sessionID && sessionID === getMainSessionID()) { - await ctx.client.session - .prompt({ - path: { id: sessionID }, - body: { parts: [{ type: "text", text: "continue" }] }, - query: { directory: ctx.directory }, - }) - .catch(() => {}); - } - } - } - }, - - "tool.execute.before": async (input, output) => { - await claudeCodeHooks["tool.execute.before"](input, output); - await nonInteractiveEnv?.["tool.execute.before"](input, output); - await commentChecker?.["tool.execute.before"](input, output); - await directoryAgentsInjector?.["tool.execute.before"]?.(input, output); - await directoryReadmeInjector?.["tool.execute.before"]?.(input, output); - await rulesInjector?.["tool.execute.before"]?.(input, output); - - if (input.tool === "task") { - const args = output.args as Record; - const subagentType = args.subagent_type as string; - const isExploreOrLibrarian = ["explore", "librarian"].includes( - subagentType - ); - - args.tools = { - ...(args.tools as Record | undefined), - background_task: false, - ...(isExploreOrLibrarian ? { call_omo_agent: false } : {}), - }; - } - - if (ralphLoop && input.tool === "slashcommand") { - const args = output.args as { command?: string } | undefined; - const command = args?.command?.replace(/^\//, "").toLowerCase(); - const sessionID = input.sessionID || getMainSessionID(); - - if (command === "ralph-loop" && sessionID) { - const rawArgs = - args?.command?.replace(/^\/?(ralph-loop)\s*/i, "") || ""; - const taskMatch = rawArgs.match(/^["'](.+?)["']/); - const prompt = - taskMatch?.[1] || - rawArgs.split(/\s+--/)[0]?.trim() || - "Complete the task as instructed"; - - const maxIterMatch = rawArgs.match(/--max-iterations=(\d+)/i); - const promiseMatch = rawArgs.match( - /--completion-promise=["']?([^"'\s]+)["']?/i - ); - - ralphLoop.startLoop(sessionID, prompt, { - maxIterations: maxIterMatch - ? parseInt(maxIterMatch[1], 10) - : undefined, - completionPromise: promiseMatch?.[1], - }); - } else if (command === "cancel-ralph" && sessionID) { - ralphLoop.cancelLoop(sessionID); - } - } - }, - - "tool.execute.after": async (input, output) => { - await claudeCodeHooks["tool.execute.after"](input, output); - await toolOutputTruncator?.["tool.execute.after"](input, output); - await contextWindowMonitor?.["tool.execute.after"](input, output); - await commentChecker?.["tool.execute.after"](input, output); - await directoryAgentsInjector?.["tool.execute.after"](input, output); - await directoryReadmeInjector?.["tool.execute.after"](input, output); - await rulesInjector?.["tool.execute.after"](input, output); - await emptyTaskResponseDetector?.["tool.execute.after"](input, output); - await agentUsageReminder?.["tool.execute.after"](input, output); - await interactiveBashSession?.["tool.execute.after"](input, output); - await editErrorRecovery?.["tool.execute.after"](input, output); - }, - }; + // Start background tmux check immediately + startTmuxCheck(); + + const pluginConfig = loadPluginConfig(ctx.directory, ctx); + const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []); + const isHookEnabled = (hookName: HookName) => !disabledHooks.has(hookName); + + const modelCacheState = createModelCacheState(); + + const contextWindowMonitor = isHookEnabled("context-window-monitor") + ? createContextWindowMonitorHook(ctx) + : null; + const sessionRecovery = isHookEnabled("session-recovery") + ? createSessionRecoveryHook(ctx, { + experimental: pluginConfig.experimental, + }) + : null; + const sessionNotification = isHookEnabled("session-notification") + ? createSessionNotification(ctx) + : null; + + const commentChecker = isHookEnabled("comment-checker") + ? createCommentCheckerHooks(pluginConfig.comment_checker) + : null; + const toolOutputTruncator = isHookEnabled("tool-output-truncator") + ? createToolOutputTruncatorHook(ctx, { + experimental: pluginConfig.experimental, + }) + : null; + const directoryAgentsInjector = isHookEnabled("directory-agents-injector") + ? createDirectoryAgentsInjectorHook(ctx) + : null; + const directoryReadmeInjector = isHookEnabled("directory-readme-injector") + ? createDirectoryReadmeInjectorHook(ctx) + : null; + const emptyTaskResponseDetector = isHookEnabled( + "empty-task-response-detector", + ) + ? createEmptyTaskResponseDetectorHook(ctx) + : null; + const thinkMode = isHookEnabled("think-mode") ? createThinkModeHook() : null; + const claudeCodeHooks = createClaudeCodeHooksHook(ctx, { + disabledHooks: (pluginConfig.claude_code?.hooks ?? true) ? undefined : true, + }); + const anthropicContextWindowLimitRecovery = isHookEnabled( + "anthropic-context-window-limit-recovery", + ) + ? createAnthropicContextWindowLimitRecoveryHook(ctx, { + experimental: pluginConfig.experimental, + dcpForCompaction: pluginConfig.experimental?.dcp_for_compaction, + }) + : null; + const compactionContextInjector = isHookEnabled("compaction-context-injector") + ? createCompactionContextInjector() + : undefined; + const preemptiveCompaction = isHookEnabled("preemptive-compaction") + ? createPreemptiveCompactionHook(ctx, { + experimental: pluginConfig.experimental, + onBeforeSummarize: compactionContextInjector, + getModelLimit: (providerID, modelID) => + getModelLimit(modelCacheState, providerID, modelID), + }) + : null; + const rulesInjector = isHookEnabled("rules-injector") + ? createRulesInjectorHook(ctx) + : null; + const autoUpdateChecker = isHookEnabled("auto-update-checker") + ? createAutoUpdateCheckerHook(ctx, { + showStartupToast: isHookEnabled("startup-toast"), + isSisyphusEnabled: pluginConfig.sisyphus_agent?.disabled !== true, + autoUpdate: pluginConfig.auto_update ?? true, + }) + : null; + const keywordDetector = isHookEnabled("keyword-detector") + ? createKeywordDetectorHook(ctx) + : null; + const contextInjector = createContextInjectorHook(contextCollector); + const contextInjectorMessagesTransform = + createContextInjectorMessagesTransformHook(contextCollector); + const agentUsageReminder = isHookEnabled("agent-usage-reminder") + ? createAgentUsageReminderHook(ctx) + : null; + const nonInteractiveEnv = isHookEnabled("non-interactive-env") + ? createNonInteractiveEnvHook(ctx) + : null; + const interactiveBashSession = isHookEnabled("interactive-bash-session") + ? createInteractiveBashSessionHook(ctx) + : null; + const emptyMessageSanitizer = isHookEnabled("empty-message-sanitizer") + ? createEmptyMessageSanitizerHook() + : null; + const thinkingBlockValidator = isHookEnabled("thinking-block-validator") + ? createThinkingBlockValidatorHook() + : null; + + const ralphLoop = isHookEnabled("ralph-loop") + ? createRalphLoopHook(ctx, { + config: pluginConfig.ralph_loop, + checkSessionExists: async (sessionId) => sessionExists(sessionId), + }) + : null; + + const autoSlashCommand = isHookEnabled("auto-slash-command") + ? createAutoSlashCommandHook() + : null; + + const editErrorRecovery = isHookEnabled("edit-error-recovery") + ? createEditErrorRecoveryHook(ctx) + : null; + + const enterpriseSecurity = isHookEnabled("enterprise-security") + ? createEnterpriseSecurityHook(ctx) + : null; + + const backgroundManager = new BackgroundManager(ctx); + + const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer") + ? createTodoContinuationEnforcer(ctx, { backgroundManager }) + : null; + + if (sessionRecovery && todoContinuationEnforcer) { + sessionRecovery.setOnAbortCallback(todoContinuationEnforcer.markRecovering); + sessionRecovery.setOnRecoveryCompleteCallback( + todoContinuationEnforcer.markRecoveryComplete, + ); + } + + const backgroundNotificationHook = isHookEnabled("background-notification") + ? createBackgroundNotificationHook(backgroundManager) + : null; + const backgroundTools = createBackgroundTools(backgroundManager, ctx.client); + + const callOmoAgent = createCallOmoAgent(ctx, backgroundManager); + const lookAt = createLookAt(ctx); + const disabledSkills = new Set(pluginConfig.disabled_skills ?? []); + const systemMcpNames = getSystemMcpServerNames(); + const builtinSkills = createBuiltinSkills().filter((skill) => { + if (disabledSkills.has(skill.name as never)) return false; + if (skill.mcpConfig) { + for (const mcpName of Object.keys(skill.mcpConfig)) { + if (systemMcpNames.has(mcpName)) return false; + } + } + return true; + }); + const includeClaudeSkills = pluginConfig.claude_code?.skills !== false; + const [userSkills, globalSkills, projectSkills, opencodeProjectSkills] = + await Promise.all([ + includeClaudeSkills ? discoverUserClaudeSkills() : Promise.resolve([]), + discoverOpencodeGlobalSkills(), + includeClaudeSkills ? discoverProjectClaudeSkills() : Promise.resolve([]), + discoverOpencodeProjectSkills(), + ]); + const mergedSkills = mergeSkills( + builtinSkills, + pluginConfig.skills, + userSkills, + globalSkills, + projectSkills, + opencodeProjectSkills, + ); + const skillMcpManager = new SkillMcpManager(); + const getSessionIDForMcp = () => getMainSessionID() || ""; + const skillTool = createSkillTool({ + skills: mergedSkills, + mcpManager: skillMcpManager, + getSessionID: getSessionIDForMcp, + }); + const skillMcpTool = createSkillMcpTool({ + manager: skillMcpManager, + getLoadedSkills: () => mergedSkills, + getSessionID: getSessionIDForMcp, + }); + + const commands = discoverCommandsSync(); + const slashcommandTool = createSlashcommandTool({ + commands, + skills: mergedSkills, + }); + + const googleAuthHooks = + pluginConfig.google_auth !== false + ? await createGoogleAntigravityAuthPlugin(ctx) + : null; + + const configHandler = createConfigHandler({ + ctx, + pluginConfig, + modelCacheState, + }); + + return { + ...(googleAuthHooks ? { auth: googleAuthHooks.auth } : {}), + + tool: { + ...builtinTools, + ...backgroundTools, + call_omo_agent: callOmoAgent, + look_at: lookAt, + skill: skillTool, + skill_mcp: skillMcpTool, + slashcommand: slashcommandTool, + interactive_bash, + }, + + "chat.message": async (input, output) => { + await claudeCodeHooks["chat.message"]?.(input, output); + await keywordDetector?.["chat.message"]?.(input, output); + await contextInjector["chat.message"]?.(input, output); + await autoSlashCommand?.["chat.message"]?.(input, output); + + if (ralphLoop) { + const parts = ( + output as { parts?: Array<{ type: string; text?: string }> } + ).parts; + const promptText = + parts + ?.filter((p) => p.type === "text" && p.text) + .map((p) => p.text) + .join("\n") + .trim() || ""; + + const isRalphLoopTemplate = + promptText.includes("You are starting a Ralph Loop") && + promptText.includes(""); + const isCancelRalphTemplate = promptText.includes( + "Cancel the currently active Ralph Loop", + ); + + if (isRalphLoopTemplate) { + const taskMatch = promptText.match( + /\s*([\s\S]*?)\s*<\/user-task>/i, + ); + const rawTask = taskMatch?.[1]?.trim() || ""; + + const quotedMatch = rawTask.match(/^["'](.+?)["']/); + const prompt = + quotedMatch?.[1] || + rawTask.split(/\s+--/)[0]?.trim() || + "Complete the task as instructed"; + + const maxIterMatch = rawTask.match(/--max-iterations=(\d+)/i); + const promiseMatch = rawTask.match( + /--completion-promise=["']?([^"'\s]+)["']?/i, + ); + + log("[ralph-loop] Starting loop from chat.message", { + sessionID: input.sessionID, + prompt, + }); + ralphLoop.startLoop(input.sessionID, prompt, { + maxIterations: maxIterMatch + ? parseInt(maxIterMatch[1], 10) + : undefined, + completionPromise: promiseMatch?.[1], + }); + } else if (isCancelRalphTemplate) { + log("[ralph-loop] Cancelling loop from chat.message", { + sessionID: input.sessionID, + }); + ralphLoop.cancelLoop(input.sessionID); + } + } + }, + + "experimental.chat.messages.transform": async ( + input: Record, + output: { messages: Array<{ info: unknown; parts: unknown[] }> }, + ) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await contextInjectorMessagesTransform?.[ + "experimental.chat.messages.transform" + ]?.(input, output as any); + await thinkingBlockValidator?.[ + "experimental.chat.messages.transform" + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ]?.(input, output as any); + await emptyMessageSanitizer?.[ + "experimental.chat.messages.transform" + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ]?.(input, output as any); + }, + + config: configHandler, + + event: async (input) => { + await autoUpdateChecker?.event(input); + await claudeCodeHooks.event(input); + await backgroundNotificationHook?.event(input); + await sessionNotification?.(input); + await todoContinuationEnforcer?.handler(input); + await contextWindowMonitor?.event(input); + await directoryAgentsInjector?.event(input); + await directoryReadmeInjector?.event(input); + await rulesInjector?.event(input); + await thinkMode?.event(input); + await anthropicContextWindowLimitRecovery?.event(input); + await preemptiveCompaction?.event(input); + await agentUsageReminder?.event(input); + await interactiveBashSession?.event(input); + await ralphLoop?.event(input); + + const { event } = input; + const props = event.properties as Record | undefined; + + if (event.type === "session.created") { + const sessionInfo = props?.info as + | { id?: string; title?: string; parentID?: string } + | undefined; + if (!sessionInfo?.parentID) { + setMainSession(sessionInfo?.id); + } + } + + if (event.type === "session.deleted") { + const sessionInfo = props?.info as { id?: string } | undefined; + if (sessionInfo?.id === getMainSessionID()) { + setMainSession(undefined); + } + if (sessionInfo?.id) { + await skillMcpManager.disconnectSession(sessionInfo.id); + } + } + + if (event.type === "session.error") { + const sessionID = props?.sessionID as string | undefined; + const error = props?.error; + + if (sessionRecovery?.isRecoverableError(error)) { + const messageInfo = { + id: props?.messageID as string | undefined, + role: "assistant" as const, + sessionID, + error, + }; + const recovered = + await sessionRecovery.handleSessionRecovery(messageInfo); + + if (recovered && sessionID && sessionID === getMainSessionID()) { + await ctx.client.session + .prompt({ + path: { id: sessionID }, + body: { parts: [{ type: "text", text: "continue" }] }, + query: { directory: ctx.directory }, + }) + .catch(() => {}); + } + } + } + }, + + "tool.execute.before": async (input, output) => { + await claudeCodeHooks["tool.execute.before"](input, output); + await nonInteractiveEnv?.["tool.execute.before"](input, output); + await enterpriseSecurity?.["tool.execute.before"]?.(input, output); + await commentChecker?.["tool.execute.before"](input, output); + await directoryAgentsInjector?.["tool.execute.before"]?.(input, output); + await directoryReadmeInjector?.["tool.execute.before"]?.(input, output); + await rulesInjector?.["tool.execute.before"]?.(input, output); + + if (input.tool === "task") { + const args = output.args as Record; + const subagentType = args.subagent_type as string; + const isExploreOrLibrarian = ["explore", "librarian"].includes( + subagentType, + ); + + args.tools = { + ...(args.tools as Record | undefined), + background_task: false, + ...(isExploreOrLibrarian ? { call_omo_agent: false } : {}), + }; + } + + if (ralphLoop && input.tool === "slashcommand") { + const args = output.args as { command?: string } | undefined; + const command = args?.command?.replace(/^\//, "").toLowerCase(); + const sessionID = input.sessionID || getMainSessionID(); + + if (command === "ralph-loop" && sessionID) { + const rawArgs = + args?.command?.replace(/^\/?(ralph-loop)\s*/i, "") || ""; + const taskMatch = rawArgs.match(/^["'](.+?)["']/); + const prompt = + taskMatch?.[1] || + rawArgs.split(/\s+--/)[0]?.trim() || + "Complete the task as instructed"; + + const maxIterMatch = rawArgs.match(/--max-iterations=(\d+)/i); + const promiseMatch = rawArgs.match( + /--completion-promise=["']?([^"'\s]+)["']?/i, + ); + + ralphLoop.startLoop(sessionID, prompt, { + maxIterations: maxIterMatch + ? parseInt(maxIterMatch[1], 10) + : undefined, + completionPromise: promiseMatch?.[1], + }); + } else if (command === "cancel-ralph" && sessionID) { + ralphLoop.cancelLoop(sessionID); + } + } + }, + + "tool.execute.after": async (input, output) => { + await claudeCodeHooks["tool.execute.after"](input, output); + await toolOutputTruncator?.["tool.execute.after"](input, output); + await contextWindowMonitor?.["tool.execute.after"](input, output); + await enterpriseSecurity?.["tool.execute.after"]?.(input, output); + await commentChecker?.["tool.execute.after"](input, output); + await directoryAgentsInjector?.["tool.execute.after"](input, output); + await directoryReadmeInjector?.["tool.execute.after"](input, output); + await rulesInjector?.["tool.execute.after"](input, output); + await emptyTaskResponseDetector?.["tool.execute.after"](input, output); + await agentUsageReminder?.["tool.execute.after"](input, output); + await interactiveBashSession?.["tool.execute.after"](input, output); + await editErrorRecovery?.["tool.execute.after"](input, output); + }, + }; }; export default OhMyOpenCodePlugin; export type { - OhMyOpenCodeConfig, - AgentName, - AgentOverrideConfig, - AgentOverrides, - McpName, - HookName, - BuiltinCommandName, + AgentName, + AgentOverrideConfig, + AgentOverrides, + BuiltinCommandName, + HookName, + McpName, + OhMyOpenCodeConfig, } from "./config"; // NOTE: Do NOT export functions from main index.ts!