wizz-method 2.23.0 → 2.25.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/package.json +1 -1
- package/skills-registry.yaml +7 -1
- package/src/bmm-skills/1-analysis/wizz-document-project/references/static-dependencies.md +124 -0
- package/src/bmm-skills/1-analysis/wizz-document-project/scripts/dependencies.py +374 -0
- package/src/bmm-skills/1-analysis/wizz-document-project/scripts/test_dependencies.py +148 -0
- package/src/bmm-skills/1-analysis/wizz-document-project/scripts/test_dependencies_unit.py +183 -0
- package/src/bmm-skills/1-analysis/wizz-document-project/templates/deep-dive-template.md +9 -0
- package/src/bmm-skills/1-analysis/wizz-document-project/templates/index-template.md +9 -0
- package/src/bmm-skills/1-analysis/wizz-document-project/workflows/deep-dive-instructions.md +4 -0
- package/src/bmm-skills/1-analysis/wizz-document-project/workflows/full-scan-instructions.md +7 -1
- package/src/bmm-skills/4-implementation/wizz-code-review/customize.toml +19 -0
- package/src/bmm-skills/4-implementation/wizz-code-review/references/review-economy.md +5 -1
- package/src/bmm-skills/4-implementation/wizz-code-review/steps/step-01-gather-context.md +27 -2
- package/src/bmm-skills/4-implementation/wizz-code-review/steps/step-02-review.md +2 -0
- package/src/bmm-skills/4-implementation/wizz-dev-story/SKILL.md +1 -1
- package/src/bmm-skills/4-implementation/wizz-quick-dev/customize.toml +19 -0
- package/src/bmm-skills/4-implementation/wizz-quick-dev/references/review-economy.md +5 -1
- package/src/bmm-skills/4-implementation/wizz-quick-dev/step-04-review.md +1 -1
- package/src/modules/wizz/_shared/approval-gate.md +45 -0
- package/src/modules/wizz/_shared/approval-gate.mjs +184 -0
- package/src/modules/wizz/_shared/communication-rules.md +2 -0
- package/src/modules/wizz/_shared/model-ladder.md +12 -0
- package/src/modules/wizz/agents/wizz-ads/SKILL.md +1 -0
- package/src/modules/wizz/agents/wizz-copy/SKILL.md +1 -0
- package/src/modules/wizz/agents/wizz-growth/SKILL.md +1 -0
- package/src/modules/wizz/agents/wizz-maestro/SKILL.md +3 -1
- package/src/modules/wizz/agents/wizz-seo/SKILL.md +1 -0
- package/src/modules/wizz/agents/wizz-social/SKILL.md +7 -1
- package/src/modules/wizz/agents/wizz-social/customize.toml +4 -3
- package/src/modules/wizz/subagents/codex/wizz-exec-review.toml +2 -1
- package/src/modules/wizz/subagents/gemini/wizz-exec-review.md +2 -1
- package/src/modules/wizz/subagents/opencode/wizz-exec-review.md +2 -1
- package/src/modules/wizz/subagents/spec.yaml +4 -3
- package/src/modules/wizz/subagents/wizz-exec-review.md +2 -1
- package/src/skills-lib/wizz-router/references/routing-table-flat.md +1 -1
- package/src/skills-lib/wizz-security-audit-pentest/SKILL.md +50 -26
- package/src/skills-lib/wizz-security-audit-pentest/references/stage-5-segredos-historico.md +74 -0
- package/src/skills-lib/wizz-security-audit-pentest/references/stage-6-advogado-do-diabo.md +33 -0
- package/src/skills-lib/wizz-security-audit-pentest/references/stage-7-fusao-plano.md +50 -0
- package/tools/installer/README.md +5 -0
- package/tools/installer/commands/templates.js +65 -0
- package/tools/installer/core/resolved-schema.js +5 -3
- package/tools/installer/modules/templates.js +229 -0
- package/tools/installer/templates.md +156 -0
- package/src/skills-lib/wizz-security-audit-pentest/references/stage-5-fusao-plano.md +0 -27
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Stage 5 — Secret hunter (working tree and git history)
|
|
2
|
+
|
|
3
|
+
You are a secret-hunting analyst ("Caçador de Segredos").
|
|
4
|
+
|
|
5
|
+
Premise: deleting a secret in a later commit does NOT erase it from git history. Anyone who clones the repository or accesses an exposed `.git` directory can recover every historic blob. At the same time, an audit must NEVER become a second leak: you must prove the secret's existence and location **without ever printing or recording the raw secret value**.
|
|
6
|
+
|
|
7
|
+
## Mandatory non-exposure rule (zero plaintext secrets)
|
|
8
|
+
|
|
9
|
+
- **NEVER print, echo, or copy the full secret value** into terminal output, stage tables, evidence columns, or the final report.
|
|
10
|
+
- When inspecting git history, use only the executable blocks below. They emit hashes, dates and file names, with **no commit subject/body, author, notes or patch**. Never use `--oneline`, `%s`, `%B`, or plain `git show --stat`: a commit message or author may contain the secret too.
|
|
11
|
+
- **NEVER run `git show -p`, `git log -p`, `git diff`, or any patch command that dumps raw secret values into stdout.**
|
|
12
|
+
- The current-tree command emits **file names only**, including tracked and non-ignored untracked text files. Do not switch to `grep -n` or `git grep -n`, which print matching secret-bearing lines. Any follow-up must preserve this output restriction; use `<file>` when a safe line number is unavailable.
|
|
13
|
+
|
|
14
|
+
Run each marked block from the audited repository root with Bash. Exit status 1 from `git grep` means no matches; other errors must stop the scan. These patterns detect candidates, not proof of validity. The long generic `sk-` pattern and token boundary avoid ordinary `risk-`, `task-` and `disk-` words. Short, unusual or ignored credentials still need a scoped investigation with the same non-exposure rule.
|
|
15
|
+
|
|
16
|
+
```bash audit-current-secrets
|
|
17
|
+
set -eu
|
|
18
|
+
secret_pattern='(^|[^A-Za-z0-9_])(sk-[A-Za-z0-9]{20,}|sk-ant-[A-Za-z0-9_-]{20,}|sk_live_[A-Za-z0-9]{20,}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[bp]-[A-Za-z0-9-]{20,}|AKIA[A-Z0-9]{16}|AIza[A-Za-z0-9_-]{20,}|eyJhbGciOi[A-Za-z0-9_-]{20,})'
|
|
19
|
+
if git --no-pager grep --untracked --exclude-standard -l -I -E "$secret_pattern" -- .; then
|
|
20
|
+
:
|
|
21
|
+
else
|
|
22
|
+
result=$?
|
|
23
|
+
if [ "$result" -ne 1 ]; then exit "$result"; fi
|
|
24
|
+
fi
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```bash audit-history-secrets
|
|
28
|
+
set -eu
|
|
29
|
+
secret_pattern='(^|[^A-Za-z0-9_])(sk-[A-Za-z0-9]{20,}|sk-ant-[A-Za-z0-9_-]{20,}|sk_live_[A-Za-z0-9]{20,}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[bp]-[A-Za-z0-9-]{20,}|AKIA[A-Z0-9]{16}|AIza[A-Za-z0-9_-]{20,}|eyJhbGciOi[A-Za-z0-9_-]{20,})'
|
|
30
|
+
git --no-pager log --all --no-ext-diff --no-textconv --no-show-signature --no-notes --no-decorate --pickaxe-regex -S "$secret_pattern" --format='%h %ad' --date=short --name-only
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Mandatory prefix sweep
|
|
34
|
+
|
|
35
|
+
Hunt for all of the following prefixes in both the working tree and the full git history (`--all`):
|
|
36
|
+
|
|
37
|
+
1. `sk-` — OpenAI and generic secret API keys
|
|
38
|
+
2. `sk-ant-` — Anthropic API keys
|
|
39
|
+
3. `sk_live_` — Stripe live secret keys
|
|
40
|
+
4. `ghp_` — GitHub classic personal access tokens
|
|
41
|
+
5. `github_pat_` — GitHub fine-grained personal access tokens
|
|
42
|
+
6. `xox[bp]-` (`xoxb-` and `xoxp-`) — Slack bot and user tokens
|
|
43
|
+
7. `AKIA` — AWS IAM long-term access key IDs
|
|
44
|
+
8. `AIza` — Google Cloud / Firebase / Gemini API keys
|
|
45
|
+
9. `eyJhbGciOi` — Base64URL-encoded JWT headers (`{"alg":"...`)
|
|
46
|
+
|
|
47
|
+
Also inventory tracked environment/key files, including root `.env` and nested files. The explicit `:(glob)**/` pathspec includes the repository root. Inventory alone is not a vulnerability: verify whether the file is a template/dummy without dumping its contents.
|
|
48
|
+
|
|
49
|
+
```bash audit-current-key-files
|
|
50
|
+
set -eu
|
|
51
|
+
git ls-files -- ':(glob)**/.env*' ':(glob)**/*.pem' ':(glob)**/*.key'
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```bash audit-history-key-files
|
|
55
|
+
set -eu
|
|
56
|
+
git --no-pager log --all --full-history --no-ext-diff --no-textconv --no-show-signature --no-notes --no-decorate --format='%h %ad' --date=short --name-only -- ':(glob)**/.env*' ':(glob)**/*.pem' ':(glob)**/*.key'
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## How to fill the table safely
|
|
60
|
+
|
|
61
|
+
- **Where (`file:line` or `commit:file`):** For working-tree findings, write `path/to/file:line`. For git-history findings, write `commit <short-sha> (path/to/file)`.
|
|
62
|
+
- **How it's exploited:** State the concrete extraction and abuse path using the redacted prefix (for example: "Extract commit `<short-sha>` affecting `config/prod.ts` from git history (`sk_live_[REDACTED]`) and authenticate directly against the Stripe API to list charges or issue refunds"). **Never paste the real secret value.**
|
|
63
|
+
- **What prevents it:** Immediate key revocation/rotation at the provider, removal from code in favor of secret manager / environment injection (invoke the `wizz-auth-and-secrets` skill), and history scrubbing (`git filter-repo`).
|
|
64
|
+
- **Severity:** Live production credentials or unrevoked secret keys in history are `CRITICAL`; scoped/secondary keys are `HIGH`; ambiguous non-dummy tokens requiring rotation verification are `MEDIUM` or `HIGH`.
|
|
65
|
+
|
|
66
|
+
Output format — markdown table, no text before or after:
|
|
67
|
+
|
|
68
|
+
| # | Risk | Where (file:line) | How it's exploited | What prevents it | Severity |
|
|
69
|
+
|
|
70
|
+
Severity: CRITICAL, HIGH, MEDIUM.
|
|
71
|
+
|
|
72
|
+
CUTOFF RULE: if you can't fill in "How it's exploited" with a concrete, specific action and a verified `file:line` or `commit:file` match, DON'T include the row. Known dummy placeholders in test fixtures (such as `sk-test-000000` or `<YOUR_API_KEY>`) are not findings.
|
|
73
|
+
|
|
74
|
+
If there's no finding that passes this ruler, answer only: NO FINDINGS AT THIS STAGE.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Stage 6 — Devil's Advocate (false-positive and severity review)
|
|
2
|
+
|
|
3
|
+
You are the Devil's Advocate ("Advogado do Diabo").
|
|
4
|
+
|
|
5
|
+
You receive the raw findings tables from Hunting Stages 1–5 (input validation, authorization failure, volume abuse, response leakage, and secret hunter). Your job is to challenge every finding against the actual repository code so that real vulnerabilities survive with verified evidence and false positives are refuted with proof.
|
|
6
|
+
|
|
7
|
+
## Allowed verdicts (mandatory)
|
|
8
|
+
|
|
9
|
+
Assign exactly one of these three verdicts to every finding:
|
|
10
|
+
|
|
11
|
+
- **`CONFIRMADO`**: The vulnerable code path or leaked secret prefix exists at the cited location, no in-repository control neutralizes the exploit path, and the concrete exploitation proof holds.
|
|
12
|
+
- **`DEPENDE DE CONFIGURAÇÃO`**: The repository code alone does not prevent exploitation, and safety depends on external or runtime state that cannot be verified from versioned files alone (for example: live database RLS policies not captured in migrations, WAF / CDN / API Gateway rate limits, reverse-proxy `trust proxy` configuration, production environment variables, or whether a historic secret found in git history has already been revoked at the provider).
|
|
13
|
+
- **`DERRUBADO`**: Concrete evidence **inside the repository** (`file:line`) disproves the finding (for example: an upstream router middleware already enforces session ownership, a strict schema validator strips unallowed fields before the database write, queries are parameterized, or a matched token prefix is an explicit dummy fixture). Rows that violated the hunting cutoff ruler (vague "best practice" wishes with no concrete payload or location) are also marked `DERRUBADO`.
|
|
14
|
+
|
|
15
|
+
## Safe severity and evidence rules (mandatory)
|
|
16
|
+
|
|
17
|
+
1. **One-way severity ratchet (can only lower, never raise):**
|
|
18
|
+
- You may keep the original severity or **lower** it (`CRITICAL` → `HIGH` or `MEDIUM`; `HIGH` → `MEDIUM`) **only** when concrete in-repository mitigation reduces the blast radius or exploitability.
|
|
19
|
+
- You may **NEVER increase** a finding's severity.
|
|
20
|
+
2. **Absence of configuration does NOT prove a false positive (divergence from naive "when in doubt, dismiss"):**
|
|
21
|
+
- Do **NOT** mark a finding `DERRUBADO` and do **NOT** lower its severity merely because external configuration might exist or cannot be seen in the repository.
|
|
22
|
+
- Missing configuration in the repo, unverified cloud/dashboard settings, or uncertainty about external infrastructure is **never** proof of a false positive. Whenever a finding's refutation depends on unverified external configuration, you MUST classify it as `DEPENDE DE CONFIGURAÇÃO` and **preserve its severity** until confirmed in the live environment.
|
|
23
|
+
- Only mark a finding `DERRUBADO` when you can cite the exact `file:line` in the repository that blocks the exploit, or when the hunter failed to provide a concrete exploit/location.
|
|
24
|
+
3. **Safe evidence handling:**
|
|
25
|
+
- Never print or echo raw secret values when verifying Stage 5 findings. Refer only to commit hashes, `file:line`, and redacted prefixes (`sk-ant-[REDACTED]`).
|
|
26
|
+
|
|
27
|
+
## Output format
|
|
28
|
+
|
|
29
|
+
Output a single markdown table covering every finding received from Stages 1–5, with no text before or after:
|
|
30
|
+
|
|
31
|
+
| # | Stage | Original Finding (Where) | Verdict (`CONFIRMADO` / `DEPENDE DE CONFIGURAÇÃO` / `DERRUBADO`) | Evidence (`file:line` that confirms/refutes, or live config to verify) | Original Severity | Final Severity |
|
|
32
|
+
|
|
33
|
+
If all five hunting stages returned `NO FINDINGS AT THIS STAGE`, answer only: `NO FINDINGS TO REVIEW`.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Stage 7 — Merge into a single plan and private external report (`SEGURANCA-<data>/`)
|
|
2
|
+
|
|
3
|
+
Below I will paste the five hunting reports (input validation, authorization failure, volume abuse, response leakage, secret hunter) and the Stage 6 Devil's Advocate review table.
|
|
4
|
+
|
|
5
|
+
## Step 1 — Isolate the report outside the audited project BEFORE writing any file
|
|
6
|
+
|
|
7
|
+
Audit reports contain concrete exploit payloads, vulnerable `file:line` coordinates, and commit hashes of leaked secrets. Keep them outside git, deploy inputs, static hosting roots and archives of the audited project. A nested `.gitignore` alone does **not** protect against deployment or zip.
|
|
8
|
+
|
|
9
|
+
Choose an existing private absolute parent directory outside those inputs and set `AUDIT_REPORT_PARENT` to it. Run this block from the audited project root. It resolves symlinks in the parent, rejects an in-project parent, creates the directory exclusively with private permissions, and writes `.gitignore` before any artifact. An existing directory, including a second run on the same date, fails without changing the earlier report; select a different private parent instead of overwriting it.
|
|
10
|
+
|
|
11
|
+
```bash audit-report-init
|
|
12
|
+
set -eu
|
|
13
|
+
: "${AUDIT_REPORT_PARENT:?set an existing private absolute report parent outside the project}"
|
|
14
|
+
case "$AUDIT_REPORT_PARENT" in /*) ;; *) echo 'report parent must be absolute' >&2; exit 1 ;; esac
|
|
15
|
+
project_root=$(pwd -P)
|
|
16
|
+
report_parent=$(cd "$AUDIT_REPORT_PARENT" && pwd -P)
|
|
17
|
+
case "$report_parent/" in "$project_root/"*) echo 'report parent must be outside the audited project' >&2; exit 1 ;; esac
|
|
18
|
+
report_dir="$report_parent/SEGURANCA-$(date +%F)"
|
|
19
|
+
umask 077
|
|
20
|
+
mkdir -m 700 "$report_dir"
|
|
21
|
+
printf '*\n' > "$report_dir/.gitignore"
|
|
22
|
+
if git -C "$report_dir" rev-parse --show-toplevel >/dev/null 2>&1; then
|
|
23
|
+
git -C "$report_dir" check-ignore -q -- RELATORIO.md
|
|
24
|
+
fi
|
|
25
|
+
printf '%s\n' "$report_dir"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Verify `.gitignore` contains exactly `*`, then write `RELATORIO.md` into the returned directory. Never include raw secret values. Keep this absolute path private when delivering the report. Do not copy the report back into the project or a publishing/packaging input.
|
|
29
|
+
|
|
30
|
+
## Step 2 — Merge the reviewed findings into a single remediation plan
|
|
31
|
+
|
|
32
|
+
Use the Stage 6 Devil's Advocate verdicts to filter and structure the plan:
|
|
33
|
+
|
|
34
|
+
1. **Keep active findings (`CONFIRMADO` and `DEPENDE DE CONFIGURAÇÃO`):**
|
|
35
|
+
- Findings with verdict `CONFIRMADO` enter the remediation plan at their Stage 6 `Final Severity`.
|
|
36
|
+
- Findings with verdict `DEPENDE DE CONFIGURAÇÃO` remain active in the plan at their preserved severity, clearly tagged with the exact live configuration or provider state that must be verified (absence of configuration in the repository never dismisses a finding).
|
|
37
|
+
- Findings with verdict `DERRUBADO` are excluded from the active remediation queue, but preserved in a dedicated "Dismissed by Devil's Advocate (`DERRUBADO`)" appendix with the refuting `file:line` evidence for auditability.
|
|
38
|
+
2. **Remove duplicates across stages:**
|
|
39
|
+
- The same root issue may appear in more than one stage under different angles (for example, a leaked key in Stage 5 and its authorization impact in Stage 2). Merge into a single item citing all originating stages.
|
|
40
|
+
3. **Order by REMEDIATION EFFORT divided by SEVERITY:**
|
|
41
|
+
- First: `CRITICAL` findings that require a small, localized fix.
|
|
42
|
+
- Last: `MEDIUM` findings that require broader refactoring.
|
|
43
|
+
4. **Identify ATTACK CHAINS:**
|
|
44
|
+
- Highlight where two `MEDIUM` or `HIGH` findings combine into a `CRITICAL` path. Example: user enumeration on login (Stage 4) combined with missing rate limiting on the same route (Stage 3) forms a single critical chain worth more than the sum of its parts.
|
|
45
|
+
5. **Close with "TODAY'S TOP THREE" ("AS TRÊS DE HOJE"):**
|
|
46
|
+
- List the three items that must be fixed or verified before anything else, with a one-line justification for each.
|
|
47
|
+
6. **Attach stage tables:**
|
|
48
|
+
- Append the raw tables from Stages 1–5 and the complete Stage 6 Devil's Advocate table at the end of `SEGURANCA-<data>/RELATORIO.md`.
|
|
49
|
+
|
|
50
|
+
[paste the 5 hunting reports and the Stage 6 Devil's Advocate table below]
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Wizz Method CLI Tool
|
|
2
2
|
|
|
3
|
+
## Versioned templates
|
|
4
|
+
|
|
5
|
+
Use `wizz templates` to validate or stage an inert package and return its `APPLY.md`.
|
|
6
|
+
See [the template contract](templates.md) for authoring, registry entries and safety limits.
|
|
7
|
+
|
|
3
8
|
## Installing external repo official modules
|
|
4
9
|
|
|
5
10
|
For external official modules to be discoverable during install, ensure an entry for the external repo is added to external-official-modules.yaml.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const path = require('node:path');
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const yaml = require('yaml');
|
|
4
|
+
const { getProjectRoot } = require('../project-root');
|
|
5
|
+
const { loadTemplate, stageTemplate, safePath, validateTemplateEntry } = require('../modules/templates');
|
|
6
|
+
|
|
7
|
+
function runTemplates(options, packageRoot = getProjectRoot()) {
|
|
8
|
+
const actions = [options.list, options.validate, options.use].filter(Boolean);
|
|
9
|
+
if (actions.length !== 1) throw new Error('Choose exactly one: --list, --validate <directory>, --use <id>');
|
|
10
|
+
if (options.validate) {
|
|
11
|
+
const { manifest, digest } = loadTemplate(path.resolve(options.validate), { sha256: options.sha256, version: options.templateVersion });
|
|
12
|
+
return { valid: true, slug: manifest.slug, version: manifest.version, sha256: digest, files: manifest.files.length };
|
|
13
|
+
}
|
|
14
|
+
const registry = yaml.parse(fs.readFileSync(path.join(packageRoot, 'skills-registry.yaml'), 'utf8'));
|
|
15
|
+
if (!registry || typeof registry !== 'object' || Array.isArray(registry)) throw new Error('Registry must be an object');
|
|
16
|
+
const entries = registry.templates === undefined ? [] : registry.templates;
|
|
17
|
+
if (!Array.isArray(entries)) throw new Error('Registry templates must be an array');
|
|
18
|
+
const seen = new Set();
|
|
19
|
+
for (const entry of entries) {
|
|
20
|
+
validateTemplateEntry(entry);
|
|
21
|
+
if (seen.has(entry.id)) throw new Error('Duplicate template registry id');
|
|
22
|
+
seen.add(entry.id);
|
|
23
|
+
}
|
|
24
|
+
if (options.list) return entries.map(({ id, version, when }) => ({ id, type: 'template', version, when }));
|
|
25
|
+
const entry = entries.find((item) => item.id === options.use);
|
|
26
|
+
if (entry && options.source) throw new Error('Registry pin cannot be overridden with --source; use a distinct local id');
|
|
27
|
+
if (!entry && !options.source) throw new Error('Unknown template id; use --list or provide --source and --sha256');
|
|
28
|
+
if (options.source && !options.sha256) throw new Error('--source requires a trusted --sha256 for manifest.json');
|
|
29
|
+
if (!options.source && entry.type !== 'template') throw new Error('Registry entry is not a template');
|
|
30
|
+
if (!options.source && options.templateVersion && options.templateVersion !== entry.version)
|
|
31
|
+
throw new Error('Requested version differs from registry pin');
|
|
32
|
+
if (!options.source && options.sha256 && options.sha256 !== entry.sha256) throw new Error('Requested hash differs from registry pin');
|
|
33
|
+
const source = options.source ? path.resolve(options.source) : path.join(packageRoot, safePath(entry.source));
|
|
34
|
+
return stageTemplate(source, path.resolve(options.project || '.'), {
|
|
35
|
+
bundledRoot: options.source ? undefined : packageRoot,
|
|
36
|
+
id: options.use,
|
|
37
|
+
version: options.source ? options.templateVersion : entry.version,
|
|
38
|
+
sha256: options.source ? options.sha256 : entry.sha256,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = {
|
|
43
|
+
command: 'templates',
|
|
44
|
+
description: 'Validate or stage an inert versioned template; read APPLY.md to apply it manually',
|
|
45
|
+
options: [
|
|
46
|
+
['--list', 'List opt-in registry templates'],
|
|
47
|
+
['--validate <directory>', 'Validate a local template without writing to the project'],
|
|
48
|
+
['--use <id>', 'Stage a template in the project cache; never apply or execute it'],
|
|
49
|
+
['--source <directory>', 'Explicit local source (requires --sha256)'],
|
|
50
|
+
['--sha256 <hash>', 'Trusted SHA-256 of manifest.json'],
|
|
51
|
+
['--template-version <version>', 'Require this exact version'],
|
|
52
|
+
['--project <directory>', 'Existing project directory (default: current directory)'],
|
|
53
|
+
['--json', 'Print structured output'],
|
|
54
|
+
],
|
|
55
|
+
action: (options) => {
|
|
56
|
+
try {
|
|
57
|
+
const result = runTemplates(options);
|
|
58
|
+
console.log(options.json ? JSON.stringify(result) : JSON.stringify(result, null, 2));
|
|
59
|
+
} catch (error) {
|
|
60
|
+
console.error(options.json ? JSON.stringify({ error: error.message }) : `Template error: ${error.message}`);
|
|
61
|
+
process.exitCode = 1;
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
runTemplates,
|
|
65
|
+
};
|
|
@@ -79,9 +79,11 @@ const WORKFLOW_KEYS = new Set([
|
|
|
79
79
|
// item keys are validated against REVIEW_LAYER_KEYS below.
|
|
80
80
|
'review_layers',
|
|
81
81
|
'oneshot_review_layers',
|
|
82
|
-
// review economy (review-economy.md): diff cleaning excludes
|
|
82
|
+
// review economy (review-economy.md): diff cleaning excludes, critical
|
|
83
|
+
// paths (plain-language guidance) and critical globs (fixed floor).
|
|
83
84
|
'review_exclude',
|
|
84
85
|
'review_critical_paths',
|
|
86
|
+
'review_critical_globs',
|
|
85
87
|
]);
|
|
86
88
|
const PARTY_MEMBER_KEYS = new Set(['code', 'name', 'icon', 'title', 'persona', 'capabilities', 'model']);
|
|
87
89
|
const PARTY_GROUP_KEYS = new Set(['id', 'name', 'scene', 'members', 'memory']);
|
|
@@ -104,7 +106,7 @@ function isArrayOfStrings(value) {
|
|
|
104
106
|
|
|
105
107
|
/**
|
|
106
108
|
* Collect unknown/invalid entries under `[workflow]`: unknown fields by
|
|
107
|
-
* name, invalid values for the
|
|
109
|
+
* name, invalid values for the three review-economy string-array fields, and
|
|
108
110
|
* — for the arrays-of-tables we own (party members/groups, review layers) —
|
|
109
111
|
* the unknown keys and invalid values of each item.
|
|
110
112
|
*
|
|
@@ -122,7 +124,7 @@ function findUnknownWorkflowKeys(workflow) {
|
|
|
122
124
|
unknown.push({ path: wfPath, kind: 'unknown' });
|
|
123
125
|
continue;
|
|
124
126
|
}
|
|
125
|
-
if (k2 === 'review_exclude' || k2 === 'review_critical_paths') {
|
|
127
|
+
if (k2 === 'review_exclude' || k2 === 'review_critical_paths' || k2 === 'review_critical_globs') {
|
|
126
128
|
if (!isArrayOfStrings(v2)) {
|
|
127
129
|
unknown.push({ path: wfPath, kind: 'invalid', detail: `expected an array of strings, got ${JSON.stringify(v2)}` });
|
|
128
130
|
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Template packages are inert data. Only explicit files enter the project cache;
|
|
2
|
+
// APPLY.md is handed to an agent, never executed by the installer.
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
const semver = require('semver');
|
|
7
|
+
|
|
8
|
+
const LIMITS = Object.freeze({ manifest: 256 * 1024, file: 25 * 1024 * 1024, total: 100 * 1024 * 1024, files: 1000 });
|
|
9
|
+
const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
10
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
11
|
+
const POLICY = Object.freeze({ conflicts: 'stop-or-prefix', provenance: 'required', verification: 'required', execution: 'manual' });
|
|
12
|
+
const APPLY_RULES = [
|
|
13
|
+
'<!-- WIZZ_TEMPLATE_POLICY_V1 -->',
|
|
14
|
+
'Never overwrite an existing project file. On conflict, stop and ask the human, or use a human-approved prefix in both the filename and the skill name: frontmatter field.',
|
|
15
|
+
'Record the provenance line returned by the CLI (origin, slug, version and manifest SHA-256) in the applied project.',
|
|
16
|
+
'Verify the adapted files and project checks after applying; report what passed and what remains unverified.',
|
|
17
|
+
'Installation is inert. Never run paid, expensive, publishing or credential operations just because this template was installed; obtain explicit human authorization first.',
|
|
18
|
+
'<!-- /WIZZ_TEMPLATE_POLICY_V1 -->',
|
|
19
|
+
'',
|
|
20
|
+
].join('\n');
|
|
21
|
+
const hash = (data) => crypto.createHash('sha256').update(data).digest('hex');
|
|
22
|
+
|
|
23
|
+
function object(value) {
|
|
24
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function keys(value, allowed, label) {
|
|
28
|
+
if (!object(value)) throw new Error(`${label} must be an object`);
|
|
29
|
+
for (const key of Object.keys(value)) if (!allowed.includes(key)) throw new Error(`${label}: unknown key ${key}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Portable paths, including on case-insensitive Windows/macOS filesystems.
|
|
33
|
+
function safePath(value) {
|
|
34
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 240) throw new Error('Invalid template path');
|
|
35
|
+
const parts = value.split('/');
|
|
36
|
+
if (
|
|
37
|
+
parts.some(
|
|
38
|
+
(part) =>
|
|
39
|
+
!/^[\w.-]+$/.test(part) ||
|
|
40
|
+
part === '.' ||
|
|
41
|
+
part === '..' ||
|
|
42
|
+
/[. ]$/.test(part) ||
|
|
43
|
+
/^(?:con|prn|aux|nul|com[0-9]|lpt[0-9])(?:\.|$)/i.test(part),
|
|
44
|
+
)
|
|
45
|
+
) {
|
|
46
|
+
throw new Error(`Unsafe template path: ${value}`);
|
|
47
|
+
}
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function validateTemplateEntry(entry) {
|
|
52
|
+
keys(entry, ['id', 'type', 'when', 'version', 'source', 'sha256', 'metadata'], 'template registry entry');
|
|
53
|
+
if (entry.type !== 'template' || typeof entry.id !== 'string' || entry.id.length > 64 || !SLUG.test(entry.id))
|
|
54
|
+
throw new Error('Invalid template registry id/type');
|
|
55
|
+
if (typeof entry.when !== 'string' || !entry.when.trim()) throw new Error('Template registry when is required');
|
|
56
|
+
if (typeof entry.version !== 'string' || semver.valid(entry.version) !== entry.version)
|
|
57
|
+
throw new Error('Invalid template registry version');
|
|
58
|
+
safePath(entry.source);
|
|
59
|
+
if (typeof entry.sha256 !== 'string' || !SHA256.test(entry.sha256)) throw new Error('Invalid template registry sha256');
|
|
60
|
+
if (entry.metadata !== undefined) {
|
|
61
|
+
keys(entry.metadata, ['version'], 'template metadata');
|
|
62
|
+
if (entry.metadata.version !== entry.version) throw new Error('Template metadata.version must match version');
|
|
63
|
+
}
|
|
64
|
+
return entry;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function validateManifest(manifest) {
|
|
68
|
+
keys(manifest, ['schema_version', 'type', 'slug', 'version', 'origin', 'instructions', 'policy', 'files'], 'manifest');
|
|
69
|
+
if (manifest.schema_version !== 1 || manifest.type !== 'template') throw new Error('Expected template schema_version 1');
|
|
70
|
+
if (typeof manifest.slug !== 'string' || manifest.slug.length > 64 || !SLUG.test(manifest.slug)) throw new Error('Invalid template slug');
|
|
71
|
+
if (typeof manifest.version !== 'string' || semver.valid(manifest.version) !== manifest.version)
|
|
72
|
+
throw new Error('Invalid template version');
|
|
73
|
+
if (typeof manifest.origin !== 'string' || !/^https:\/\/[^\s]+$/.test(manifest.origin) || /[^\u0021-\u007E]/.test(manifest.origin))
|
|
74
|
+
throw new Error('Template origin must be an HTTPS attribution URL');
|
|
75
|
+
const origin = new URL(manifest.origin);
|
|
76
|
+
if (origin.username || origin.password || manifest.origin.includes(';'))
|
|
77
|
+
throw new Error('Origin cannot contain credentials or provenance delimiters');
|
|
78
|
+
if (manifest.instructions !== 'APPLY.md') throw new Error('Template instructions must be APPLY.md');
|
|
79
|
+
keys(manifest.policy, Object.keys(POLICY), 'policy');
|
|
80
|
+
for (const [key, value] of Object.entries(POLICY))
|
|
81
|
+
if (manifest.policy[key] !== value) throw new Error(`Required template policy: ${key}=${value}`);
|
|
82
|
+
if (!Array.isArray(manifest.files) || manifest.files.length === 0 || manifest.files.length > LIMITS.files)
|
|
83
|
+
throw new Error('Invalid template file count');
|
|
84
|
+
const seen = new Set(['manifest.json', 'origin.json']);
|
|
85
|
+
let total = 0;
|
|
86
|
+
for (const file of manifest.files) {
|
|
87
|
+
keys(file, ['path', 'size', 'sha256'], 'file');
|
|
88
|
+
safePath(file.path);
|
|
89
|
+
const folded = file.path.toLowerCase();
|
|
90
|
+
if (seen.has(folded)) throw new Error(`Duplicate/reserved template path: ${file.path}`);
|
|
91
|
+
seen.add(folded);
|
|
92
|
+
if (!Number.isSafeInteger(file.size) || file.size < 0 || file.size > LIMITS.file) throw new Error('Invalid template file size');
|
|
93
|
+
if (!SHA256.test(file.sha256)) throw new Error('Invalid template file sha256');
|
|
94
|
+
total += file.size;
|
|
95
|
+
}
|
|
96
|
+
for (const name of seen) {
|
|
97
|
+
const parts = name.split('/');
|
|
98
|
+
while (parts.length > 1) {
|
|
99
|
+
parts.pop();
|
|
100
|
+
if (seen.has(parts.join('/'))) throw new Error('Template file/directory path collision');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (total > LIMITS.total) throw new Error('Template exceeds expanded size limit');
|
|
104
|
+
if (!manifest.files.some((file) => file.path === 'APPLY.md')) throw new Error('APPLY.md must be explicitly listed');
|
|
105
|
+
return manifest;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Reject links in every relative path component, then open without following a
|
|
109
|
+
// final symlink. Read at most the declared size + 1, even if the source grows.
|
|
110
|
+
function readFile(root, relative, maxSize, allowHardlinks = false) {
|
|
111
|
+
let current = root;
|
|
112
|
+
for (const part of safePath(relative).split('/')) {
|
|
113
|
+
current = path.join(current, part);
|
|
114
|
+
const stat = fs.lstatSync(current);
|
|
115
|
+
if (stat.isSymbolicLink()) throw new Error(`Template symlink rejected: ${relative}`);
|
|
116
|
+
}
|
|
117
|
+
const fd = fs.openSync(current, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0));
|
|
118
|
+
try {
|
|
119
|
+
const stat = fs.fstatSync(fd);
|
|
120
|
+
if (!stat.isFile() || (!allowHardlinks && stat.nlink !== 1) || stat.size > maxSize)
|
|
121
|
+
throw new Error(`Unsafe/oversized template file: ${relative}`);
|
|
122
|
+
const data = Buffer.alloc(maxSize + 1);
|
|
123
|
+
let size = 0;
|
|
124
|
+
while (size < data.length) {
|
|
125
|
+
const count = fs.readSync(fd, data, size, data.length - size, null);
|
|
126
|
+
if (count === 0) break;
|
|
127
|
+
size += count;
|
|
128
|
+
}
|
|
129
|
+
if (size > maxSize) throw new Error(`Template size exceeded: ${relative}`);
|
|
130
|
+
return data.subarray(0, size);
|
|
131
|
+
} finally {
|
|
132
|
+
fs.closeSync(fd);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function loadTemplate(source, { sha256, version, id, bundledRoot } = {}) {
|
|
137
|
+
if (fs.lstatSync(source).isSymbolicLink() || !fs.statSync(source).isDirectory())
|
|
138
|
+
throw new Error('Template source must be a directory, not a link');
|
|
139
|
+
const root = fs.realpathSync(source);
|
|
140
|
+
const allowHardlinks = Boolean(
|
|
141
|
+
bundledRoot &&
|
|
142
|
+
sha256 &&
|
|
143
|
+
!path.relative(fs.realpathSync(bundledRoot), root).startsWith('..') &&
|
|
144
|
+
!path.isAbsolute(path.relative(fs.realpathSync(bundledRoot), root)),
|
|
145
|
+
);
|
|
146
|
+
if (bundledRoot && !allowHardlinks) throw new Error('Bundled source must be pinned inside package root');
|
|
147
|
+
const raw = readFile(root, 'manifest.json', LIMITS.manifest, allowHardlinks);
|
|
148
|
+
const digest = hash(raw);
|
|
149
|
+
if (sha256 !== undefined && (!SHA256.test(sha256) || digest !== sha256)) throw new Error('Template manifest integrity mismatch');
|
|
150
|
+
const manifest = validateManifest(JSON.parse(raw.toString('utf8')));
|
|
151
|
+
if (version && version !== manifest.version) throw new Error('Template version mismatch');
|
|
152
|
+
if (id && id !== manifest.slug) throw new Error('Template id mismatch');
|
|
153
|
+
const files = manifest.files.map((file) => {
|
|
154
|
+
const data = readFile(root, file.path, file.size, allowHardlinks);
|
|
155
|
+
if (data.length !== file.size || hash(data) !== file.sha256) throw new Error(`Template integrity mismatch: ${file.path}`);
|
|
156
|
+
return { path: file.path, data };
|
|
157
|
+
});
|
|
158
|
+
const instructions = files.find((file) => file.path === 'APPLY.md').data.toString('utf8');
|
|
159
|
+
if (!instructions.startsWith(APPLY_RULES)) throw new Error('APPLY.md must start with the canonical template policy');
|
|
160
|
+
return { manifest, raw, digest, files };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function ensureDirectory(parent, name) {
|
|
164
|
+
const target = path.join(parent, name);
|
|
165
|
+
try {
|
|
166
|
+
fs.mkdirSync(target, { mode: 0o700 });
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (error.code !== 'EEXIST') throw error;
|
|
169
|
+
}
|
|
170
|
+
const stat = fs.lstatSync(target);
|
|
171
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`Unsafe template cache directory: ${target}`);
|
|
172
|
+
return target;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function stageTemplate(source, project, expected) {
|
|
176
|
+
if (!expected?.sha256) throw new Error('A trusted manifest sha256 is required to use a template');
|
|
177
|
+
// Read and verify EVERYTHING before making any project changes.
|
|
178
|
+
const loaded = loadTemplate(source, expected);
|
|
179
|
+
const { manifest, raw, digest, files } = loaded;
|
|
180
|
+
const projectRoot = fs.realpathSync(project);
|
|
181
|
+
let parent = projectRoot;
|
|
182
|
+
for (const part of ['.wizz', 'templates', manifest.slug]) parent = ensureDirectory(parent, part);
|
|
183
|
+
const destination = path.join(parent, manifest.version);
|
|
184
|
+
// Publish a fully verified tree in one rename. An interrupted process can
|
|
185
|
+
// leave a hidden staging sibling, never a partially installed version.
|
|
186
|
+
try {
|
|
187
|
+
fs.lstatSync(destination);
|
|
188
|
+
const incomplete = !fs.existsSync(path.join(destination, 'origin.json'));
|
|
189
|
+
throw Object.assign(
|
|
190
|
+
new Error(
|
|
191
|
+
`EEXIST: template version already exists${incomplete ? ' (incomplete legacy cache; inspect and move it aside before retry)' : ''}`,
|
|
192
|
+
),
|
|
193
|
+
{ code: 'EEXIST' },
|
|
194
|
+
);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if (error.code !== 'ENOENT') throw error;
|
|
197
|
+
}
|
|
198
|
+
const temporary = fs.mkdtempSync(path.join(parent, `.${manifest.version}-staging-`));
|
|
199
|
+
try {
|
|
200
|
+
for (const { path: relative, data } of [...files, { path: 'manifest.json', data: raw }]) {
|
|
201
|
+
const parts = relative.split('/');
|
|
202
|
+
const name = parts.pop();
|
|
203
|
+
let directory = temporary;
|
|
204
|
+
for (const part of parts) directory = ensureDirectory(directory, part);
|
|
205
|
+
fs.writeFileSync(path.join(directory, name), data, { flag: 'wx', mode: 0o600 });
|
|
206
|
+
}
|
|
207
|
+
const provenance = `Wizz template: ${manifest.slug}@${manifest.version}; origin: ${manifest.origin}; sha256: ${digest}`;
|
|
208
|
+
fs.writeFileSync(
|
|
209
|
+
path.join(temporary, 'origin.json'),
|
|
210
|
+
`${JSON.stringify({ schema_version: 1, slug: manifest.slug, version: manifest.version, origin: manifest.origin, sha256: digest, provenance }, null, 2)}\n`,
|
|
211
|
+
{ flag: 'wx', mode: 0o600 },
|
|
212
|
+
);
|
|
213
|
+
fs.renameSync(temporary, destination);
|
|
214
|
+
return {
|
|
215
|
+
slug: manifest.slug,
|
|
216
|
+
version: manifest.version,
|
|
217
|
+
destination,
|
|
218
|
+
instructions: path.join(destination, 'APPLY.md'),
|
|
219
|
+
provenance,
|
|
220
|
+
policy: manifest.policy,
|
|
221
|
+
applied: false,
|
|
222
|
+
};
|
|
223
|
+
} catch (error) {
|
|
224
|
+
fs.rmSync(temporary, { recursive: true, force: true });
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
module.exports = { APPLY_RULES, LIMITS, POLICY, safePath, validateTemplateEntry, validateManifest, loadTemplate, stageTemplate, hash };
|