Skip to content
Back to skills

Aif Security Checklist

ASecurity

> 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)

  • 3 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 19, 2026
ai-agentsrustsqlreactnodeexpressgitapisecurity

Works with

  • cursor
  • terminal
  • cli
  • api

Security analysis

A100/100

Scanned September 19, 2026

npx -y skills add maister-dev/maister --skill aif-security-checklist --agent claude-code

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.

Security grade badge for Aif Security Checklist
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/maister-dev-aif-security-checklist-maister/badge)](https://www.skillsdirectory.com/skills/maister-dev-aif-security-checklist-maister)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
# 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.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.