chill-agent 1.4.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/rules/ponytail.md +30 -0
- package/.claude-plugin/contrast-mcp-launcher.mjs +31 -0
- package/.claude-plugin/marketplace.json +17 -0
- package/.claude-plugin/plugin.json +8 -3
- package/.cline-plugin/plugin.js +6 -0
- package/.clinerules/ponytail.md +30 -0
- package/.codex-plugin/plugin.json +4 -3
- package/.cursor/rules/ponytail.mdc +36 -0
- package/.cursor-plugin/marketplace.json +16 -0
- package/.cursor-plugin/plugin.json +2 -2
- package/.devin-plugin/plugin.json +2 -2
- package/.grok-plugin/marketplace.json +16 -0
- package/.kimi-plugin/plugin.json +11 -29
- package/.kiro/steering/ponytail.md +35 -0
- package/.omp-plugin/marketplace.json +15 -0
- package/.openclaw/skills/ponytail/SKILL.md +47 -0
- package/.openclaw/skills/ponytail-audit/SKILL.md +100 -0
- package/.openclaw/skills/ponytail-debt/SKILL.md +43 -0
- package/.openclaw/skills/ponytail-gain/SKILL.md +51 -0
- package/.openclaw/skills/ponytail-help/SKILL.md +76 -0
- package/.openclaw/skills/ponytail-review/SKILL.md +98 -0
- package/.opencode/command/ponytail-audit.md +5 -0
- package/.opencode/command/ponytail-debt.md +5 -0
- package/.opencode/command/ponytail-gain.md +5 -0
- package/.opencode/command/ponytail-help.md +5 -0
- package/.opencode/command/ponytail-review.md +5 -0
- package/.opencode/command/ponytail.md +5 -0
- package/.opencode/plugins/index.js +4 -0
- package/.opencode/plugins/ponytail-frontmatter.cjs +63 -0
- package/.opencode/plugins/ponytail.mjs +206 -0
- package/.qoder-plugin/plugin.json +12 -26
- package/.windsurf/rules/ponytail.md +30 -0
- package/AGENTS.md +32 -0
- package/__init__.py +228 -0
- package/assets/antislop-banner.png +0 -0
- package/assets/band/band.png +0 -0
- package/assets/band/board.png +0 -0
- package/assets/benchmark-3model.svg +21 -0
- package/assets/benchmark-agentic.svg +62 -0
- package/assets/codex-icon.png +0 -0
- package/assets/compare/code/after.webp +0 -0
- package/assets/compare/code/before.webp +0 -0
- package/assets/compare/text/after.webp +0 -0
- package/assets/compare/text/before.webp +0 -0
- package/assets/compare/ui/w-all.webp +0 -0
- package/assets/compare/ui/w-antislop.webp +0 -0
- package/assets/compare/ui/w-design.webp +0 -0
- package/assets/compare/ui/without.webp +0 -0
- package/assets/logo-dark.png +0 -0
- package/assets/logo-dark.svg +115 -0
- package/assets/logo-greenpt-dark.svg +27 -0
- package/assets/logo-greenpt.svg +27 -0
- package/assets/logo.png +0 -0
- package/assets/photoreunion-logo-dark.svg +4 -0
- package/assets/photoreunion-logo-light.svg +4 -0
- package/assets/profile.png +0 -0
- package/assets/retriever-logo-dark.svg +4 -0
- package/assets/retriever-logo-light.svg +4 -0
- package/assets/stories-banner.png +0 -0
- package/assets/v5/audit-es.png +0 -0
- package/assets/v5/audit-ja.png +0 -0
- package/assets/v5/audit-ko.png +0 -0
- package/assets/v5/audit-zh-CN.png +0 -0
- package/assets/v5/audit.png +0 -0
- package/assets/v5/beforeafter-es.png +0 -0
- package/assets/v5/beforeafter-ja.png +0 -0
- package/assets/v5/beforeafter-ko.png +0 -0
- package/assets/v5/beforeafter-zh-CN.png +0 -0
- package/assets/v5/beforeafter.png +0 -0
- package/assets/v5/chart-es.png +0 -0
- package/assets/v5/chart-ja.png +0 -0
- package/assets/v5/chart-ko.png +0 -0
- package/assets/v5/chart-zh-CN.png +0 -0
- package/assets/v5/chart.png +0 -0
- package/assets/v5/hero-es.jpg +0 -0
- package/assets/v5/hero-ja.jpg +0 -0
- package/assets/v5/hero-ko.jpg +0 -0
- package/assets/v5/hero-zh-CN.jpg +0 -0
- package/assets/v5/hero.jpg +0 -0
- package/assets/v5/ladder-es.png +0 -0
- package/assets/v5/ladder-ja.png +0 -0
- package/assets/v5/ladder-ko.png +0 -0
- package/assets/v5/ladder-zh-CN.png +0 -0
- package/assets/v5/ladder.png +0 -0
- package/assets/v5/review-es.png +0 -0
- package/assets/v5/review-ja.png +0 -0
- package/assets/v5/review-ko.png +0 -0
- package/assets/v5/review-zh-CN.png +0 -0
- package/assets/v5/review.png +0 -0
- package/assets/v5/tests-es.png +0 -0
- package/assets/v5/tests-ja.png +0 -0
- package/assets/v5/tests-ko.png +0 -0
- package/assets/v5/tests-zh-CN.png +0 -0
- package/assets/v5/tests.png +0 -0
- package/assets/waitlist-banner-es.png +0 -0
- package/assets/waitlist-banner-ja.png +0 -0
- package/assets/waitlist-banner-ko.png +0 -0
- package/assets/waitlist-banner-zh-CN.png +0 -0
- package/assets/waitlist-banner.png +0 -0
- package/benchmarks/.env.example +2 -0
- package/benchmarks/agentic/README.md +201 -0
- package/benchmarks/agentic/answer.py +288 -0
- package/benchmarks/agentic/compare.py +93 -0
- package/benchmarks/agentic/complete.py +178 -0
- package/benchmarks/agentic/judge.py +198 -0
- package/benchmarks/agentic/run.py +543 -0
- package/benchmarks/agentic/tasks.py +1544 -0
- package/benchmarks/arms/baseline.js +2 -0
- package/benchmarks/arms/caveman-SKILL.md +67 -0
- package/benchmarks/arms/caveman.js +8 -0
- package/benchmarks/arms/ponytail.js +8 -0
- package/benchmarks/behavior.js +54 -0
- package/benchmarks/behavior.yaml +40 -0
- package/benchmarks/correctness.js +291 -0
- package/benchmarks/loc.js +17 -19
- package/benchmarks/promptfooconfig.gemini.yaml +32 -0
- package/benchmarks/promptfooconfig.gpt-newest.yaml +33 -0
- package/benchmarks/promptfooconfig.yaml +41 -0
- package/benchmarks/results/2026-06-12-caveman-vs-ponytail.md +71 -0
- package/benchmarks/results/2026-06-12-v4-hardening-vs-caveman.md +125 -0
- package/benchmarks/results/2026-06-15-llama3.2-local.md +82 -0
- package/benchmarks/results/2026-06-16-correctness-gate-fix.md +113 -0
- package/benchmarks/results/2026-06-16-robustness-audit.md +135 -0
- package/benchmarks/results/2026-06-17-agentic-safety.md +164 -0
- package/benchmarks/results/2026-06-17-cost-verification.md +93 -0
- package/benchmarks/results/2026-06-18-agentic.md +219 -0
- package/benchmarks/results/2026-06-22-issue-245-217-comprehension.md +98 -0
- package/benchmarks/results/2026-10-07-agentic.md +186 -0
- package/cli/index.mjs +241 -0
- package/cli/lib/banner.mjs +21 -0
- package/cli/lib/install.mjs +498 -0
- package/cli/lib/settings.mjs +48 -0
- package/cli/package-lock.json +87 -0
- package/cli/package.json +42 -0
- package/cli/scripts/smoke-test.mjs +95 -0
- package/cli/scripts/smoke-worker.mjs +411 -0
- package/cli/scripts/sync-skills.mjs +81 -0
- package/commands/ponytail-audit.toml +2 -0
- package/commands/ponytail-debt.toml +2 -0
- package/commands/ponytail-gain.toml +2 -0
- package/commands/ponytail-help.toml +2 -0
- package/commands/ponytail-review.toml +2 -0
- package/commands/ponytail.toml +2 -0
- package/examples/email-validation.md +156 -0
- package/examples/group-by.md +35 -0
- package/examples/infinite-scroll.md +58 -0
- package/examples/number-formatting.md +37 -0
- package/examples/rate-limit.md +272 -0
- package/examples/react-countdown.md +390 -0
- package/examples/url-params.md +41 -0
- package/gemini-extension.json +2 -2
- package/hooks/band/board.ts +560 -0
- package/hooks/band/logo.ts +3 -0
- package/hooks/band/metrics.ts +311 -0
- package/hooks/band/register.tsx +495 -0
- package/hooks/band/types.d.ts +34 -0
- package/hooks/chill-statusline.ps1 +1 -1
- package/hooks/claude-band.json +6 -0
- package/hooks/claude-codex-hooks.json +34 -4
- package/hooks/copilot-hooks.json +12 -2
- package/hooks/cursor-hooks.json +2 -2
- package/hooks/ponytail-activate.js +201 -0
- package/hooks/ponytail-band-import.js +274 -0
- package/hooks/ponytail-config.js +175 -0
- package/hooks/ponytail-instructions.js +98 -0
- package/hooks/ponytail-map.js +111 -0
- package/hooks/ponytail-mode-tracker.js +216 -0
- package/hooks/ponytail-runtime.js +182 -0
- package/hooks/ponytail-statusline.ps1 +24 -0
- package/hooks/ponytail-statusline.sh +18 -0
- package/hooks/ponytail-subagent.js +94 -0
- package/hooks/qoder-hooks.json +19 -4
- package/i18n/README.ko.md +230 -0
- package/package.json +17 -6
- package/pi-extension/index.js +254 -0
- package/pi-extension/package.json +8 -0
- package/pi-extension/test/extension.test.js +367 -0
- package/pi-extension/test/helpers.test.js +179 -0
- package/plugin.yaml +21 -12
- package/rules/antislop.mdc +18 -0
- package/scripts/build-openclaw-skills.js +60 -0
- package/scripts/check-repo.mjs +389 -0
- package/scripts/check-rule-copies.js +74 -0
- package/scripts/check-versions.js +77 -0
- package/scripts/cursor-hooks.js +145 -0
- package/scripts/generate-chillslop.mjs +42 -0
- package/scripts/generate-examples.mjs +149 -0
- package/scripts/generate-plugin-doors.mjs +108 -0
- package/scripts/publish-openclaw-skills.js +75 -0
- package/scripts/skill-index.mjs +81 -0
- package/scripts/sync-ponytail.mjs +65 -0
- package/scripts/uninstall.js +92 -0
- package/skills/antislop/SKILL.md +700 -0
- package/skills/antislop/VERSION +1 -0
- package/skills/antislop-code/SKILL.md +128 -0
- package/skills/antislop-copywriting/SKILL.md +407 -0
- package/skills/antislop-human/LICENSE-web-quality-skills +21 -0
- package/skills/antislop-human/SKILL.md +204 -0
- package/skills/antislop-human/contrast-check.py +168 -0
- package/skills/antislop-human/contrast-mcp.py +121 -0
- package/skills/antislop-human/references/A11Y-PATTERNS.md +249 -0
- package/skills/antislop-human/references/WCAG.md +127 -0
- package/skills/antislop-layoutmobile/SKILL.md +170 -0
- package/skills/antislop-ui/LICENSE-web-quality-skills +21 -0
- package/skills/antislop-ui/SKILL.md +344 -0
- package/skills/antislop-ui/references/CORE-WEB-VITALS.md +181 -0
- package/skills/antislop-ui/references/WEB-BASELINE.md +28 -0
- package/skills/plugin.json +5 -0
- package/skills/ponytail/SKILL.md +52 -0
- package/skills/ponytail-audit/SKILL.md +103 -0
- package/skills/ponytail-debt/SKILL.md +44 -0
- package/skills/ponytail-gain/SKILL.md +52 -0
- package/skills/ponytail-help/SKILL.md +76 -0
- package/skills/ponytail-review/SKILL.md +101 -0
- package/src/package.json +3 -0
- package/tests/activation-map.test.js +63 -0
- package/tests/band-import.test.js +111 -0
- package/tests/band.test.tsx +520 -0
- package/tests/behavior.test.js +80 -0
- package/tests/commands.test.js +94 -0
- package/tests/copilot-plugin.test.js +35 -0
- package/tests/correctness.test.js +245 -0
- package/tests/cursor-hooks.test.js +343 -0
- package/tests/gemini-extension.test.js +91 -0
- package/tests/grok-plugin.test.js +39 -0
- package/tests/hermes-plugin.test.js +403 -0
- package/tests/hooks-windows.test.js +205 -0
- package/tests/hooks.test.js +1005 -0
- package/tests/kimi-plugin.test.js +25 -0
- package/tests/loc.test.js +18 -0
- package/tests/map.test.js +30 -0
- package/tests/openclaw-skills.test.js +26 -0
- package/tests/opencode-plugin.test.js +340 -0
- package/tests/package-scripts.test.js +35 -0
- package/tests/package.test.js +23 -0
- package/tests/qoder-plugin.test.js +84 -0
- package/tests/uninstall.test.js +202 -0
- /package/bin/{chill-agent.js → chill-agent.mjs} +0 -0
- /package/tests/{manifests.test.js → manifests.test.mjs} +0 -0
- /package/tests/{rules.test.js → rules.test.mjs} +0 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Hermes support is a real plugin, not just copied rules: the repo root must be
|
|
3
|
+
// installable with `hermes plugins install owner/repo`, register bundled skills,
|
|
4
|
+
// inject active mode context, and expose slash commands.
|
|
5
|
+
|
|
6
|
+
const test = require('node:test');
|
|
7
|
+
const assert = require('node:assert/strict');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { spawnSync } = require('child_process');
|
|
12
|
+
const { filterSkillBodyForMode } = require('../hooks/ponytail-instructions');
|
|
13
|
+
|
|
14
|
+
const commands = ['ponytail', 'ponytail-review', 'ponytail-audit', 'ponytail-debt', 'ponytail-gain', 'ponytail-help'];
|
|
15
|
+
const skillCommands = commands.filter((name) => name !== 'ponytail');
|
|
16
|
+
|
|
17
|
+
const root = path.join(__dirname, '..');
|
|
18
|
+
|
|
19
|
+
// ponytail: probe once; on Windows `python3` is the Store-alias stub that fails
|
|
20
|
+
// even when Python is installed, so fall back to `python` (mirrors benchmarks/correctness.js).
|
|
21
|
+
let pythonCmd;
|
|
22
|
+
function pythonExe() {
|
|
23
|
+
if (pythonCmd) return pythonCmd;
|
|
24
|
+
for (const cmd of ['python3', 'python']) {
|
|
25
|
+
if (spawnSync(cmd, ['-c', 'import sys'], { encoding: 'utf8' }).status === 0) {
|
|
26
|
+
return (pythonCmd = cmd);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return (pythonCmd = 'python3');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function python(script, env = {}) {
|
|
33
|
+
const result = spawnSync(pythonExe(), ['-c', script], {
|
|
34
|
+
cwd: root,
|
|
35
|
+
// Isolate from the developer's own ~/.config/ponytail and PONYTAIL_DEFAULT_MODE.
|
|
36
|
+
env: {
|
|
37
|
+
...process.env,
|
|
38
|
+
PONYTAIL_DEFAULT_MODE: '',
|
|
39
|
+
XDG_CONFIG_HOME: fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-config-')),
|
|
40
|
+
...env,
|
|
41
|
+
},
|
|
42
|
+
encoding: 'utf8',
|
|
43
|
+
});
|
|
44
|
+
if (result.status !== 0) {
|
|
45
|
+
throw new Error(`python failed\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`);
|
|
46
|
+
}
|
|
47
|
+
return result.stdout.trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test('Hermes plugin manifest matches runtime skills, hooks, commands, and package version', () => {
|
|
51
|
+
const manifestPath = path.join(root, 'plugin.yaml');
|
|
52
|
+
assert.ok(fs.existsSync(manifestPath), 'missing root plugin.yaml');
|
|
53
|
+
const manifest = fs.readFileSync(manifestPath, 'utf8');
|
|
54
|
+
const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
55
|
+
const skillDirs = fs.readdirSync(path.join(root, 'skills'))
|
|
56
|
+
.filter((name) => name.startsWith('ponytail') && fs.existsSync(path.join(root, 'skills', name, 'SKILL.md')))
|
|
57
|
+
.sort();
|
|
58
|
+
|
|
59
|
+
assert.match(manifest, /^name:\s*ponytail$/m);
|
|
60
|
+
assert.match(manifest, new RegExp(`^version:\\s*${packageJson.version}$`, 'm'));
|
|
61
|
+
assert.match(manifest, new RegExp(`^author:\\s*${packageJson.author.name}$`, 'm'));
|
|
62
|
+
assert.deepEqual(commands.filter((name) => manifest.includes(` - ${name}`)), commands);
|
|
63
|
+
assert.deepEqual(skillDirs.filter((name) => manifest.includes(` - ${name}`)), skillDirs);
|
|
64
|
+
assert.match(manifest, /pre_llm_call/);
|
|
65
|
+
assert.match(manifest, /pre_gateway_dispatch/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('Hermes plugin registers every shipped skill under the ponytail namespace', () => {
|
|
69
|
+
const output = python(String.raw`
|
|
70
|
+
import importlib.util, json, pathlib
|
|
71
|
+
spec = importlib.util.spec_from_file_location('ponytail_hermes_plugin', '__init__.py')
|
|
72
|
+
mod = importlib.util.module_from_spec(spec)
|
|
73
|
+
spec.loader.exec_module(mod)
|
|
74
|
+
class Ctx:
|
|
75
|
+
def __init__(self):
|
|
76
|
+
self.skills = []
|
|
77
|
+
self.hooks = []
|
|
78
|
+
self.commands = []
|
|
79
|
+
def register_skill(self, name, path):
|
|
80
|
+
self.skills.append((name, pathlib.Path(path).as_posix()))
|
|
81
|
+
def register_hook(self, name, handler):
|
|
82
|
+
self.hooks.append(name)
|
|
83
|
+
def register_command(self, name, handler, description='', args_hint=''):
|
|
84
|
+
self.commands.append(name)
|
|
85
|
+
ctx = Ctx()
|
|
86
|
+
mod.register(ctx)
|
|
87
|
+
print(json.dumps({'skills': ctx.skills, 'hooks': ctx.hooks, 'commands': ctx.commands}, sort_keys=True))
|
|
88
|
+
`);
|
|
89
|
+
const data = JSON.parse(output);
|
|
90
|
+
assert.deepEqual(data.skills.map(([name]) => name).sort(), [
|
|
91
|
+
'ponytail',
|
|
92
|
+
'ponytail-audit',
|
|
93
|
+
'ponytail-debt',
|
|
94
|
+
'ponytail-gain',
|
|
95
|
+
'ponytail-help',
|
|
96
|
+
'ponytail-review',
|
|
97
|
+
]);
|
|
98
|
+
assert.ok(data.skills.every(([, skillPath]) => skillPath.endsWith('/SKILL.md')));
|
|
99
|
+
assert.ok(data.hooks.includes('pre_llm_call'));
|
|
100
|
+
assert.ok(data.commands.includes('ponytail'));
|
|
101
|
+
assert.ok(data.commands.includes('ponytail-review'));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('Hermes plugin builds mode-aware injected context from the canonical skill', () => {
|
|
105
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-config-'));
|
|
106
|
+
const output = python(String.raw`
|
|
107
|
+
import importlib.util, json
|
|
108
|
+
spec = importlib.util.spec_from_file_location('ponytail_hermes_plugin', '__init__.py')
|
|
109
|
+
mod = importlib.util.module_from_spec(spec)
|
|
110
|
+
spec.loader.exec_module(mod)
|
|
111
|
+
ctx = mod.build_injected_context('ultra')
|
|
112
|
+
print(json.dumps({'ctx': ctx}))
|
|
113
|
+
`, { XDG_CONFIG_HOME: tmp });
|
|
114
|
+
const { ctx } = JSON.parse(output);
|
|
115
|
+
|
|
116
|
+
assert.match(ctx, /PONYTAIL MODE ACTIVE — level: ultra/);
|
|
117
|
+
assert.match(ctx, /The best\s+code is the code never written/);
|
|
118
|
+
assert.match(ctx, /ultra/i);
|
|
119
|
+
assert.doesNotMatch(ctx, /^---/);
|
|
120
|
+
assert.doesNotMatch(ctx, /\|\s*\*\*Lite\*\*/i);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('Hermes mode config respects env, config file, off, and invalid command behavior', () => {
|
|
124
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-config-'));
|
|
125
|
+
fs.mkdirSync(path.join(tmp, 'ponytail'), { recursive: true });
|
|
126
|
+
fs.writeFileSync(path.join(tmp, 'ponytail', 'config.json'), JSON.stringify({ defaultMode: 'lite' }));
|
|
127
|
+
const output = python(String.raw`
|
|
128
|
+
import importlib.util, json
|
|
129
|
+
spec = importlib.util.spec_from_file_location('ponytail_hermes_plugin', '__init__.py')
|
|
130
|
+
mod = importlib.util.module_from_spec(spec)
|
|
131
|
+
spec.loader.exec_module(mod)
|
|
132
|
+
class Ctx:
|
|
133
|
+
def __init__(self): self.commands = {}
|
|
134
|
+
def register_skill(self, name, path): pass
|
|
135
|
+
def register_hook(self, name, handler): pass
|
|
136
|
+
def register_command(self, name, handler, description='', args_hint=''):
|
|
137
|
+
self.commands[name] = handler
|
|
138
|
+
ctx = Ctx()
|
|
139
|
+
mod.register(ctx)
|
|
140
|
+
status_before = ctx.commands['ponytail']('')
|
|
141
|
+
invalid = ctx.commands['ponytail']('maximum')
|
|
142
|
+
status_after = ctx.commands['ponytail']('')
|
|
143
|
+
print(json.dumps({
|
|
144
|
+
'default': mod.build_injected_context(None),
|
|
145
|
+
'off': mod.build_injected_context('off'),
|
|
146
|
+
'status_before': status_before,
|
|
147
|
+
'invalid': invalid,
|
|
148
|
+
'status_after': status_after,
|
|
149
|
+
}))
|
|
150
|
+
`, { XDG_CONFIG_HOME: tmp, PONYTAIL_DEFAULT_MODE: 'ultra' });
|
|
151
|
+
const data = JSON.parse(output);
|
|
152
|
+
assert.match(data.default, /level: ultra/);
|
|
153
|
+
assert.equal(data.off, '');
|
|
154
|
+
assert.match(data.status_before, /Ponytail mode: ultra/);
|
|
155
|
+
assert.match(data.invalid, /Usage:/);
|
|
156
|
+
assert.match(data.status_after, /Ponytail mode: ultra/);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('Hermes honors plain and BOM-prefixed default-mode config before LLM calls', (t) => {
|
|
160
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-config-'));
|
|
161
|
+
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
|
162
|
+
fs.mkdirSync(path.join(tmp, 'ponytail'));
|
|
163
|
+
for (const prefix of ['', '\uFEFF']) {
|
|
164
|
+
for (const mode of ['lite', 'off']) {
|
|
165
|
+
fs.writeFileSync(path.join(tmp, 'ponytail', 'config.json'), prefix + JSON.stringify({ defaultMode: mode }));
|
|
166
|
+
const output = python(String.raw`
|
|
167
|
+
import importlib.util, json
|
|
168
|
+
spec = importlib.util.spec_from_file_location('ponytail_hermes_plugin', '__init__.py')
|
|
169
|
+
mod = importlib.util.module_from_spec(spec)
|
|
170
|
+
spec.loader.exec_module(mod)
|
|
171
|
+
print(json.dumps(mod._pre_llm_call()))
|
|
172
|
+
`, { XDG_CONFIG_HOME: tmp, PONYTAIL_DEFAULT_MODE: '' });
|
|
173
|
+
const data = JSON.parse(output);
|
|
174
|
+
if (mode === 'off') assert.equal(data, null);
|
|
175
|
+
else assert.match(data.context, /PONYTAIL MODE ACTIVE — level: lite/);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('Hermes treats review as a command, never a level', () => {
|
|
181
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-review-default-'));
|
|
182
|
+
fs.mkdirSync(path.join(tmp, 'ponytail'), { recursive: true });
|
|
183
|
+
fs.writeFileSync(path.join(tmp, 'ponytail', 'config.json'), JSON.stringify({ defaultMode: 'review' }));
|
|
184
|
+
const output = python(String.raw`
|
|
185
|
+
import importlib.util, json, os
|
|
186
|
+
spec = importlib.util.spec_from_file_location('ponytail_hermes_plugin', '__init__.py')
|
|
187
|
+
mod = importlib.util.module_from_spec(spec)
|
|
188
|
+
spec.loader.exec_module(mod)
|
|
189
|
+
env_default = mod.build_injected_context(None)
|
|
190
|
+
del os.environ['PONYTAIL_DEFAULT_MODE']
|
|
191
|
+
file_default = mod.build_injected_context(None)
|
|
192
|
+
explicit_review = mod.build_injected_context('review')
|
|
193
|
+
print(json.dumps({
|
|
194
|
+
'env_default': env_default,
|
|
195
|
+
'file_default': file_default,
|
|
196
|
+
'explicit_review': explicit_review,
|
|
197
|
+
}))
|
|
198
|
+
`, { XDG_CONFIG_HOME: tmp, PONYTAIL_DEFAULT_MODE: 'review' });
|
|
199
|
+
const data = JSON.parse(output);
|
|
200
|
+
assert.match(data.env_default, /PONYTAIL MODE ACTIVE — level: full/);
|
|
201
|
+
assert.match(data.file_default, /PONYTAIL MODE ACTIVE — level: full/);
|
|
202
|
+
assert.match(data.explicit_review, /PONYTAIL MODE ACTIVE — level: full/);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('Hermes /ponytail command changes mode and pre_llm_call injects current context', () => {
|
|
206
|
+
const output = python(String.raw`
|
|
207
|
+
import importlib.util, json
|
|
208
|
+
spec = importlib.util.spec_from_file_location('ponytail_hermes_plugin', '__init__.py')
|
|
209
|
+
mod = importlib.util.module_from_spec(spec)
|
|
210
|
+
spec.loader.exec_module(mod)
|
|
211
|
+
class Ctx:
|
|
212
|
+
def __init__(self):
|
|
213
|
+
self.hooks = {}
|
|
214
|
+
self.commands = {}
|
|
215
|
+
def register_skill(self, name, path): pass
|
|
216
|
+
def register_hook(self, name, handler): self.hooks[name] = handler
|
|
217
|
+
def register_command(self, name, handler, description='', args_hint=''):
|
|
218
|
+
self.commands[name] = handler
|
|
219
|
+
ctx = Ctx()
|
|
220
|
+
mod.register(ctx)
|
|
221
|
+
message = ctx.commands['ponytail']('ultra')
|
|
222
|
+
injected = ctx.hooks['pre_llm_call'](session_id='s1', user_message='build it', conversation_history=[], is_first_turn=False, model='m', platform='cli')
|
|
223
|
+
print(json.dumps({'message': message, 'context': injected['context']}))
|
|
224
|
+
`);
|
|
225
|
+
const data = JSON.parse(output);
|
|
226
|
+
assert.match(data.message, /ultra/);
|
|
227
|
+
assert.match(data.context, /PONYTAIL MODE ACTIVE — level: ultra/);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('Hermes pre_llm_call does not append the same ruleset to every persisted turn', () => {
|
|
231
|
+
const output = python(String.raw`
|
|
232
|
+
import importlib.util, json
|
|
233
|
+
spec = importlib.util.spec_from_file_location('ponytail_hermes_plugin', '__init__.py')
|
|
234
|
+
mod = importlib.util.module_from_spec(spec)
|
|
235
|
+
spec.loader.exec_module(mod)
|
|
236
|
+
first = mod._pre_llm_call(conversation_history=[])
|
|
237
|
+
history = [{
|
|
238
|
+
'role': 'user',
|
|
239
|
+
'content': 'first task',
|
|
240
|
+
'api_content': 'first task\\n\\n' + first['context'],
|
|
241
|
+
}]
|
|
242
|
+
second = mod._pre_llm_call(conversation_history=history)
|
|
243
|
+
mod._current_mode = 'ultra'
|
|
244
|
+
switched = mod._pre_llm_call(conversation_history=history)
|
|
245
|
+
history.append({
|
|
246
|
+
'role': 'user',
|
|
247
|
+
'content': 'second task',
|
|
248
|
+
'api_content': 'second task\\n\\n' + switched['context'],
|
|
249
|
+
})
|
|
250
|
+
after_switch = mod._pre_llm_call(conversation_history=history)
|
|
251
|
+
print(json.dumps({'first': first, 'second': second, 'switched': switched, 'after_switch': after_switch}))
|
|
252
|
+
`);
|
|
253
|
+
const data = JSON.parse(output);
|
|
254
|
+
assert.match(data.first.context, /PONYTAIL MODE ACTIVE — level: full/);
|
|
255
|
+
assert.equal(data.second, null);
|
|
256
|
+
assert.match(data.switched.context, /PONYTAIL MODE ACTIVE — level: ultra/);
|
|
257
|
+
assert.equal(data.after_switch, null);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('Hermes /ponytail off reaches the model once, and a later level re-injects the ruleset', () => {
|
|
261
|
+
// The ruleset from the first turn is replayed from api_content on every later
|
|
262
|
+
// turn, and the command's reply goes to the user only, so off must be injected.
|
|
263
|
+
const output = python(String.raw`
|
|
264
|
+
import importlib.util, json
|
|
265
|
+
spec = importlib.util.spec_from_file_location('ponytail_hermes_plugin', '__init__.py')
|
|
266
|
+
mod = importlib.util.module_from_spec(spec)
|
|
267
|
+
spec.loader.exec_module(mod)
|
|
268
|
+
first = mod._pre_llm_call(conversation_history=[])
|
|
269
|
+
history = [{
|
|
270
|
+
'role': 'user',
|
|
271
|
+
'content': 'first task',
|
|
272
|
+
'api_content': 'first task\\n\\n' + first['context'],
|
|
273
|
+
}]
|
|
274
|
+
mod._handle_mode_command('off')
|
|
275
|
+
nothing_persisted = mod._pre_llm_call(conversation_history=[])
|
|
276
|
+
off = mod._pre_llm_call(conversation_history=history)
|
|
277
|
+
history.append({
|
|
278
|
+
'role': 'user',
|
|
279
|
+
'content': 'second task',
|
|
280
|
+
'api_content': 'second task\\n\\nPONYTAIL MODE OFF',
|
|
281
|
+
})
|
|
282
|
+
still_off = mod._pre_llm_call(conversation_history=history)
|
|
283
|
+
mod._handle_mode_command('full')
|
|
284
|
+
back_on = mod._pre_llm_call(conversation_history=history)
|
|
285
|
+
print(json.dumps({'nothing_persisted': nothing_persisted, 'off': off, 'still_off': still_off, 'back_on': back_on}))
|
|
286
|
+
`);
|
|
287
|
+
const data = JSON.parse(output);
|
|
288
|
+
assert.equal(data.nothing_persisted, null);
|
|
289
|
+
assert.deepEqual(data.off, { context: 'PONYTAIL MODE OFF' });
|
|
290
|
+
assert.equal(data.still_off, null);
|
|
291
|
+
assert.match(data.back_on.context, /PONYTAIL MODE ACTIVE — level: full/);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('Hermes gateway rewrite respects slash access denial', () => {
|
|
295
|
+
const output = python(String.raw`
|
|
296
|
+
import importlib.util, json
|
|
297
|
+
spec = importlib.util.spec_from_file_location('ponytail_hermes_plugin', '__init__.py')
|
|
298
|
+
mod = importlib.util.module_from_spec(spec)
|
|
299
|
+
spec.loader.exec_module(mod)
|
|
300
|
+
class Source:
|
|
301
|
+
platform = None
|
|
302
|
+
chat_id = 'c1'
|
|
303
|
+
user_id = 'u1'
|
|
304
|
+
class Event:
|
|
305
|
+
text = '/ponytail-review src/app.js'
|
|
306
|
+
source = Source()
|
|
307
|
+
class Gateway:
|
|
308
|
+
def _check_slash_access(self, source, command):
|
|
309
|
+
return 'denied'
|
|
310
|
+
result = mod.rewrite_gateway_command(event=Event(), gateway=Gateway())
|
|
311
|
+
print(json.dumps(result))
|
|
312
|
+
`);
|
|
313
|
+
assert.equal(output, 'null');
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test('Hermes bare /ponytail switches an off session on and only reports a running level (#639)', () => {
|
|
317
|
+
const output = python(String.raw`
|
|
318
|
+
import importlib.util, json
|
|
319
|
+
spec = importlib.util.spec_from_file_location('ponytail_hermes_plugin', '__init__.py')
|
|
320
|
+
mod = importlib.util.module_from_spec(spec)
|
|
321
|
+
spec.loader.exec_module(mod)
|
|
322
|
+
mod._current_mode = 'ultra'
|
|
323
|
+
running = mod._handle_mode_command('')
|
|
324
|
+
kept = mod._current_mode
|
|
325
|
+
mod._current_mode = 'off'
|
|
326
|
+
switched = mod._handle_mode_command('')
|
|
327
|
+
print(json.dumps([running, kept, switched, mod._current_mode]))
|
|
328
|
+
`);
|
|
329
|
+
const [running, kept, switched, after] = JSON.parse(output);
|
|
330
|
+
assert.match(running, /Ponytail mode: ultra/);
|
|
331
|
+
assert.equal(kept, 'ultra');
|
|
332
|
+
assert.match(switched, /set to full/);
|
|
333
|
+
assert.equal(after, 'full');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test('Hermes gateway rewrite preserves every skill command and ignores unrelated text', () => {
|
|
337
|
+
const output = python(String.raw`
|
|
338
|
+
import importlib.util, json
|
|
339
|
+
spec = importlib.util.spec_from_file_location('ponytail_hermes_plugin', '__init__.py')
|
|
340
|
+
mod = importlib.util.module_from_spec(spec)
|
|
341
|
+
spec.loader.exec_module(mod)
|
|
342
|
+
class Event:
|
|
343
|
+
def __init__(self, text): self.text = text
|
|
344
|
+
cases = {}
|
|
345
|
+
for text in ['/ponytail-review x', '/ponytail_audit repo', '/ponytail-debt', '/ponytail-help', '/status', 'hello']:
|
|
346
|
+
cases[text] = mod.rewrite_gateway_command(event=Event(text))
|
|
347
|
+
print(json.dumps(cases, sort_keys=True))
|
|
348
|
+
`);
|
|
349
|
+
const data = JSON.parse(output);
|
|
350
|
+
assert.match(data['/ponytail-review x'].text, /ponytail-review/);
|
|
351
|
+
assert.match(data['/ponytail_audit repo'].text, /ponytail-audit/);
|
|
352
|
+
assert.match(data['/ponytail_audit repo'].text, /repo/);
|
|
353
|
+
assert.match(data['/ponytail-debt'].text, /ponytail-debt/);
|
|
354
|
+
assert.match(data['/ponytail-help'].text, /ponytail-help/);
|
|
355
|
+
assert.equal(data['/status'], null);
|
|
356
|
+
assert.equal(data.hello, null);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('Hermes mode filter does not drop a rule bullet whose label matches a mode name', () => {
|
|
360
|
+
// Regression for the Hermes side of #571: a rule bullet shaped "- Full: ..."
|
|
361
|
+
// has the same "label: text" shape as a worked example, but isn't one — it
|
|
362
|
+
// must survive in every mode. Only the quoted `- lite: "..."`-style bullets
|
|
363
|
+
// are real per-mode examples. hooks/ponytail-instructions.js already guards
|
|
364
|
+
// against this; the Hermes plugin filter had drifted and lost the same guard.
|
|
365
|
+
const output = python(String.raw`
|
|
366
|
+
import importlib.util, json
|
|
367
|
+
spec = importlib.util.spec_from_file_location('ponytail_hermes_plugin', '__init__.py')
|
|
368
|
+
mod = importlib.util.module_from_spec(spec)
|
|
369
|
+
spec.loader.exec_module(mod)
|
|
370
|
+
body = (
|
|
371
|
+
"- Full: do not confuse this rule label with the mode name.\n"
|
|
372
|
+
"- Lite: same risk, this is a real rule bullet.\n"
|
|
373
|
+
'- lite: "real worked example"\n'
|
|
374
|
+
'- ultra: "real worked example"'
|
|
375
|
+
)
|
|
376
|
+
print(json.dumps({'filtered': mod._filter_skill_body_for_mode(body, 'ultra')}))
|
|
377
|
+
`);
|
|
378
|
+
const { filtered } = JSON.parse(output);
|
|
379
|
+
assert.ok(filtered.includes('Full: do not confuse'), 'an unquoted rule bullet must not be treated as a mode example');
|
|
380
|
+
assert.ok(filtered.includes('Lite: same risk'), 'an unquoted rule bullet must not be treated as a mode example');
|
|
381
|
+
assert.ok(!filtered.includes('- lite:'), 'the real quoted lite example must still be filtered out in ultra mode');
|
|
382
|
+
assert.ok(filtered.includes('ultra: "real worked example"'));
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test('Hermes mode filter stays in lockstep with the JS filter on the real skill body', () => {
|
|
386
|
+
// The two filters are independent ports of the same contract (one shared
|
|
387
|
+
// skill file, every host adapter trims it the same way). Diff them directly
|
|
388
|
+
// on skills/ponytail/SKILL.md instead of trusting the two implementations to
|
|
389
|
+
// stay in sync by hand.
|
|
390
|
+
const body = fs.readFileSync(path.join(root, 'skills', 'ponytail', 'SKILL.md'), 'utf8');
|
|
391
|
+
for (const mode of ['lite', 'full', 'ultra']) {
|
|
392
|
+
const jsFiltered = filterSkillBodyForMode(body, mode);
|
|
393
|
+
const pyFiltered = python(String.raw`
|
|
394
|
+
import importlib.util, json
|
|
395
|
+
spec = importlib.util.spec_from_file_location('ponytail_hermes_plugin', '__init__.py')
|
|
396
|
+
mod = importlib.util.module_from_spec(spec)
|
|
397
|
+
spec.loader.exec_module(mod)
|
|
398
|
+
body = open('skills/ponytail/SKILL.md', encoding='utf-8').read()
|
|
399
|
+
print(json.dumps(mod._filter_skill_body_for_mode(body, '${mode}')))
|
|
400
|
+
`);
|
|
401
|
+
assert.equal(JSON.parse(pyFiltered), jsFiltered, `filters diverge for mode "${mode}"`);
|
|
402
|
+
}
|
|
403
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Regression test for issues #19 and #593: on Windows the lifecycle hooks run
|
|
3
|
+
// via PowerShell, so the shared `command` field must be cross-platform (plain
|
|
4
|
+
// `node`, no bash-only syntax). commandWindows is not part of the supported
|
|
5
|
+
// hooks schema on the Claude.ai plugin marketplace validator, so it is omitted
|
|
6
|
+
// — plain `node -e "..."` works everywhere without relying on shell-specific
|
|
7
|
+
// variable syntax (see #824: the command text used to embed a
|
|
8
|
+
// ${CLAUDE_PLUGIN_ROOT} placeholder that the *host* substitutes textually
|
|
9
|
+
// before the shell parses the command, which is unsafe if the substituted
|
|
10
|
+
// value carries shell metacharacters — the fixed command instead reads
|
|
11
|
+
// process.env.CLAUDE_PLUGIN_ROOT from inside Node, so the shell never
|
|
12
|
+
// re-parses the plugin root at all).
|
|
13
|
+
//
|
|
14
|
+
// The hook also has to point at a script that actually ships in hooks/.
|
|
15
|
+
|
|
16
|
+
const test = require('node:test');
|
|
17
|
+
const assert = require('node:assert/strict');
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const os = require('os');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const { spawn, execFileSync } = require('child_process');
|
|
22
|
+
|
|
23
|
+
const root = path.join(__dirname, '..');
|
|
24
|
+
const HOOKS_JSON = 'hooks/claude-codex-hooks.json';
|
|
25
|
+
const COPILOT_HOOKS_JSON = 'hooks/copilot-hooks.json';
|
|
26
|
+
const HOST_PLUGIN_MANIFESTS = [
|
|
27
|
+
'.claude-plugin/plugin.json',
|
|
28
|
+
'.codex-plugin/plugin.json',
|
|
29
|
+
];
|
|
30
|
+
// PowerShell 5.1 rejects these POSIX shell guards when a host runs `command`.
|
|
31
|
+
const POSIX_GUARD_SYNTAX = /\bcommand\s+-v\b|&&|\|\||>\/dev\/null|2>&1/;
|
|
32
|
+
// Pull the hooks/<script> a command launches, so we can check it exists.
|
|
33
|
+
const HOOK_SCRIPT = /hooks[\\/]([\w.-]+\.(?:js|mjs|cjs|ps1|sh))/;
|
|
34
|
+
// #824: a plugin-root placeholder textually interpolated into shell command
|
|
35
|
+
// text is unsafe — the host substitutes it before the shell parses the
|
|
36
|
+
// command, so a hostile install path can break out of the quoting.
|
|
37
|
+
const PLUGIN_ROOT_PLACEHOLDER = /\$\{(?:CLAUDE_)?PLUGIN_ROOT\}/;
|
|
38
|
+
|
|
39
|
+
// Read inside each case so a missing/malformed file fails as a clean assertion,
|
|
40
|
+
// not a load-time crash.
|
|
41
|
+
function commandHooks() {
|
|
42
|
+
const config = JSON.parse(fs.readFileSync(path.join(root, HOOKS_JSON), 'utf8'));
|
|
43
|
+
return Object.values(config.hooks)
|
|
44
|
+
.flat()
|
|
45
|
+
.flatMap((entry) => entry.hooks);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function copilotCommands() {
|
|
49
|
+
const config = JSON.parse(fs.readFileSync(path.join(root, COPILOT_HOOKS_JSON), 'utf8'));
|
|
50
|
+
return Object.values(config.hooks)
|
|
51
|
+
.flat()
|
|
52
|
+
.flatMap((entry) => [entry.bash, entry.powershell]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// commandWindows is not part of the supported hooks schema on the Claude.ai
|
|
56
|
+
// plugin marketplace validator (#593). Since the shared `command` field
|
|
57
|
+
// already runs cross-platform (plain `node -e "..."`, no shell-specific
|
|
58
|
+
// syntax, and VS Code Copilot ignores commandWindows and runs `command`
|
|
59
|
+
// through PowerShell on Windows anyway), it is omitted.
|
|
60
|
+
test('hooks.json omits commandWindows for marketplace validation (#593)', () => {
|
|
61
|
+
for (const hook of commandHooks()) {
|
|
62
|
+
assert.equal(hook.commandWindows, undefined, `hook must not use commandWindows (not supported by marketplace validator): ${hook.command}`);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('shared hook commands avoid POSIX-only guard syntax', () => {
|
|
67
|
+
const commands = commandHooks()
|
|
68
|
+
.map((h) => h.command)
|
|
69
|
+
.filter(Boolean);
|
|
70
|
+
assert.ok(commands.length > 0, 'expected at least one shared command entry');
|
|
71
|
+
for (const cmd of commands) {
|
|
72
|
+
assert.doesNotMatch(cmd, POSIX_GUARD_SYNTAX, `command uses POSIX-only guard syntax: ${cmd}`);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Issue #527 / #569: the shared `command` field must be shell-agnostic. `exec`
|
|
77
|
+
// is a bash/zsh builtin with no PowerShell equivalent, but some hosts run
|
|
78
|
+
// `command` through PowerShell on Windows regardless of the commandWindows
|
|
79
|
+
// field — VS Code Copilot always does (it never reads commandWindows), and
|
|
80
|
+
// native Claude Code launched from Git Bash was seen doing the same. `exec
|
|
81
|
+
// node ...` then dies on its first token with CommandNotFoundException, so
|
|
82
|
+
// every hook fails on Windows. Plain `node ...` runs natively in both bash and
|
|
83
|
+
// PowerShell. The wrapper-process pileup that #461 originally used `exec` to
|
|
84
|
+
// avoid is handled separately by each hook's stdin self-exit guard (#443/#477).
|
|
85
|
+
test('shared hook commands are shell-agnostic (no bash-only exec prefix)', () => {
|
|
86
|
+
const commands = commandHooks()
|
|
87
|
+
.map((h) => h.command)
|
|
88
|
+
.filter(Boolean);
|
|
89
|
+
assert.ok(commands.length > 0, 'expected at least one shared command entry');
|
|
90
|
+
for (const cmd of commands) {
|
|
91
|
+
assert.doesNotMatch(cmd, /(^|\s)exec\s/, `command must not use the bash-only 'exec' builtin (breaks under PowerShell): ${cmd}`);
|
|
92
|
+
assert.match(cmd, /^node\s+/, `command must invoke node directly so it runs in both bash and PowerShell: ${cmd}`);
|
|
93
|
+
assert.doesNotMatch(cmd, /;\s*exit 0$/, `command must not leave a shell wrapper waiting on node: ${cmd}`);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('every hook command points at a script that ships in hooks/', () => {
|
|
98
|
+
for (const hook of commandHooks()) {
|
|
99
|
+
const cmd = hook.command;
|
|
100
|
+
const match = cmd.match(HOOK_SCRIPT);
|
|
101
|
+
assert.ok(match, `cannot find a hooks/ script in command: ${cmd}`);
|
|
102
|
+
const script = path.join(root, 'hooks', match[1]);
|
|
103
|
+
assert.ok(fs.existsSync(script), `command references a missing hook script: ${match[1]}`);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Issue #443: on Windows the UserPromptSubmit hook runs inside a PowerShell
|
|
108
|
+
// `if {}` wrapper that can swallow the piped prompt JSON, so stdin 'end' never
|
|
109
|
+
// fires. The hook must never wait on stdin forever — that freezes the whole
|
|
110
|
+
// session. It has to self-exit even when stdin stays open and empty. The
|
|
111
|
+
// activate hook reads the SessionStart source from stdin too.
|
|
112
|
+
for (const script of ['ponytail-mode-tracker.js', 'ponytail-activate.js']) {
|
|
113
|
+
test(`${script} self-exits when stdin never closes (no freeze)`, async () => {
|
|
114
|
+
const hook = path.join(root, 'hooks', script);
|
|
115
|
+
// A throwaway home: the activate fallback writes the flag and the statusline copy.
|
|
116
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-stdin-'));
|
|
117
|
+
const env = { ...process.env, HOME: home, USERPROFILE: home, CLAUDE_CONFIG_DIR: path.join(home, '.claude'), PONYTAIL_MAP: '0' };
|
|
118
|
+
// stdin is a pipe we never write to or end, reproducing the deadlock.
|
|
119
|
+
const child = spawn(process.execPath, [hook], { env, stdio: ['pipe', 'ignore', 'ignore'] });
|
|
120
|
+
|
|
121
|
+
const code = await new Promise((resolve, reject) => {
|
|
122
|
+
const guard = setTimeout(() => {
|
|
123
|
+
child.kill('SIGKILL');
|
|
124
|
+
reject(new Error('hook hung on open stdin — it would freeze the session'));
|
|
125
|
+
}, 3000);
|
|
126
|
+
child.on('exit', (c) => { clearTimeout(guard); resolve(c); });
|
|
127
|
+
child.on('error', reject);
|
|
128
|
+
});
|
|
129
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
130
|
+
|
|
131
|
+
assert.equal(code, 0, 'hook must exit cleanly when stdin never closes');
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
test('Claude and Codex manifests point at the shared host-specific hook config', () => {
|
|
136
|
+
// Claude Code also loads the band's hooks module, from a file of its own the other hosts never read.
|
|
137
|
+
const expected = {
|
|
138
|
+
'.claude-plugin/plugin.json': [`./${HOOKS_JSON}`, './hooks/claude-band.json'],
|
|
139
|
+
'.codex-plugin/plugin.json': `./${HOOKS_JSON}`,
|
|
140
|
+
};
|
|
141
|
+
for (const rel of HOST_PLUGIN_MANIFESTS) {
|
|
142
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(root, rel), 'utf8'));
|
|
143
|
+
assert.deepEqual(manifest.hooks, expected[rel], `${rel} must not rely on root hooks auto-discovery`);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// #824: lifecycle hook commands must not interpolate the plugin-root
|
|
148
|
+
// placeholder into shell text — a host that substitutes it textually before
|
|
149
|
+
// the shell parses the command lets a hostile install path break out of the
|
|
150
|
+
// quoting. Commands must instead have the invoked script read the root from
|
|
151
|
+
// its own process.env.
|
|
152
|
+
test('claude-codex-hooks.json commands contain no plugin-root placeholder', () => {
|
|
153
|
+
const commands = commandHooks().map((h) => h.command).filter(Boolean);
|
|
154
|
+
assert.ok(commands.length > 0, 'expected at least one shared command entry');
|
|
155
|
+
for (const cmd of commands) {
|
|
156
|
+
assert.doesNotMatch(cmd, PLUGIN_ROOT_PLACEHOLDER, `command interpolates the plugin root into shell text: ${cmd}`);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('copilot-hooks.json commands contain no plugin-root placeholder', () => {
|
|
161
|
+
const commands = copilotCommands().filter(Boolean);
|
|
162
|
+
assert.ok(commands.length > 0, 'expected at least one copilot command entry');
|
|
163
|
+
for (const cmd of commands) {
|
|
164
|
+
assert.doesNotMatch(cmd, PLUGIN_ROOT_PLACEHOLDER, `command interpolates the plugin root into shell text: ${cmd}`);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// End-to-end proof for #824. The reported mechanism is specifically a HOST
|
|
169
|
+
// that textually substitutes a ${CLAUDE_PLUGIN_ROOT}/${PLUGIN_ROOT}
|
|
170
|
+
// placeholder into the command STRING itself before a shell parses it — a
|
|
171
|
+
// real shell's own quoted ${VAR} expansion is safe (no re-parsing inside
|
|
172
|
+
// double quotes), so invoking the raw command with the env var merely SET
|
|
173
|
+
// does not reproduce the bug. This test instead performs that host-style
|
|
174
|
+
// substitution explicitly, matching the threat model in the issue: before
|
|
175
|
+
// the fix this created MARKER; after the fix there is no placeholder left to
|
|
176
|
+
// substitute, so the hostile value never reaches shell text at all.
|
|
177
|
+
test('a hostile plugin root cannot inject shell commands via host-side textual substitution (#824)', () => {
|
|
178
|
+
const isWindows = process.platform === 'win32';
|
|
179
|
+
const commands = [...commandHooks().map((h) => h.command), ...copilotCommands()].filter(Boolean);
|
|
180
|
+
assert.ok(commands.length > 0, 'expected at least one command to check');
|
|
181
|
+
|
|
182
|
+
const marker = path.join(os.tmpdir(), `ponytail-824-marker-${process.pid}`);
|
|
183
|
+
const hostileRoot = isWindows
|
|
184
|
+
? `C:\\x" ; New-Item -ItemType File -Path "${marker}" ; "`
|
|
185
|
+
: `/tmp/x"; touch "${marker}"; echo "`;
|
|
186
|
+
|
|
187
|
+
for (const cmd of commands) {
|
|
188
|
+
fs.rmSync(marker, { force: true });
|
|
189
|
+
const substituted = cmd
|
|
190
|
+
.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, hostileRoot)
|
|
191
|
+
.replace(/\$\{PLUGIN_ROOT\}/g, hostileRoot);
|
|
192
|
+
try {
|
|
193
|
+
if (isWindows) {
|
|
194
|
+
execFileSync('powershell', ['-NoProfile', '-Command', substituted], { stdio: 'ignore' });
|
|
195
|
+
} else {
|
|
196
|
+
execFileSync('bash', ['-c', substituted], { stdio: 'ignore' });
|
|
197
|
+
}
|
|
198
|
+
} catch (e) {
|
|
199
|
+
// Expected to fail (resolves to a bogus module path) — only whether
|
|
200
|
+
// MARKER was created matters.
|
|
201
|
+
}
|
|
202
|
+
assert.equal(fs.existsSync(marker), false, `hostile plugin root injected shell commands via: ${cmd}`);
|
|
203
|
+
}
|
|
204
|
+
fs.rmSync(marker, { force: true });
|
|
205
|
+
});
|