diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..0c0b6fc --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +# Corpus changes require review from a repo member with write access. +# See README.md for the corpus update process. +/corpus/ @GangGreenTemperTatum @mamicidal @rocklambros @sclintonowasp diff --git a/.github/workflows/extract-pdf-text.yaml b/.github/workflows/extract-pdf-text.yaml new file mode 100644 index 0000000..a76796b --- /dev/null +++ b/.github/workflows/extract-pdf-text.yaml @@ -0,0 +1,43 @@ +name: Extract PDF text for search index + +on: + push: + branches: [main] + paths: + - "corpus/**/*.pdf" + - "corpus/MANIFEST.yaml" + - "scripts/extract_pdf_text.py" + workflow_dispatch: {} + +permissions: + contents: write + +jobs: + extract: + runs-on: ubuntu-latest + steps: + - name: Check out repo + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: pip install -r scripts/requirements.txt + + - name: Extract text from corpus PDFs + run: python scripts/extract_pdf_text.py --repo-root . + + - name: Commit updated extracted text + run: | + git config user.name "corpus-extraction-bot" + git config user.email "actions@users.noreply.github.com" + git add corpus/_extracted/ + if git diff --cached --quiet; then + echo "No changes to commit." + else + git commit -m "Extract PDF text for search index ($(date -u +%Y-%m-%d))" + git push + fi diff --git a/.github/workflows/validate-corpus.yaml b/.github/workflows/validate-corpus.yaml new file mode 100644 index 0000000..876144d --- /dev/null +++ b/.github/workflows/validate-corpus.yaml @@ -0,0 +1,22 @@ +name: PR - Validate corpus + +on: + pull_request: + paths: + - "corpus/**" + - "scripts/validate_corpus.py" + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: pip install pyyaml + + - name: Validate corpus manifest + run: python scripts/validate_corpus.py --repo-root . diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..124727f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.DS_Store +.venv/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..086088f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,34 @@ +# Contributing + +## Adding a corpus resource + +See "Adding or updating a resource" in [README.md](./README.md). In short: drop the +file(s) in `corpus//`, add a `corpus/MANIFEST.yaml` entry, open a PR -- +CI validates the manifest, and PDF text extraction runs automatically after merge. + +## Changing the tooling + +`scripts/validate_corpus.py` and `scripts/extract_pdf_text.py` are run by the +workflows in `.github/workflows/`. Changing `extract_pdf_text.py` re-runs extraction +on the next push to `main`. + +## Where to make corpus changes + +Here, always. This repo is the authoritative corpus for both the skill and the MCP +server; the skills repo's `corpus/` is a copy that gets overwritten. + +## Skill instructions + +`SKILL.md` lives in +[GenAI-Security-Advisor](https://github.com/GenAI-Security-Project/GenAI-Security-Advisor), +along with the skill's versioning and changelog. Open a PR there when a corpus +change should alter how the skill describes or uses the corpus. + +## License + +By contributing, you agree that: +- Contributions to `scripts/`, workflows, and other repo-native files are licensed + under this repo's Apache-2.0 license. +- Contributions to `corpus/` are either your own original work under a compatible + license, or vendored content from an OWASP GenAI Security Project source whose + license is recorded accurately in the `MANIFEST.yaml` entry. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c6dd8a9 --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# GenAI Security Corpus + +The curated, versioned research corpus behind the OWASP GenAI Security Project's +advisor tooling. **This repo is the authoritative source of the corpus** -- the +single place where corpus content is added, updated, validated, and processed. + +Consumers: + +- [GenAI-Security-Advisor-MCP](https://github.com/GenAI-Security-Project/GenAI-Security-Advisor-MCP) + -- remote MCP server; reads `corpus/MANIFEST.yaml` and files from this repo's + `main` branch at query time (edge-cached for up to an hour). +- [GenAI-Security-Advisor](https://github.com/GenAI-Security-Project/GenAI-Security-Advisor) + -- the Agent Skill (`SKILL.md`). It bundles a copy of `corpus/`, which a GitHub + Action copies over from this repo when the corpus changes. **Never edit the + skills repo's `corpus/` directly** -- changes there are overwritten by the next + copy. Skill instructions live there; corpus content lives here. + +## Layout + +```text +corpus/ + MANIFEST.yaml # catalog of every resource -- the single source of truth + /... # vendored content, grouped by initiative + _extracted/ # auto-generated PDF text (search index only, never edit) +scripts/ + validate_corpus.py # MANIFEST checks, run by CI on every PR + extract_pdf_text.py # regenerates corpus/_extracted/ from vendored PDFs + requirements.txt +.github/workflows/ + validate-corpus.yaml # PR check + extract-pdf-text.yaml # runs on push to main, commits refreshed _extracted/ +``` + +## Adding or updating a resource + +1. **Confirm the license.** Only vendor (copy in) a document whose license is + explicitly stated in the source -- most OWASP GenAI Security Project output is + CC BY-SA 4.0. If you can't confirm it, catalog it as a `linked` entry instead + (step 3) and don't commit the file. +2. **Add the file(s)** under the appropriate `corpus//` folder. If it + supersedes an existing resource, use a new version- or date-named folder + rather than overwriting the old file. A new initiative folder is fine. +3. **Add an entry to `corpus/MANIFEST.yaml`.** + + ```yaml + - id: my-resource-1.0 # unique, stable + title: "Full Document Title" + initiative: mcp-security # matches the corpus/ subfolder + version: "1.0" + status: current # current | draft | superseded | linked + format: pdf # markdown | pdf | json | mixed + path: corpus/mcp-security/My-Resource-v1.0.pdf # file, or folder ending in / + license: CC-BY-SA-4.0 + source_url: https://genai.owasp.org/resource/... # and/or source_repo + source_path + vendored_commit: # when copied from a git repo + vendored_date: "YYYY-MM-DD" + published: "YYYY-MM-DD" # or null if unknown + notes: > + What it is and when to use it. + ``` + + For a `linked` entry: `path: null`, `license: UNCONFIRMED` (or the actual + license if known but not vendored), and a `source_url` or `source_repo`. +4. **If it replaces something,** set the old entry's `status: superseded`. Don't + delete it -- historical questions still need it. +5. **Validate locally (optional):** + + ```bash + pip install -r scripts/requirements.txt + python scripts/validate_corpus.py + ``` + +6. **Open a PR.** `validate-corpus.yaml` checks that every entry has its required + fields, ids are unique, `status` is valid, and `path` exists (or is `null` for + `linked`). CODEOWNERS review is required for `corpus/`. +7. **After merge,** `extract-pdf-text.yaml` extracts text from every `current` or + `draft` PDF (including PDFs inside a `mixed` folder) into `corpus/_extracted/` + and commits it to `main`, so the MCP server's full-text search picks it up. + It can also be run manually from the Actions tab (`workflow_dispatch`). + +Don't hand-edit `corpus/_extracted/`; see its [README](corpus/_extracted/README.md). + +### Updating from upstream + +Everything under `corpus/` is a frozen point-in-time copy -- there's no sync +with the upstream initiative repos. Pulling in an upstream change is always +manual: re-fetch, review, update `vendored_commit` / `vendored_date`, PR. + +### Letting the skill know + +The MCP server picks up merged changes automatically, and the copy Action carries +them into the skills repo's `corpus/`. The skill's `SKILL.md` +describes corpus areas in prose, so a new initiative or a superseded headline +resource should also get a matching update (and version bump) in +[GenAI-Security-Advisor](https://github.com/GenAI-Security-Project/GenAI-Security-Advisor). + +## Licensing + +Repo-native files (scripts, workflows, docs, `MANIFEST.yaml`) are Apache-2.0 -- +see [LICENSE](LICENSE). Content under `corpus/` is third-party and keeps its +original license, recorded per entry in `corpus/MANIFEST.yaml` (mostly +CC BY-SA 4.0). Extracted text inherits its source PDF's license. diff --git a/corpus/MANIFEST.yaml b/corpus/MANIFEST.yaml index 3d7c0ea..62ed35f 100644 --- a/corpus/MANIFEST.yaml +++ b/corpus/MANIFEST.yaml @@ -1,7 +1,9 @@ # Catalog of every document/collection in corpus/. # -# This file is the single source of truth the skill consults to find current -# content -- SKILL.md never hardcodes file paths. To add a new resource: +# This repo (GenAI-Security-Corpus) is the authoritative corpus; the Advisor +# skill's corpus/ is a copy of it. This file is the single source of truth the +# Advisor skill and MCP server consult to find current content. To add a new resource (full process in +# README.md at the repo root): # 1. Drop the file(s) in the appropriate corpus// subfolder # (create a new version-dated folder if this supersedes something). # 2. Add an entry below. diff --git a/scripts/extract_pdf_text.py b/scripts/extract_pdf_text.py new file mode 100644 index 0000000..febd13b --- /dev/null +++ b/scripts/extract_pdf_text.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Extract plain text from vendored PDFs into corpus/_extracted/ sidecar files. + +Purely a search-index aid for the companion MCP server (GenAI-Security-Advisor-MCP) +-- pypdf's extraction is not layout-aware (tables/columns often come out +garbled), so these .txt files are NEVER the citable source. The original PDF +(referenced by each sidecar's header comment) remains that. See +corpus/_extracted/README.md for the full caveat. + +Run via `python scripts/extract_pdf_text.py` from the repo root, or let +.github/workflows/extract-pdf-text.yaml run it on every corpus/**.pdf change. +""" +import argparse +import os +import sys +from datetime import datetime, timezone + +import yaml +from pypdf import PdfReader + +EXTRACTED_ROOT = "_extracted" + + +def die(msg: str) -> None: + print(f"[extract_pdf_text] ERROR: {msg}", file=sys.stderr) + sys.exit(1) + + +def extracted_path(pdf_path: str) -> str: + """corpus/foo/bar.pdf -> corpus/_extracted/foo/bar.pdf.txt""" + assert pdf_path.startswith("corpus/") + rest = pdf_path[len("corpus/"):] + return os.path.join("corpus", EXTRACTED_ROOT, rest + ".txt") + + +def extract_one(repo_root: str, pdf_path: str, resource_id: str, license_: str) -> bool: + abs_pdf = os.path.join(repo_root, pdf_path) + if not os.path.isfile(abs_pdf): + print(f"[extract_pdf_text] WARN: {pdf_path} not found, skipping", file=sys.stderr) + return False + + out_rel = extracted_path(pdf_path) + out_abs = os.path.join(repo_root, out_rel) + os.makedirs(os.path.dirname(out_abs), exist_ok=True) + + reader = PdfReader(abs_pdf) + pages = [] + for i, page in enumerate(reader.pages, start=1): + text = page.extract_text() or "" + pages.append(f"--- page {i} ---\n{text.strip()}") + + header = ( + f"# AUTO-GENERATED by scripts/extract_pdf_text.py -- DO NOT EDIT BY HAND\n" + f"# source_pdf: {pdf_path}\n" + f"# resource_id: {resource_id}\n" + f"# license: {license_} (inherited from source PDF, see corpus/MANIFEST.yaml)\n" + f"# extracted: {datetime.now(timezone.utc).strftime('%Y-%m-%d')}\n" + f"#\n" + f"# UNREVIEWED, LOSSY TEXT EXTRACTION -- for full-text search only.\n" + f"# Tables, multi-column layout, and figures do not survive extraction\n" + f"# reliably. Never quote this as the citable source -- fetch source_pdf\n" + f"# above for anything that needs to be accurate.\n" + f"\n" + ) + + with open(out_abs, "w", encoding="utf-8") as f: + f.write(header) + f.write("\n\n".join(pages)) + f.write("\n") + + print(f"[extract_pdf_text] wrote {out_rel} ({len(pages)} pages)") + return True + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--repo-root", default=".") + args = ap.parse_args() + + repo_root = os.path.abspath(args.repo_root) + manifest_path = os.path.join(repo_root, "corpus", "MANIFEST.yaml") + if not os.path.isfile(manifest_path): + die(f"manifest not found: {manifest_path}") + + with open(manifest_path, "r", encoding="utf-8") as f: + manifest = yaml.safe_load(f) or {} + + resources = manifest.get("resources", []) + wrote = 0 + for r in resources: + if r.get("status") not in ("current", "draft"): + continue + fmt = r.get("format") + path = r.get("path") + if not path: + continue + + pdf_paths = [] + if fmt == "pdf" and path.endswith(".pdf"): + pdf_paths = [path] + elif fmt == "mixed" and path.endswith("/"): + abs_dir = os.path.join(repo_root, path) + if os.path.isdir(abs_dir): + pdf_paths = [ + os.path.join(path, name) + for name in sorted(os.listdir(abs_dir)) + if name.lower().endswith(".pdf") + ] + + for pdf_path in pdf_paths: + if extract_one(repo_root, pdf_path, r["id"], r.get("license", "UNKNOWN")): + wrote += 1 + + print(f"[extract_pdf_text] done: {wrote} file(s) extracted") + + +if __name__ == "__main__": + main() diff --git a/scripts/requirements.txt b/scripts/requirements.txt new file mode 100644 index 0000000..aefe988 --- /dev/null +++ b/scripts/requirements.txt @@ -0,0 +1,2 @@ +pyyaml +pypdf diff --git a/scripts/validate_corpus.py b/scripts/validate_corpus.py new file mode 100644 index 0000000..7c937e3 --- /dev/null +++ b/scripts/validate_corpus.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Validate corpus/MANIFEST.yaml: required fields present, referenced paths exist.""" +import argparse +import os +import sys + +import yaml + +REQUIRED_FIELDS = {"id", "title", "initiative", "version", "status", "format", "license"} +VALID_STATUS = {"current", "draft", "superseded", "linked"} + + +def die(msg: str) -> None: + print(f"[validate_corpus] ERROR: {msg}", file=sys.stderr) + sys.exit(1) + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--repo-root", default=".") + ap.add_argument("--manifest", default=None) + args = ap.parse_args() + + repo_root = os.path.abspath(args.repo_root) + manifest_path = args.manifest or os.path.join(repo_root, "corpus", "MANIFEST.yaml") + + if not os.path.isfile(manifest_path): + die(f"manifest not found: {manifest_path}") + + with open(manifest_path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + + resources = data.get("resources") + if not isinstance(resources, list) or not resources: + die("MANIFEST.yaml must contain a non-empty 'resources:' list") + + seen_ids = set() + for i, r in enumerate(resources): + if not isinstance(r, dict): + die(f"resources[{i}] must be a mapping") + + missing = REQUIRED_FIELDS - set(r.keys()) + if missing: + die(f"resources[{i}] missing required fields: {sorted(missing)}") + + rid = str(r["id"]).strip() + if not rid: + die(f"resources[{i}].id is empty") + if rid in seen_ids: + die(f"duplicate resource id: {rid}") + seen_ids.add(rid) + + status = r["status"] + if status not in VALID_STATUS: + die(f"resource '{rid}': status '{status}' not one of {sorted(VALID_STATUS)}") + + path = r.get("path") + if status == "linked": + if path not in (None, "null"): + die(f"resource '{rid}': status 'linked' should have path: null") + if not r.get("source_url") and not r.get("source_repo"): + die(f"resource '{rid}': linked resource needs source_url or source_repo") + else: + if not path: + die(f"resource '{rid}': status '{status}' requires a path") + abs_path = os.path.join(repo_root, path) + if not os.path.exists(abs_path): + die(f"resource '{rid}': path does not exist: {path}") + + for key in ("license", "title", "initiative", "version"): + if not str(r[key]).strip(): + die(f"resource '{rid}': {key} is empty") + + print(f"[validate_corpus] OK: {len(resources)} resources validated from {manifest_path}") + + +if __name__ == "__main__": + main()