canary-test-cli 7.0.0 → 7.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/skills/README.md +308 -0
- package/agents/skills/canary:generate.md +49 -0
- package/agents/skills/canary:init.md +37 -0
- package/agents/skills/canary:migrate.md +66 -0
- package/agents/skills/claude-code/canary-add-framework/SKILL.md +248 -0
- package/agents/skills/claude-code/canary-blackhawk/SKILL.md +170 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/cli.mjs +188 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/rules.mjs +120 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/scanner.mjs +244 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/string-literals.mjs +116 -0
- package/agents/skills/claude-code/canary-cassandra/SKILL.md +180 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +268 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
- package/agents/skills/claude-code/canary-ci-ready/SKILL.md +161 -0
- package/agents/skills/claude-code/canary-ci-ready/skill.yaml +14 -0
- package/agents/skills/claude-code/canary-company-knowledge/SKILL.md +196 -0
- package/agents/skills/claude-code/canary-critical-areas/SKILL.md +142 -0
- package/agents/skills/claude-code/canary-critical-areas/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-edge-case-discovery/SKILL.md +160 -0
- package/agents/skills/claude-code/canary-edge-case-discovery/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-fail-fast/SKILL.md +75 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/cli.mjs +118 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/digest.mjs +69 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/failures.mjs +60 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/fastfail_check.mjs +43 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/parse.mjs +149 -0
- package/agents/skills/claude-code/canary-failure-impact/SKILL.md +153 -0
- package/agents/skills/claude-code/canary-failure-impact/skill.yaml +15 -0
- package/agents/skills/claude-code/canary-fleet-health/SKILL.md +196 -0
- package/agents/skills/claude-code/canary-generate-test/SKILL.md +185 -0
- package/agents/skills/claude-code/canary-instrument/SKILL.md +157 -0
- package/agents/skills/claude-code/canary-instrument/scripts/cli.mjs +178 -0
- package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs +96 -0
- package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/playwright-fixture.ts +44 -0
- package/agents/skills/claude-code/canary-instrument/scripts/run_types.mjs +81 -0
- package/agents/skills/claude-code/canary-instrument/scripts/span_reader.mjs +187 -0
- package/agents/skills/claude-code/canary-katana/SKILL.md +243 -0
- package/agents/skills/claude-code/canary-katana/scripts/alarm.mjs +296 -0
- package/agents/skills/claude-code/canary-katana/scripts/cli.mjs +247 -0
- package/agents/skills/claude-code/canary-katana/scripts/diffscan.mjs +0 -0
- package/agents/skills/claude-code/canary-katana/scripts/ledger.mjs +183 -0
- package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +129 -0
- package/agents/skills/claude-code/canary-pr-guardian/skill.yaml +17 -0
- package/agents/skills/claude-code/canary-promote-test/SKILL.md +228 -0
- package/agents/skills/claude-code/canary-savant/SKILL.md +233 -0
- package/agents/skills/claude-code/canary-savant/scripts/cli.mjs +274 -0
- package/agents/skills/claude-code/canary-savant/scripts/restoration.mjs +274 -0
- package/agents/skills/claude-code/canary-savant/scripts/rules.mjs +168 -0
- package/agents/skills/claude-code/canary-savant/scripts/runner.mjs +572 -0
- package/agents/skills/claude-code/canary-savant/scripts/scanner.mjs +374 -0
- package/agents/skills/claude-code/canary-savant/scripts/string-literals.mjs +116 -0
- package/agents/skills/claude-code/canary-setup-harness/SKILL.md +263 -0
- package/agents/skills/claude-code/canary-shadow/SKILL.md +131 -0
- package/agents/skills/claude-code/canary-shadow/scripts/cases.example.json +32 -0
- package/agents/skills/claude-code/canary-shadow/scripts/cli.mjs +195 -0
- package/agents/skills/claude-code/canary-ship/SKILL.md +177 -0
- package/agents/skills/claude-code/canary-ship/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-strix/SKILL.md +130 -0
- package/agents/skills/claude-code/canary-strix/scripts/cli.mjs +255 -0
- package/agents/skills/claude-code/canary-strix/scripts/scanner.mjs +252 -0
- package/agents/skills/claude-code/canary-strix/scripts/terms.mjs +132 -0
- package/agents/skills/claude-code/canary-test-pipeline/SKILL.md +159 -0
- package/agents/skills/claude-code/canary-test-pipeline/skill.yaml +19 -0
- package/agents/skills/claude-code/canary-test-reporter/SKILL.md +138 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/cli.mjs +98 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/json_report.mjs +58 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/parse.mjs +216 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/render.mjs +114 -0
- package/agents/skills/lib/parse-args.mjs +214 -0
- package/dist/engine/analysis/cli.js +116 -54
- package/dist/engine/analysis/engine.js +34 -16
- package/dist/engine/analysis/reports.js +5 -4
- package/dist/engine/cli-commands.js +252 -106
- package/dist/engine/cli-common.js +15 -24
- package/dist/engine/cli.core.js +37 -11
- package/dist/engine/cli.js +2 -2
- package/dist/engine/company-knowledge-cli.js +2 -2
- package/dist/engine/core/adoption.js +408 -0
- package/dist/engine/core/framework-probes.js +7 -7
- package/dist/engine/core/fs-glob.js +2 -2
- package/dist/engine/core/gate-result.js +17 -0
- package/dist/engine/core/migrator.js +9 -17
- package/dist/engine/core/pattern-matcher.js +23 -5
- package/dist/engine/core/persona.js +421 -0
- package/dist/engine/core/promotion-verdict.js +261 -0
- package/dist/engine/core/quality-scorer.js +15 -2
- package/dist/engine/core/reporter.js +1 -9
- package/dist/engine/core/skill-dispatch.js +115 -0
- package/dist/engine/core/skill-examples.js +392 -0
- package/dist/engine/core/skill-registry.js +59 -4
- package/dist/engine/core/skill-surfaces.js +307 -0
- package/dist/engine/core/static-linter.js +310 -38
- package/dist/engine/core/test-files.js +77 -0
- package/dist/engine/core/ticket-updater.js +1 -7
- package/dist/engine/core/vacuity-scanner.js +726 -0
- package/dist/engine/core/workflow-discovery.js +2 -8
- package/dist/engine/core/workspace-detect.js +7 -6
- package/dist/engine/data/personas/registry.json +36 -0
- package/dist/engine/guardian/adjudication.js +5 -5
- package/dist/engine/guardian/analysis-emit.js +19 -28
- package/dist/engine/guardian/cli.js +150 -51
- package/dist/engine/guardian/coverage.js +1 -1
- package/dist/engine/guardian/diff-coverage/heuristic-tier.js +1 -1
- package/dist/engine/guardian/diff-coverage/orchestrator.js +2 -2
- package/dist/engine/guardian/pr-check.js +322 -33
- package/dist/engine/guardian/pr-comment.js +4 -3
- package/dist/engine/history/cli.js +210 -6
- package/dist/engine/history/ndjson-store.js +9 -5
- package/dist/engine/history/record.js +34 -5
- package/dist/engine/history/run-recorder.js +165 -0
- package/dist/engine/history/schema.js +25 -7
- package/dist/engine/history/store.js +9 -0
- package/dist/engine/mcp-server.js +35 -13
- package/dist/engine/skills-cli.js +249 -23
- package/dist/engine/util/ensure-ascii.js +37 -0
- package/dist/engine/workflow-cli.js +6 -6
- package/dist/gate-result.d.ts +11 -0
- package/dist/gate-result.js +18 -0
- package/dist/reporters/testtracker.d.ts +1 -1
- package/dist/reporters/testtracker.js +1 -1
- package/dist/uninstall.js +12 -5
- package/package.json +3 -2
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: canary-ship
|
|
3
|
+
version: '1.0.0'
|
|
4
|
+
description: >-
|
|
5
|
+
Run the canary ship gate on a finished change: spawn parallel adversarial
|
|
6
|
+
reviewers on the diff, resolve every confirmed finding with a regression test,
|
|
7
|
+
then commit, open a PR, and squash-merge while watching CI to green. Use this
|
|
8
|
+
whenever an implemented, locally-green change on a feature branch should be
|
|
9
|
+
merged — when the user says ship it, or review resolve commit push pr merge,
|
|
10
|
+
or get this merged when it is green, or take it the rest of the way, or right
|
|
11
|
+
after you finish implementing a feature or fix and the next step is
|
|
12
|
+
integration. It bakes in this repo's conventions (no co-author trailer, squash
|
|
13
|
+
merge, prettier, exclude local IDE churn, update the roadmap). NOT for writing
|
|
14
|
+
the implementation itself, and NOT a substitute for finishing the work — the
|
|
15
|
+
change must already exist and pass tests locally before the gate runs.
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# Canary Ship
|
|
19
|
+
|
|
20
|
+
The last mile of a change is where quality is won or lost: a plausible-looking
|
|
21
|
+
diff merges, and the bug nobody looked for ships with it. This skill is the
|
|
22
|
+
**ship gate** — the disciplined path from "the code is written and tests pass"
|
|
23
|
+
to "it's merged on `main` with CI green." Its spine is a real adversarial review
|
|
24
|
+
_before_ the merge button, not after, because the cheapest place to catch a
|
|
25
|
+
defect is the one before it reaches `main`.
|
|
26
|
+
|
|
27
|
+
It is not a code generator. It assumes the change already exists on a feature
|
|
28
|
+
branch and passes the suite locally. Its job is to pressure-test that change,
|
|
29
|
+
resolve what the pressure surfaces, and integrate it under this repo's
|
|
30
|
+
conventions without you having to re-remember them each time.
|
|
31
|
+
|
|
32
|
+
## When this fires vs. when it doesn't
|
|
33
|
+
|
|
34
|
+
Use it when the work is **done and green** and the remaining verb is "ship",
|
|
35
|
+
"merge", "PR it", "take it home". Do **not** use it to do the implementation, to
|
|
36
|
+
merge something whose tests you haven't run, or to force a merge past a failing
|
|
37
|
+
gate. If the change isn't on a branch yet, make the branch first (see
|
|
38
|
+
[AGENTS.md](../../../../AGENTS.md) branch hygiene) — never ship from `main`.
|
|
39
|
+
|
|
40
|
+
## Preconditions (verify before starting)
|
|
41
|
+
|
|
42
|
+
- On a feature branch, not `main` (`git branch --show-current`).
|
|
43
|
+
- The change is implemented and the relevant suite + lint pass locally. If you
|
|
44
|
+
can't confirm that, stop and run them — the gate reviews a _finished_ change.
|
|
45
|
+
- `git fetch origin` first, then know your diff base (`origin/main...`). Other
|
|
46
|
+
sessions may be active on this repo; trust the remote, not stale local state.
|
|
47
|
+
|
|
48
|
+
## The pipeline
|
|
49
|
+
|
|
50
|
+
Five phases. Do them in order; each depends on the last. Track them as todos so
|
|
51
|
+
none is silently skipped.
|
|
52
|
+
|
|
53
|
+
### 1 · Review — two independent reviewers, in parallel, on the diff
|
|
54
|
+
|
|
55
|
+
Spawn **two** review subagents in the _same_ turn so they run concurrently, each
|
|
56
|
+
scoped to the actual change (not the whole repo):
|
|
57
|
+
|
|
58
|
+
- a **correctness/design** pass — `harness-code-reviewer` — hunting real bugs,
|
|
59
|
+
broken contracts, and inconsistencies;
|
|
60
|
+
- an **adversarial** pass — `harness-adversarial-reviewer` — constructing
|
|
61
|
+
failure scenarios: assumption violations, composition failures across _all_
|
|
62
|
+
callers of anything you changed, and blind spots in new guards or tests.
|
|
63
|
+
|
|
64
|
+
Give each reviewer the changed-file list, the design intent, and the specific
|
|
65
|
+
risk areas you already suspect (a contract change? a new parser? an import
|
|
66
|
+
cycle?). Ask for **confirmed findings with a concrete failure scenario**
|
|
67
|
+
(specific input/state → bad outcome), ranked by severity — not style nits.
|
|
68
|
+
|
|
69
|
+
Why two, why adversarial, why before the merge: a single reviewer rubber-stamps;
|
|
70
|
+
two independent ones that disagree surface the real seam. The adversarial lens
|
|
71
|
+
is the one that asks "who _else_ calls this?" — the question that catches the
|
|
72
|
+
regression your own change introduced in a caller you weren't looking at. (This
|
|
73
|
+
is not hypothetical; it is the failure mode this gate exists to catch.)
|
|
74
|
+
|
|
75
|
+
If the `harness-code-review` pipeline (the `/harness code-review` skill) is
|
|
76
|
+
available and you prefer it, it can stand in for the correctness pass — but keep
|
|
77
|
+
a distinct adversarial pass alongside it. Wait for both to report before editing
|
|
78
|
+
anything, so you resolve in one pass and don't edit files a reviewer is still
|
|
79
|
+
reading.
|
|
80
|
+
|
|
81
|
+
### 2 · Resolve — fix confirmed findings, each with a regression test
|
|
82
|
+
|
|
83
|
+
For every **confirmed** finding, fix it and add a test that would have caught
|
|
84
|
+
it. A fix without a regression test is a fix that silently comes back. Rank by
|
|
85
|
+
severity and resolve top-down.
|
|
86
|
+
|
|
87
|
+
You do not have to accept every finding — but rejecting one is a technical
|
|
88
|
+
judgment you must be able to defend, not a convenience. When you skip a finding,
|
|
89
|
+
say why (e.g. "pre-existing, out of scope"; "the quadrant is empty and pinned by
|
|
90
|
+
an invariant test"). When a reviewer independently confirms another reviewer's
|
|
91
|
+
finding, treat that convergence as strong signal it's real.
|
|
92
|
+
|
|
93
|
+
Then re-run the affected suite **and** the full suite + lint. Green here is the
|
|
94
|
+
gate for phase 3 — a partial run is not confirmation.
|
|
95
|
+
|
|
96
|
+
### 3 · Commit — stage the change only, format, no co-author trailer
|
|
97
|
+
|
|
98
|
+
- **Stage precisely.** Add only the files that belong to this change. This repo
|
|
99
|
+
often carries local IDE/agent churn (`.claude/settings.json`, `.gitignore`,
|
|
100
|
+
editor caches) that is _not_ yours to commit — exclude it explicitly and
|
|
101
|
+
double-check `git status` before committing.
|
|
102
|
+
- **Format first.** Run `npx prettier --write` on any changed `*.md` / `*.yaml`
|
|
103
|
+
/ `*.ts` _before_ committing. Prettier is a **CI** gate here
|
|
104
|
+
(`npm run format:check` in `harness-quality.yml`), so an unformatted diff
|
|
105
|
+
commits fine but fails CI _after_ you push — format now to avoid the red
|
|
106
|
+
round-trip. (The pre-commit hook itself runs the roadmap-comment guard,
|
|
107
|
+
**markdownlint** on staged `.md`, and the security-ledger refresh — not
|
|
108
|
+
prettier.) Watch for prettier mangling inline code in prose (e.g. `foo(bar)` →
|
|
109
|
+
`foo (bar)`); reword to avoid a paren at a wrap boundary if it does.
|
|
110
|
+
- **Doc drift + roadmap.** Before committing, check whether the change made any
|
|
111
|
+
doc stale, and update `docs/roadmap.md` (mark the item done, with a summary of
|
|
112
|
+
what shipped and any reviewed-and-resolved findings). Shipping code with a
|
|
113
|
+
stale roadmap is drift this project specifically tracks.
|
|
114
|
+
- **Commit message:** conventional (`feat(scope): …`), imperative subject, body
|
|
115
|
+
explaining the _why_ and the review outcome. **No `Co-Authored-By` trailer** —
|
|
116
|
+
this project's standing preference. Let the pre-commit hooks run.
|
|
117
|
+
|
|
118
|
+
### 4 · PR — push and open with a structured body
|
|
119
|
+
|
|
120
|
+
`git push -u origin <branch>`, then `gh pr create --base main` with a body that
|
|
121
|
+
states **What / How / Review (findings resolved) / Tests**. The review section
|
|
122
|
+
is the honest part — name the findings the gate caught and how they were fixed.
|
|
123
|
+
Search for a PR template in `.github/` first and honor it if present.
|
|
124
|
+
|
|
125
|
+
### 5 · Merge — then watch CI, sync, and prune
|
|
126
|
+
|
|
127
|
+
`gh pr merge <n> --squash --auto --delete-branch` (this repo squashes).
|
|
128
|
+
Repo-level auto-merge is enabled (`allow_auto_merge: true`) and there are 13
|
|
129
|
+
required status checks, with `strict_required_status_checks_policy: true` on
|
|
130
|
+
ruleset 16189198 — so a PR cannot merge until its branch is up to date with
|
|
131
|
+
`main` and every required check is green. `--auto` is therefore the right
|
|
132
|
+
default: it lands the PR the moment both hold.
|
|
133
|
+
|
|
134
|
+
Auto-merge **waits**; it does not update the branch. Whenever `main` moves the
|
|
135
|
+
PR goes stale, so run `gh pr update-branch <n>` and let the checks re-run.
|
|
136
|
+
`--delete-branch` is still needed explicitly since the repo does not auto-delete
|
|
137
|
+
on merge.
|
|
138
|
+
|
|
139
|
+
Merging immediately means CI runs _post-merge_ — so "merge when green" is on
|
|
140
|
+
you: **watch the post-merge CI runs to completion**
|
|
141
|
+
(`gh run watch <id> --exit-status`, or poll `gh run list --branch main`) and
|
|
142
|
+
confirm every check passes. If one fails, that is a real regression on `main` —
|
|
143
|
+
fix it forward immediately, don't leave it red.
|
|
144
|
+
|
|
145
|
+
Then sync and clean up: `git checkout main && git pull --ff-only`, delete the
|
|
146
|
+
merged local branch, and `git fetch --prune`.
|
|
147
|
+
|
|
148
|
+
## Conventions this gate enforces (the point of packaging it)
|
|
149
|
+
|
|
150
|
+
| Convention | Why |
|
|
151
|
+
| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
|
|
152
|
+
| No `Co-Authored-By` trailer | Standing user preference for this repo |
|
|
153
|
+
| Squash merge (`--squash --auto --delete-branch`) | Repo squashes; auto-merge is on and `strict` is true, so queue it and update the branch when `main` moves; branches aren't auto-deleted |
|
|
154
|
+
| Prettier `--write` before commit | Formatting is a CI gate (`format:check`); avoids a red CI round-trip post-push |
|
|
155
|
+
| Exclude local IDE/agent churn from staging | `settings.json`/caches aren't part of the change |
|
|
156
|
+
| Update `docs/roadmap.md` + check doc drift | Drift this project explicitly tracks |
|
|
157
|
+
| Adversarial review before merge, not after | Cheapest defect-catch is before `main` |
|
|
158
|
+
|
|
159
|
+
## Rationalizations to reject
|
|
160
|
+
|
|
161
|
+
- _"It's a small change, skip the review."_ Small diffs introduce the caller
|
|
162
|
+
regressions no one looks for — the review is cheapest exactly when the change
|
|
163
|
+
looks trivial.
|
|
164
|
+
- _"Tests pass, just merge."_ Passing tests prove the paths you thought of. The
|
|
165
|
+
adversarial pass is for the paths you didn't.
|
|
166
|
+
- _"CI has no required checks, so I don't need to watch it."_ Then nothing else
|
|
167
|
+
will — post-merge red on `main` is yours to catch.
|
|
168
|
+
- _"I'll fix the doc/roadmap later."_ Later is how drift accumulates; it's one
|
|
169
|
+
edit now.
|
|
170
|
+
|
|
171
|
+
## Stop conditions
|
|
172
|
+
|
|
173
|
+
Stop and hand back to the human when: a confirmed finding needs a product
|
|
174
|
+
decision you can't make; the review surfaces a design flaw that warrants
|
|
175
|
+
re-scoping rather than patching; a merge conflict needs human judgment; or CI
|
|
176
|
+
fails for a reason you can't safely fix forward. Never force-merge past a red
|
|
177
|
+
gate or an unresolved confirmed finding.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
name: canary-ship
|
|
2
|
+
version: '1.0.0'
|
|
3
|
+
description: >-
|
|
4
|
+
Run the canary ship gate on a finished, locally-green change - spawn parallel
|
|
5
|
+
adversarial reviewers on the diff, resolve every confirmed finding with a
|
|
6
|
+
regression test, then commit, open a PR, and squash-merge while watching
|
|
7
|
+
CI to green. Bakes in this repo's conventions (no co-author trailer, squash,
|
|
8
|
+
prettier, exclude local IDE churn, update the roadmap).
|
|
9
|
+
stability: static
|
|
10
|
+
triggers:
|
|
11
|
+
- manual
|
|
12
|
+
platforms:
|
|
13
|
+
- claude-code
|
|
14
|
+
type: rigid
|
|
15
|
+
tools: []
|
|
16
|
+
tier: 2
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: canary-strix
|
|
3
|
+
description: >
|
|
4
|
+
Keeps company, client and consumer identifiers out of a public repository —
|
|
5
|
+
scanning both tracked file contents and the AUTHORSHIP of the commits a change
|
|
6
|
+
adds (author, committer, and `Co-authored-by:` trailers), because a company
|
|
7
|
+
email never appears in a file, only in commit metadata. Use when the user says
|
|
8
|
+
"is anything leaking into this public repo", "scan for client names", "did we
|
|
9
|
+
commit a company email", "open-source hygiene", or before making a repo
|
|
10
|
+
public. Deterministic — no LLM, no network, no execution. NOT a secret scanner
|
|
11
|
+
(use gitleaks/trufflehog for credentials); this is about IDENTIFIERS — who you
|
|
12
|
+
work for and who your customers are.
|
|
13
|
+
cli: scripts/cli.mjs
|
|
14
|
+
requires: [node>=20]
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# canary-strix
|
|
18
|
+
|
|
19
|
+
A public repository leaks who you work for in two places, and only one of them
|
|
20
|
+
is a file.
|
|
21
|
+
|
|
22
|
+
## The two surfaces
|
|
23
|
+
|
|
24
|
+
**Tracked file contents** (`STRIX-001`) — the obvious half. A client name in a
|
|
25
|
+
comment, a fixture, a changelog entry.
|
|
26
|
+
|
|
27
|
+
**Commit authorship** (`STRIX-002`) — the half that gets missed. A company email
|
|
28
|
+
never appears _in_ a tracked file; it lives in the metadata of the commit
|
|
29
|
+
carrying it. A tree can scan perfectly clean while every commit in it is stamped
|
|
30
|
+
with a company address. This is not hypothetical: canary itself accumulated 190
|
|
31
|
+
such commits, every one from a clone that inherited a global `user.email`
|
|
32
|
+
instead of the repo's pin.
|
|
33
|
+
|
|
34
|
+
The authorship scan reads the author, the committer, **and** `Co-authored-by:`
|
|
35
|
+
style trailers. GitHub renders a co-author as a linked contributor on the public
|
|
36
|
+
commit page, which makes a trailer a _more_ visible identity surface than the
|
|
37
|
+
author field.
|
|
38
|
+
|
|
39
|
+
## Where terms come from
|
|
40
|
+
|
|
41
|
+
Three sources, unioned. Which one is right depends on whether the repo is
|
|
42
|
+
public:
|
|
43
|
+
|
|
44
|
+
| Source | Committed? | Use when |
|
|
45
|
+
| ----------------------------------- | ---------- | -------------------------------------------- |
|
|
46
|
+
| `CANARY_PROPRIETARY_DENYLIST` env | no | **public repo** — supply it as a CI secret |
|
|
47
|
+
| `.proprietary-denylist` (gitignore) | no | **public repo** — the same list, at the desk |
|
|
48
|
+
| `.canary/company.json` | **yes** | **private repo only** |
|
|
49
|
+
|
|
50
|
+
`company.json` is the convenient option and the one to be careful with: it is
|
|
51
|
+
committed, so on a public repo it publishes exactly the list of identifiers you
|
|
52
|
+
are trying to hide. The CLI says so out loud when terms come from there.
|
|
53
|
+
|
|
54
|
+
Prefer **one term per line** in the secret. GitHub masks a multi-line secret
|
|
55
|
+
line by line, so each term is masked individually in a log; a comma-joined value
|
|
56
|
+
is only masked as the whole string, which is never the form that appears in
|
|
57
|
+
output.
|
|
58
|
+
|
|
59
|
+
## Zero terms is not a clean scan
|
|
60
|
+
|
|
61
|
+
With no terms configured this cannot match anything, whatever it is pointed at.
|
|
62
|
+
That is an abstention, and it exits **3** under `--strict` rather than reporting
|
|
63
|
+
a pass. The same applies when only half the scan ran — files read but commits
|
|
64
|
+
unreadable is a partial result, not a green one.
|
|
65
|
+
|
|
66
|
+
Findings never echo the matched value. On a public repo the CI log is
|
|
67
|
+
world-readable, so printing the identifier would publish the thing the scan
|
|
68
|
+
exists to protect, on exactly the lines that trip it. A finding names the file
|
|
69
|
+
and line, or the commit and which field matched, plus a local command to inspect
|
|
70
|
+
it.
|
|
71
|
+
|
|
72
|
+
## Matching
|
|
73
|
+
|
|
74
|
+
Terms are authored as prose (`Acme Health`, `Acme Inc.`) and subjects are not:
|
|
75
|
+
an email domain drops or changes the separator. A naive `\b<term>\b` misses
|
|
76
|
+
`dev@acmehealth.example`, misses `dev@acme-health.example`, and — for a term
|
|
77
|
+
ending in punctuation like `Acme Inc.` — matches nothing at all, ever, because
|
|
78
|
+
`\b` after `.` requires a word character. Those failures are silent and they
|
|
79
|
+
fail **open**.
|
|
80
|
+
|
|
81
|
+
So a term is split into alphanumeric tokens, joined with a separator-tolerant
|
|
82
|
+
gap, and bounded with unicode-aware lookarounds. Boundaries still hold: `Acme`
|
|
83
|
+
does not match `acmecorp`.
|
|
84
|
+
|
|
85
|
+
## Invocation
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
# Advisory scan of the current repo (exit 0 regardless):
|
|
89
|
+
canary skills run canary-strix
|
|
90
|
+
|
|
91
|
+
# Gate a CI step — findings or an abstention fail it:
|
|
92
|
+
canary skills run canary-strix -- --strict
|
|
93
|
+
|
|
94
|
+
# Files only, skipping commit authorship:
|
|
95
|
+
canary skills run canary-strix -- --files-only
|
|
96
|
+
|
|
97
|
+
# An explicit range instead of the CI event:
|
|
98
|
+
canary skills run canary-strix -- --range origin/main..HEAD
|
|
99
|
+
|
|
100
|
+
# Machine-readable:
|
|
101
|
+
canary skills run canary-strix -- --json
|
|
102
|
+
|
|
103
|
+
# Usage and the full flag list (exits 0):
|
|
104
|
+
canary skills run canary-strix -- --help
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
On CI the commit range is resolved from the event: `GITHUB_BASE_REF` (with
|
|
108
|
+
`GITHUB_PR_HEAD_SHA`) on a pull request, `GITHUB_EVENT_BEFORE` on a push. Pass
|
|
109
|
+
`GITHUB_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}` — without it the
|
|
110
|
+
range ends at the checked-out `refs/pull/N/merge` commit, which GitHub
|
|
111
|
+
synthesises per event and authors with the PR author's _account_ email. That
|
|
112
|
+
commit is discarded at merge, so scanning it reports a leak that cannot happen.
|
|
113
|
+
|
|
114
|
+
## Exit codes
|
|
115
|
+
|
|
116
|
+
| Code | Meaning |
|
|
117
|
+
| ---- | --------------------------------------------------------------- |
|
|
118
|
+
| 0 | advisory run, or `--strict` with a complete scan and no finding |
|
|
119
|
+
| 1 | `--strict`, findings |
|
|
120
|
+
| 2 | usage error |
|
|
121
|
+
| 3 | `--strict`, abstained — no terms, or only half the scan ran |
|
|
122
|
+
|
|
123
|
+
## Fidelity limits
|
|
124
|
+
|
|
125
|
+
Literal terms, not inference. It will not catch a client referred to only by a
|
|
126
|
+
nickname, an internal hostname that is not on the list, or a codename nobody
|
|
127
|
+
wrote down. The list is the denominator, and the CLI always prints it.
|
|
128
|
+
|
|
129
|
+
Secrets and credentials are out of scope — that is a different tool and a
|
|
130
|
+
different failure mode.
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// canary-strix -- keep company and consumer identifiers out of a public repo.
|
|
3
|
+
//
|
|
4
|
+
// --root <path> repository to scan (default: the current directory).
|
|
5
|
+
// --range <spec> commit range for the authorship scan; otherwise resolved
|
|
6
|
+
// from the CI event (GITHUB_BASE_REF / GITHUB_EVENT_BEFORE).
|
|
7
|
+
// --files-only skip the authorship scan.
|
|
8
|
+
// --json emit machine-readable findings instead of human text.
|
|
9
|
+
// --strict exit 1 when there are findings (default is advisory: 0).
|
|
10
|
+
//
|
|
11
|
+
// Deterministic: no LLM, no network, no execution.
|
|
12
|
+
//
|
|
13
|
+
// Invoked via `canary skills run canary-strix -- [--json] [--strict] ...`.
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
createParser,
|
|
17
|
+
formatUsageError,
|
|
18
|
+
EXIT_USAGE,
|
|
19
|
+
} from '../../../lib/parse-args.mjs';
|
|
20
|
+
import {
|
|
21
|
+
loadTerms,
|
|
22
|
+
DENYLIST_ENV,
|
|
23
|
+
DENYLIST_FILE,
|
|
24
|
+
COMPANY_FILE,
|
|
25
|
+
} from './terms.mjs';
|
|
26
|
+
import {
|
|
27
|
+
compileTerms,
|
|
28
|
+
scanFiles,
|
|
29
|
+
scanAuthorship,
|
|
30
|
+
resolveRange,
|
|
31
|
+
} from './scanner.mjs';
|
|
32
|
+
|
|
33
|
+
export const SCHEMA_VERSION = 1;
|
|
34
|
+
|
|
35
|
+
const PREFIX = 'canary-strix:';
|
|
36
|
+
|
|
37
|
+
/** Reserved CLI-wide: exit 3 means "abstained -- verified zero items". */
|
|
38
|
+
const EXIT_ABSTAINED = 3;
|
|
39
|
+
|
|
40
|
+
const ARROW = '\u{2192}';
|
|
41
|
+
|
|
42
|
+
const USAGE =
|
|
43
|
+
'usage: canary-strix [-h] [--json] [--strict] [--files-only] [--root PATH]\n' +
|
|
44
|
+
' [--range SPEC]\n' +
|
|
45
|
+
'\n' +
|
|
46
|
+
'Keep company and consumer identifiers out of a public repo.\n' +
|
|
47
|
+
'\n' +
|
|
48
|
+
'options:\n' +
|
|
49
|
+
' -h, --help show this help message and exit\n' +
|
|
50
|
+
' --root PATH repository to scan (default: the current directory)\n' +
|
|
51
|
+
' --range SPEC commit range for the authorship scan (default: the CI event)\n' +
|
|
52
|
+
' --files-only skip the authorship scan\n' +
|
|
53
|
+
' --json emit machine-readable findings instead of human text\n' +
|
|
54
|
+
' --strict exit 1 when there are findings (default is advisory: exit 0)\n' +
|
|
55
|
+
'\n' +
|
|
56
|
+
'rules:\n' +
|
|
57
|
+
' STRIX-001 company identifier in a tracked file\n' +
|
|
58
|
+
' STRIX-002 company identity on a commit (author, committer or trailer)\n' +
|
|
59
|
+
'\n' +
|
|
60
|
+
'terms come from, unioned:\n' +
|
|
61
|
+
` ${DENYLIST_ENV} a CI secret -- the only source safe for a PUBLIC repo\n` +
|
|
62
|
+
` ${DENYLIST_FILE} gitignored, so the desk catches it before a push\n` +
|
|
63
|
+
` ${COMPANY_FILE} committed: PRIVATE repos only\n` +
|
|
64
|
+
'\n' +
|
|
65
|
+
'ZERO TERMS IS NOT A CLEAN SCAN. With no terms configured this cannot match\n' +
|
|
66
|
+
'anything, and it exits 3 under --strict rather than reporting a pass.';
|
|
67
|
+
|
|
68
|
+
export const CLI_SPEC = {
|
|
69
|
+
prog: 'canary-strix',
|
|
70
|
+
booleans: {
|
|
71
|
+
'--json': 'json',
|
|
72
|
+
'--strict': 'strict',
|
|
73
|
+
'--files-only': 'filesOnly',
|
|
74
|
+
},
|
|
75
|
+
values: {
|
|
76
|
+
'--root': { key: 'root' },
|
|
77
|
+
'--range': { key: 'range' },
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const parseArgs = createParser(CLI_SPEC);
|
|
82
|
+
|
|
83
|
+
function analyse(opts, env = process.env) {
|
|
84
|
+
const root = opts.root ?? '.';
|
|
85
|
+
const { terms, sources, committedSource, implausible } = loadTerms(root, env);
|
|
86
|
+
|
|
87
|
+
// A denylist of zero terms matches nothing, whatever it is pointed at. That
|
|
88
|
+
// is an abstention in every environment -- the single most important line in
|
|
89
|
+
// this file, because the alternative reads exactly like a clean repo.
|
|
90
|
+
if (!terms.length) {
|
|
91
|
+
return {
|
|
92
|
+
abstained: 'no terms configured',
|
|
93
|
+
terms,
|
|
94
|
+
implausible,
|
|
95
|
+
sources,
|
|
96
|
+
implausible,
|
|
97
|
+
findings: [],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const matchers = compileTerms(terms);
|
|
102
|
+
const files = scanFiles(root, matchers);
|
|
103
|
+
if (files.unavailable) {
|
|
104
|
+
return {
|
|
105
|
+
abstained: files.unavailable,
|
|
106
|
+
terms,
|
|
107
|
+
sources,
|
|
108
|
+
implausible,
|
|
109
|
+
findings: [],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const findings = [...files.findings];
|
|
114
|
+
let authorship = { skipped: '--files-only' };
|
|
115
|
+
if (!opts.filesOnly) {
|
|
116
|
+
authorship = scanAuthorship(root, matchers, resolveRange(env, opts.range));
|
|
117
|
+
if (authorship.findings) findings.push(...authorship.findings);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
terms,
|
|
122
|
+
sources,
|
|
123
|
+
committedSource,
|
|
124
|
+
implausible,
|
|
125
|
+
files,
|
|
126
|
+
authorship,
|
|
127
|
+
findings,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Warns about denylist entries that do not look like identifiers.
|
|
133
|
+
*
|
|
134
|
+
* Junk in the denylist inflates the denominator, which is the half of #818
|
|
135
|
+
* that outlives the parser bug: `15 term(s)` read as fifteen protections when
|
|
136
|
+
* seven were real, on the last line of defence before a company name reaches a
|
|
137
|
+
* public repo.
|
|
138
|
+
*
|
|
139
|
+
* Reports SHAPE, never the value. The denylist holds exactly the names this
|
|
140
|
+
* scan exists to keep out of a public repo and this line goes to a CI log, so
|
|
141
|
+
* printing a term to complain about it would be the leak itself.
|
|
142
|
+
*/
|
|
143
|
+
function reportImplausible(terms) {
|
|
144
|
+
const odd = terms ?? [];
|
|
145
|
+
if (odd.length === 0) return;
|
|
146
|
+
const first = odd[0];
|
|
147
|
+
console.log(
|
|
148
|
+
`${PREFIX} WARNING: ${odd.length} denylist term(s) do not look like identifiers ` +
|
|
149
|
+
`(3+ all-lowercase words, e.g. ${first.split(/\s+/).length} words starting ` +
|
|
150
|
+
`${JSON.stringify(first.slice(0, 3))}). They are still matched; check the ` +
|
|
151
|
+
`denylist for stray prose.`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function reportJson(result) {
|
|
156
|
+
console.log(
|
|
157
|
+
JSON.stringify(
|
|
158
|
+
{
|
|
159
|
+
schema_version: SCHEMA_VERSION,
|
|
160
|
+
skill: 'canary-strix',
|
|
161
|
+
abstained: result.abstained ?? null,
|
|
162
|
+
term_count: result.terms.length,
|
|
163
|
+
sources: result.sources,
|
|
164
|
+
files_scanned: result.files?.scanned ?? 0,
|
|
165
|
+
commits_scanned: result.authorship?.scanned ?? 0,
|
|
166
|
+
findings: result.findings,
|
|
167
|
+
},
|
|
168
|
+
null,
|
|
169
|
+
2,
|
|
170
|
+
),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Caveats that qualify an otherwise-clean line. Never silent. */
|
|
175
|
+
function reportCaveats(result) {
|
|
176
|
+
if (result.committedSource) {
|
|
177
|
+
console.error(
|
|
178
|
+
`${PREFIX} note: terms were read from ${COMPANY_FILE}, which is ` +
|
|
179
|
+
'COMMITTED. On a public repo that publishes the list of identifiers ' +
|
|
180
|
+
`you are hiding -- use ${DENYLIST_ENV} or ${DENYLIST_FILE} instead.`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
if (result.authorship?.unavailable) {
|
|
184
|
+
console.error(
|
|
185
|
+
`${PREFIX} authorship scan could not run: ${result.authorship.unavailable}. ` +
|
|
186
|
+
'Files were scanned; commits were not.',
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function report(result, json) {
|
|
192
|
+
if (json) return reportJson(result);
|
|
193
|
+
|
|
194
|
+
if (result.abstained) {
|
|
195
|
+
console.error(
|
|
196
|
+
`${PREFIX} ABSTAINED -- ${result.abstained}.\n` +
|
|
197
|
+
'Nothing was matched, so this is not a clean result. Configure terms in ' +
|
|
198
|
+
`${DENYLIST_ENV}, ${DENYLIST_FILE}, or ${COMPANY_FILE}.`,
|
|
199
|
+
);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
for (const f of result.findings) {
|
|
204
|
+
const where = f.file ? `${f.file}:${f.line}` : f.commit;
|
|
205
|
+
const what = f.fields ? ` (${f.fields.join('/')})` : '';
|
|
206
|
+
console.log(`${f.rule} ${where}${what}\n ${ARROW} ${f.detail}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// The denominators, always -- a verdict without them cannot be checked.
|
|
210
|
+
console.log(
|
|
211
|
+
`${PREFIX} ${result.findings.length} finding(s) over ` +
|
|
212
|
+
`${result.files.scanned} file(s) and ` +
|
|
213
|
+
`${result.authorship?.scanned ?? 0} commit(s), ` +
|
|
214
|
+
`${result.terms.length} term(s) from ${result.sources.join(' + ')}.`,
|
|
215
|
+
);
|
|
216
|
+
reportImplausible(result.implausible);
|
|
217
|
+
reportCaveats(result);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function main(argv = [], env = process.env) {
|
|
221
|
+
const { opts, help, error } = parseArgs(argv);
|
|
222
|
+
|
|
223
|
+
if (help) {
|
|
224
|
+
console.log(USAGE);
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
if (error) {
|
|
228
|
+
console.error(formatUsageError(CLI_SPEC.prog, error));
|
|
229
|
+
return EXIT_USAGE;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const result = analyse(opts, env);
|
|
233
|
+
report(result, opts.json);
|
|
234
|
+
|
|
235
|
+
// Advisory by default: findings are loud, the exit is not. Under --strict the
|
|
236
|
+
// exit-code contract applies, and an abstention takes 3 -- distinct from 1,
|
|
237
|
+
// "found something real".
|
|
238
|
+
if (!opts.strict) return 0;
|
|
239
|
+
if (result.abstained) return EXIT_ABSTAINED;
|
|
240
|
+
if (result.findings.length) return 1;
|
|
241
|
+
// Files scanned but commits not: a PARTIAL scan. Zero findings over a half
|
|
242
|
+
// that never ran is not a pass, and exiting 0 here is precisely the
|
|
243
|
+
// silently-degraded green this skill exists to make impossible. Findings
|
|
244
|
+
// still outrank it above — something real is more informative than "one
|
|
245
|
+
// half was dark".
|
|
246
|
+
if (result.authorship?.unavailable) return EXIT_ABSTAINED;
|
|
247
|
+
return 0;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// `process.exitCode`, not `process.exit()`: a large `--json` payload exceeds
|
|
251
|
+
// the pipe buffer and `process.exit` tears the process down mid-write, leaving
|
|
252
|
+
// truncated JSON that still exits 0 (#791).
|
|
253
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
254
|
+
process.exitCode = main(process.argv.slice(2));
|
|
255
|
+
}
|