Installs into .claude/skills of the current project.
Are you the author of Aif Security Checklist?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/maister-dev-aif-security-checklist-maister)
# Project Rules for /aif-security-checklist
> Auto-generated by `/aif-evolve`. Do not edit manually.
> Last updated: 2026-09-04
> Based on: 2 analyzed patches + /aif-evolve 107-patch batch (2026-06-17, cursor 2026-05-30 → 2026-06-16)
> + /aif-evolve 114-patch batch (2026-07-11, cursor 2026-06-16 → 2026-07-07)
> + /aif-evolve 74-patch batch (2026-09-04, cursor 2026-07-07 → 2026-09-04)
## Rules
### Path-traversal via validator-bypass — Zod `z.string()` flowing into `path.*` is CRITICAL
**Source**: 2026-05-26-12.45.md, 2026-05-26-12.53.md
**Rule**: During injection audits, add a dedicated step: trace every `path.resolve` / `path.join` / `fs.*` / `child_process.spawn` argv (including `cwd`) back to its Zod (or other boundary) schema. If the field that produced the value is `z.string()`, `z.string().min(N)`, or any other unconstrained string without a regex constraint (`^[A-Za-z0-9._-]+$` for filesystem path segments) or a path-shape `.refine()` (`startsWith("/")` + no `..` for absolute paths), record the finding as CRITICAL `#path-traversal`. Severity remains CRITICAL even when the affected service is documented as internal-network only (no auth, internal topology) — internal services routinely gain auth later or get exposed by mistake, and the defense costs ~5 minutes per field. When one field in a schema gets a regex, audit all siblings — partial-coverage waves are the common failure mode.
### SSRF / egress trust — a member-writable destination + a viewer-readable response is a cloud-metadata read primitive
**Source**: 2026-06-11-11.54.md
**Rule**: Audit every outbound HTTP feature (webhooks, pings, subscriptions, agent fetch). Evaluate the trust claim against the **LOWEST role that can write the destination URL** PLUS **every read path of the response** — "operator-trusted" only holds if operators are the only writers AND readers. A member-created subscription/ping to `http://169.254.169.254/latest/meta-data/…` whose response snippet is persisted and viewer-readable = a cloud-IMDS credential read (SSRF → credential exfil). Required defense, all four layers:
1. A `node:net` `BlockList` over loopback / private / link-local (incl. the `169.254.169.254` metadata range) / multicast / unspecified — and the v4-mapped-v6 forms of each.
2. **Write-time** IP-literal rejection in `assertUrl` (reject when the host is a literal in a blocked range).
3. **Send-time** `dns.lookup` of ALL records with refuse-if-ANY-blocked → nothing is sent.
4. A pinned `undici` dispatcher (`Agent` answering only from the vetted addresses) to close the DNS-rebind TOCTOU between resolve and connect.
Flag `://user:pass@host` userinfo in any body-supplied URL as a secret-at-rest decision (store / redact / reject — keep the "no secrets at rest" spec claim consistent).
### Authored-package path-safety — reject `..` on original bytes, decode fatally, refuse symlinks, validate every external id before a sink
**Source**: 2026-06-08-12.02.md, 2026-06-08-12.15.md, 2026-06-08-13.05.md, 2026-06-08-14.41.md, 2026-06-01-15.31.md
**Rule**: For any code that imports/installs/exports a package, archive, or upload:
- Reject `..` in the **ORIGINAL** slash/backslash path segments BEFORE normalization — `schemas/../setup.sh` is accepted if you check the normalized result (`setup.sh`). Test both normalized and original forms, and both POSIX `../` and Windows `..\` separators regardless of host OS.
- Read package bytes as bytes, THEN decode with **fatal UTF-8** validation — `readFile(path, "utf8")` silently replaces invalid bytes before validation can reject a binary payload.
- Reject non-regular entries (symlinks): the generic installer copies the full tree, so a symlinked `setup.sh` enters the cache as untrusted bytes and later becomes executable. Log the refused path.
- Reject ancestor/descendant (file-vs-directory) path collisions, not just exact duplicates — they fail at export time.
- Validate EVERY external identifier (package slug, filename, `flow.yaml.name`) server-side before it reaches DB identity, YAML, SQL, or a filesystem sink; never reuse one manifest field as both display label and sink-safe slug.
- Hidden form fields (`capabilitySlug`) and body fields are locators / concurrency tokens ONLY, never authority for resource identity — load the server-owned value after authorization.
- Confinement must realpath the FINAL resolved leaf when it exists AND the nearest EXISTING ancestor for to-be-created paths (`mkdir -p`/git ops follow symlinks in ANY existing segment; a lexical fallback for a missing parent is always unsafe); enumerations switch `stat`→`lstat` (symlink leaves skipped); fork/export/`cp` filters reject symlinks per source so copies never carry an escaping link.
- When a "trusted-by-provenance" source abstraction (installed package, install-time-validated paths) is generalized to an UNTRUSTED one (local working dir, lenient-parsed user-editable manifest), re-audit every path reaching an `fs`/loader/`spawn` sink — provenance safety does not transfer across the new source; route all such sites through ONE confinement gate (`resolveConfinedFlowYaml` reusing `resolveConfinedFile`: `..`/absolute/symlink/size-cap).
- Repo-local state/restore manifests (`.maister/agent-package-skills.json`) live INSIDE the checkout being policed — treat as untrusted even though MAIster wrote them earlier in the same lifecycle: accept only normalized relative paths under the known skill homes (`.claude/skills`, `.gemini/skills`), act only when the MAIster marker exists, and parse porcelain by path boundaries, not substring matching.
- Internal runtime dirs (`.git`, `.maister` path segments) are PRUNED before traversal, not only rejected at read time.
**Source (additions)**: 2026-06-21-01.26, 2026-06-20-21.18-m36-flow-package-viewer, 2026-06-29-01.03, 2026-07-07-18.04-review-blockers, 2026-06-30-13.04
### Fail-open at the trust boundary + authz scope must match write scope
**Source**: 2026-06-04-17.50.md, 2026-06-12-12.56.md
**Rule**: Validate the **wire**, not the schema — a zod range (`z.number().min(0).max(1)`) on the producer side does NOT hold for an untrusted producer (agent stdout `{"confidence":2}`, `Infinity`). Re-check the domain at every untrusted sink. **Out-of-range ≠ missing**: a present-but-invalid value must fail-closed on its own branch and must never fall through to a "missing / allow-default" branch (a fail-OPEN promotion gate). Every `typeof x === "number"` before a comparison that gates promotion needs `Number.isFinite(x)` + the domain bound in the SAME expression. Separately: an endpoint's authz scope MUST match the **write scope** of what it mutates — a project-URL route (`POST .../packages/{attachmentId}/trust`) gated on project-scoped `managePackages` but whose handler fans `setup.sh` trust across EVERY project on a shared platform `package_installs` row is cross-project arbitrary-code-execution; it needs `requireGlobalRole("admin")`. If a project-URL route updates platform-scoped rows (no `projectId` in the WHERE), red-flag it.
Extensions: token SCOPE alone is insufficient for privileged surfaces — restrict privileged callers to the specific principal (the package-sourced judge id allowlist), not any token holding the scopes. `requireSession()` is the password-change escape hatch ONLY (it enforces neither active status nor password-change completion) — every state-changing or role-scoped route uses `requireActiveSession()` / `requireGlobalRole`; grep `requireSession(` during audits. Read-only GETs still have a security boundary — "no body → no injection" is not enough; authorization on the server-derived resource needs its own tests (foreign-run → 403, 401 no session, run-scoped fields derived from the row not the body).
**Source (additions)**: 2026-07-04-13.37-experiment-comparison-review-fix-followup, 2026-06-20-21.18-m36-flow-package-viewer, 2026-06-18-23.21
2026-07..09 extensions: pick a READ gate from the DATA'S CLASS, not the route's neighbourhood — "what is the most sensitive thing that can end up in this response, and who else can already see that?": a file an agent writes with repo access inherits `readRepoFiles` (ADR-053), not `readBoard`, and a test that pinned viewer→200 with an explanatory comment froze the wrong answer. Any RSC read that feeds client props is gated by the SAME authorization as the surface that displays the value — "rendered only under X" ≠ "transmitted only under X" (the lock holder's email reached viewers in the Flight payload). When a read spans TWO authorization domains, re-derive the question from the RESPONSE SHAPE ("which rows belong to something the caller was never authorized for?") — redact foreign content behind an explicit `redacted` boolean while keeping the address the contract requires; a sensitive aggregation (cross-project attachment counts) is an explicit opt-in for authorized operators. When an allow-list entry's JUSTIFICATION is narrower than the grant ("cannot later remove the edge IT CREATED" vs any `relations:delete`), the grant is per-scope but the intent is per-row — enforce in the operation's own predicate (`onlyAuthoredBy` as AND-terms on the DELETE's WHERE, never a preceding SELECT) and keep the existence-hidden shape. A client-minted session/bearer token is bound to a server-authoritative principal at EVERY privileged write (`locked_by_user_id = ${userId}`) and never generated from `Date.now()`/`Math.random()` (use `crypto.getRandomValues`). Every new grant has a POSITIVE test exercising the granted path (a deny-only suite cannot distinguish "refused" from "broken"), and ownership-derived ext routes get one DB-backed regression on the real `handleExt` + token + DB path (token bound to project A → 404 for project B's run).
**Source (additions)**: 2026-07-27-21.50, 2026-07-22-01.14, 2026-08-06-19.10, 2026-07-13-11.21, 2026-08-06-17.03, 2026-07-27-11.00
### Run-bound credentials: revoke on every terminal transition AND re-validate the subject on every use
**Source**: 2026-06-21-11.58
**Rule**: A run-bound/resource-bound credential needs BOTH halves, audited together:
1. **Revocation on EVERY terminal transition of its subject** — centralized in the one teardown path all terminals route through (`cascadeAbandonRunTree`, placed BEFORE its nothing-to-cascade early-return) so abandon/stop/drop/crash all revoke.
2. **Consumer-side re-validation of the subject's live state on each use** — `resolveActiveBoundRun` returning `{ok:false, PRECONDITION}` on missing or `isTerminalRunStatus`, wired as the FIRST check in every bound route (all 7).
"The token still verifies" never implies "the subject is still active"; TTL is a backstop, not a boundary.
### Supply chain: dependency manifests and lockfiles are executable input
**Source**: 2026-07-02-17.03
**Rule**: Any automation acting on a `package.json`/lockfile diff (auto-promotion lanes, auto-merge) gates on specifier SHAPE with a BOTH-sided check (`isRegistryVersionSpecifier` on old AND new values: reject `file:`, `git`/`github:`, `http(s):`, `workspace:`, and filesystem-path specifiers) AND disqualifies lockfile-only diffs (a changed dependency graph with NO manifest evidence). "The value changed" is necessary but never sufficient for "safe registry bump"; residual transitive-smuggle risk fails to the manual-bounded lane, never auto.
### LLM memory & distillation are prompt-injection surfaces
**Source**: 2026-07-02-17.52, 2026-06-25-17.42
**Rule**: Audit project-brain/memory features as injection surfaces: fence untrusted task/review text entering an LLM distiller prompt (+ cap `max_tokens`); require a confidence floor (0.4) before ambient memory injection into agent context; never-expiring kinds (`state_fact`) are DURABLE cross-run injection vectors — carry an untrusted-content caveat on prompt pointers and dedupe snapshots per (run, query_hash, model). Any typed, snapshotted column whose value is later prompt-injected must be validated at the service seam on EVERY write — including the declaration's own `default` (`type: number, default: "x"` must fail); a `z.record(z.unknown())` wire type feeding such a column is a trust smell.
2026-07..09 extension: agent-authored free text replayed as prompt context on later runs is a durable SELF-INFLUENCE channel — frame the block explicitly as untrusted self-authored DATA between `BEGIN/END AGENT MEMORY` markers, record the accepted residual risk in the ADR (ADR-152 D29), and keep the injected bytes, the snapshot, and the hash describing ONE thing (no per-consumer trim).
**Source (additions)**: 2026-07-27-21.50
### Resource caps at the request boundary — paid APIs are sinks
**Source**: 2026-06-21-01.26, 2026-06-20-21.18-m36-flow-package-viewer, 2026-07-02-17.52
**Rule**: Enforce size/count limits on DECLARED metadata at the request boundary BEFORE materializing bytes (`fileParts.length` ≤ maxEntries, per-file `size` ≤ maxFileBytes, running total ≤ maxBytes → 422 before any `arrayBuffer()`/`Promise.all`) — a cap inside a downstream library does not protect memory the route already read. Decompression limits run DURING streaming with running actual-byte counters + declared-size pre-checks (zip/tar bombs: a compressed-size cap is not an uncompressed-size cap; abort on first breach, bounding peak memory to ~maxBytes). Fields feeding paid APIs (embedding/LLM token spend) are sinks: cap every such field (`content` ≤ 32k, `q` ≤ 2k, tags count×length) and audit ALL siblings on the same sink when capping one — an uncapped sibling next to a capped `title` is the partial-coverage wave.
### Git transport URLs and remotes are command-injection sinks — test the bypass shapes
**Source**: 2026-07-12-11.12
**Rule**: Any URL that reaches `git remote add` / `git clone` / `git fetch` argv is a subprocess sink, not a string: `packageSourceCreateBodySchema.url` was a bare `z.string()` and `kind:'git'` never called `validateUrl`, so an admin-registered `ext::sh -c '…'` executed on the host at publish. Required controls: `validateUrl` at EVERY producer including defaults (`ensureDefaultPackageSources`); `--` before positional args (`gitSetRemote`); reject `::` smart-transport prefixes; reject `-`-leading scp user/host segments (option injection: `-oProxyCommand=…@host:` slips through `/^[^/@]+@[^/:]+:/`); `branchNameSchema` on base branches; `..`-segment refusal on local paths; never rely on git's default `protocol.ext.allow` as the only backstop. A security-control test must assert the BYPASS the control exists to stop — the first fix's tests asserted the non-scp `ext::sh -c 'id'` form (already rejected for an unrelated reason) while the scp-shaped payload was still stored; enumerate evasion shapes (transport prefixes, option-leading, encoding), pin each as a negative test, and re-derive the regex's actual accept-set by running it on the evasion strings.
### Idempotency and replay are authorization decisions — content-guard keys, `sameDecision`, destructive effects after the claim
**Source**: 2026-07-25-12.09, 2026-09-01-04.15
**Rule**: An idempotency key is a promise that the SAME request is safe to retry, not that any request under the key resolves to the stored result: a "return existing on conflict" branch without a digest compare is a silent-substitution vector (a changed variant launched the previous configuration with a `deduped` success) — require `existing.definitionDigest === contentDigest(definition)` and `CONFLICT` on mismatch. An already-delivered branch reached by a DIFFERENT payload is a loser, not a retry — it must mutate nothing (`sameDecision`, byte-identical to the stored decision). A destructive side effect (`reset --hard` + `git clean -fd`) sequenced BEFORE the transaction that locks the request row and checks its marker lets a replay or a concurrent responder destroy the operator's uncommitted work and then report `200 idempotent` — order is lock → verify not-already-done → commit intent → effect; "idempotent on retry" justifies crash recovery, never admission.
### Redaction by kind, inherited scope sets, and lineage guards at every mutation path
**Source**: 2026-09-04-00.21, 2026-07-16-16.40, 2026-07-18-14.55
**Rule**: Payload redaction is a per-KIND ALLOW-LIST projection — key-based redaction let `args`/`url` secrets through the execution-command ledger. A scope set that is safe only by spreading another (`ORCHESTRATOR_TOKEN_SCOPES` lacked `runs:sync` only via `[...AGENT_TOKEN_SCOPES]`) needs its own explicit NEGATIVE test; the next explicit append silently hands a machine actor a human-reserved force-push. An immutability/lineage guard (launched evaluation participants, sealed executions) is enumerated at EVERY mutation path — participant hard-delete, restart, sync, GC, holds — and ids submitted with a verdict are validated against membership before they can pull a cross-project recipe; "immutable" enforced by convention where an upsert path exists is a finding.