diff --git a/README.md b/README.md index c57811a0..1fd89bc5 100644 --- a/README.md +++ b/README.md @@ -269,7 +269,7 @@ Active every session, with a handful of commands (see [Commands](#commands)). `/ Set the level for every new session with the `PONYTAIL_DEFAULT_MODE` env var (`lite`/`full`/`ultra`/`off`), or a `defaultMode` field in `~/.config/ponytail/config.json` (`%APPDATA%\ponytail\config.json` on Windows). The default is `full`. -While active, the ruleset is also injected into every subagent spawned via the Agent tool. To scope that to specific agent types (say, keep it off read-only search agents), set the `PONYTAIL_SUBAGENT_MATCHER` env var to a regex tested against the subagent's `agent_type`. It is unanchored and case-insensitive: `explore|general` matches either, `^general$` is exact, and plugin agent types look like `plugin:name`. Unset means inject into every subagent (the default); an invalid regex, or a subagent whose type the platform doesn't report, also falls back to injecting. +While active, the ruleset is also injected into every subagent spawned via the Agent tool. To scope that to specific agent types (say, keep it off read-only search agents), set the `PONYTAIL_SUBAGENT_MATCHER` env var to a regex tested against the subagent's `agent_type`. It is unanchored and case-insensitive: `explore|general` matches either, `^general$` is exact, and plugin agent types look like `plugin:name`. Unset means inject into every subagent (the default); an invalid regex, a pattern over 256 characters, or a subagent whose type the platform doesn't report, also falls back to injecting, with a warning on stderr. Cursor, Windsurf, Cline, GitHub Copilot Chat (the VS Code, JetBrains, and Visual Studio editor extension, not the standalone Copilot CLI covered under [Install](#install)), Aider, Kiro, Zed, CodeWhale, Swival, Qoder: copy the matching rules file from this repo ([`.cursor/rules/`](.cursor/rules/), [`.windsurf/rules/`](.windsurf/rules/), [`.clinerules/`](.clinerules/), [`.github/copilot-instructions.md`](.github/copilot-instructions.md), [`AGENTS.md`](AGENTS.md), [`.kiro/steering/`](.kiro/steering/), [`.qoder/rules/`](.qoder/rules/)). diff --git a/hooks/ponytail-subagent.js b/hooks/ponytail-subagent.js index f2a7c776..7b0ad4a7 100644 --- a/hooks/ponytail-subagent.js +++ b/hooks/ponytail-subagent.js @@ -29,12 +29,25 @@ function inject() { } // A bad regex must never crash the hook; treat it as "no matcher" and inject. +// The pattern is operator-controlled, so also cap it and warn on stderr +// (issue #658); a typo should be visible, not silently ignored. +const MAX_MATCHER_LEN = 256; +function warn(msg) { + // Stderr only; stdout is the hook payload and must stay valid JSON. + try { process.stderr.write('ponytail-subagent: ' + msg + '\n'); } catch (e) {} +} let matcherRe = null; try { - if (process.env.PONYTAIL_SUBAGENT_MATCHER) { - matcherRe = new RegExp(process.env.PONYTAIL_SUBAGENT_MATCHER, 'i'); + const pattern = process.env.PONYTAIL_SUBAGENT_MATCHER; + if (pattern) { + if (pattern.length > MAX_MATCHER_LEN) { + warn('PONYTAIL_SUBAGENT_MATCHER exceeds ' + MAX_MATCHER_LEN + ' chars; ignoring'); + } else { + matcherRe = new RegExp(pattern, 'i'); + } } } catch (e) { + warn('PONYTAIL_SUBAGENT_MATCHER is invalid (' + (e && e.message) + '); ignoring'); matcherRe = null; } diff --git a/tests/hooks.test.js b/tests/hooks.test.js index 44ee9222..470e52cf 100644 --- a/tests/hooks.test.js +++ b/tests/hooks.test.js @@ -335,13 +335,47 @@ assert.equal(result.status, 0, result.stderr); output = JSON.parse(result.stdout); assert.match(output.hookSpecificOutput.additionalContext, /PONYTAIL MODE ACTIVE — level: full/); -// Invalid regex → must not crash; fall back to injecting everywhere. +// Invalid regex → must not crash; fall back to injecting everywhere, with a +// warning on stderr (issue #658). result = run( 'ponytail-subagent.js', { ...scopeEnv, PONYTAIL_SUBAGENT_MATCHER: '(' }, JSON.stringify({ agent_type: 'anything' }), ); assert.equal(result.status, 0, result.stderr); +assert.ok(/PONYTAIL_SUBAGENT_MATCHER is invalid/.test(result.stderr), 'bad pattern must warn on stderr'); +output = JSON.parse(result.stdout); +assert.equal(output.hookSpecificOutput.hookEventName, 'SubagentStart'); + +// Overlong pattern → rejected with a warning; falls back to injecting. +result = run( + 'ponytail-subagent.js', + { ...scopeEnv, PONYTAIL_SUBAGENT_MATCHER: 'a'.repeat(300) }, + JSON.stringify({ agent_type: 'anything' }), +); +assert.equal(result.status, 0, result.stderr); +assert.ok(/exceeds 256 chars/.test(result.stderr), 'overlong pattern must warn on stderr'); +output = JSON.parse(result.stdout); +assert.equal(output.hookSpecificOutput.hookEventName, 'SubagentStart'); + +// The length cap is inclusive at 256 characters. +result = run( + 'ponytail-subagent.js', + { ...scopeEnv, PONYTAIL_SUBAGENT_MATCHER: 'a'.repeat(256) }, + JSON.stringify({ agent_type: 'a'.repeat(256) }), +); +assert.equal(result.status, 0, result.stderr); +assert.equal(result.stderr, '', '256-char matcher is valid and must not warn'); +output = JSON.parse(result.stdout); +assert.equal(output.hookSpecificOutput.hookEventName, 'SubagentStart'); + +result = run( + 'ponytail-subagent.js', + { ...scopeEnv, PONYTAIL_SUBAGENT_MATCHER: 'a'.repeat(257) }, + JSON.stringify({ agent_type: 'a'.repeat(257) }), +); +assert.equal(result.status, 0, result.stderr); +assert.ok(/exceeds 256 chars/.test(result.stderr), '257-char matcher must warn'); output = JSON.parse(result.stdout); assert.equal(output.hookSpecificOutput.hookEventName, 'SubagentStart');