diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ff5be1..931d578 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,3 +28,5 @@ jobs: run: bash scripts/ci/workflow-guardrails.sh . - name: Canon-guardrail regression suite run: bash scripts/test-canon-guardrail.sh + - name: Public-truth check (public-claims accuracy) + run: bash scripts/ci/public-truth.sh . diff --git a/AUDIT_POSTURE.md b/AUDIT_POSTURE.md index 5e29da6..1a9350c 100644 --- a/AUDIT_POSTURE.md +++ b/AUDIT_POSTURE.md @@ -61,11 +61,12 @@ See [`SECURITY.md`](SECURITY.md). All Tier-1 repos must include a `SECURITY.md` ## Audit history -Federation-wide audit history is summarized below. Individual audit reports live in each repo's `audits/` directory; pre-split audit reports live in [`citrate-monorepo-archive`](https://github.com/CitrateNetwork/citrate-monorepo-archive)'s `audits/`. +Federation-wide audit history is summarized below. Individual audit reports live in each repo's `audits/` directory. Pre-split audit reports are kept in a private archive and are not public. | Date | Auditor | Scope | Outcome | |---|---|---|---| -| Pre-split | various | Monorepo as of 2026-05-17 | See archive `audits/` | +| Pre-split | internal | Monorepo as of 2026-05-17 | Private archive, not public | +| 2026-09-24 | internal adversarial audit (pre-bounty) | Federation, public repos and live testnet | Remediation in progress | | Planned: Q3 2026 | TBD | `citrate-chain` Tier-1 pass before `v0.5.0` stable | — | ## Changelog of this document diff --git a/README.md b/README.md index 8532574..4b1f8ea 100644 --- a/README.md +++ b/README.md @@ -224,10 +224,10 @@ cargo run --release -- --rpc-url http://127.0.0.1:8545 | Network name | Citrate | | Chain ID | `40204` (hex `0x9d0c`) | | RPC URL | `https://rpc.citrate.ai` | -| WebSocket | `wss://ws.citrate.ai` | +| WebSocket | not publicly served yet (run a local node for `ws://127.0.0.1:8546`) | | Block explorer | `https://explorer.citrate.ai` | | Faucet | `https://faucet.citrate.ai` | -| Chain spec | `citrate-chain/specs/testnet.toml` | +| Chain spec | `citrate-chain/node/config/testnet.toml` | > Mainnet target is Q2 2027; the current network is chain 40204. Confirm live endpoint > status in the [docs](https://docs.citrate.ai) before assuming availability. @@ -308,12 +308,12 @@ see each repo's README for specifics. | Repo | What it builds | Run | |---|---|---| -| [`citrate-inference-gateway`](https://github.com/CitrateNetwork/citrate-inference-gateway) | x402-metered inference gateway | `cargo run --release` | +| [`citrate-inference-gateway`](https://github.com/CitrateNetwork/citrate-inference-gateway) | Inference gateway (paid routes not deployed yet) | `cargo run --release` | | [`citrate-compute-pool`](https://github.com/CitrateNetwork/citrate-compute-pool) | Coordinator + workers for pooled training | `cargo run --release` | | [`citrate-cluster`](https://github.com/CitrateNetwork/citrate-cluster) | GPU-fleet and compute-cluster tooling | `cargo run --release` | | [`citrate-core`](https://github.com/CitrateNetwork/citrate-core) | Desktop app that runs a full node | `cargo run --release` | | [`citrate-comms`](https://github.com/CitrateNetwork/citrate-comms) | E2E-encrypted, server-blind team workspace | `pnpm install && pnpm dev` | -| [`citrate-quorum`](https://github.com/CitrateNetwork/citrate-quorum) | Human-in-the-loop governance surface for AI | `cargo run --release` | +| [`citrate-quorum`](https://github.com/CitrateNetwork/citrate-quorum) | HIC (Human In Control) governance surface for AI | `cargo run --release` | | [`citrate-identity`](https://github.com/CitrateNetwork/citrate-identity) | OIDC/OAuth2 authority (SIWE, passkeys) | `pnpm install && pnpm dev` | | [`citrate-memories`](https://github.com/CitrateNetwork/citrate-memories) | Content-addressed knowledge graph for agents | `cargo run --release` | | [`citrate-native`](https://github.com/CitrateNetwork/citrate-native) | Slint desktop wallet + agent client | `cargo run --release` | @@ -454,11 +454,11 @@ on: jobs: rust: if: hashFiles('Cargo.toml') != '' - uses: citratenetwork/.github/.github/workflows/reusable-rust-ci.yml@v1 # pin a tag or SHA, not @main + uses: citratenetwork/.github/.github/workflows/reusable-rust-ci.yml@5d24169b7acf6533b1eaba2399a25effc6c346d6 # pin a SHA, not @main js: if: hashFiles('package.json') != '' - uses: citratenetwork/.github/.github/workflows/reusable-js-ci.yml@v1 # pin a tag or SHA, not @main + uses: citratenetwork/.github/.github/workflows/reusable-js-ci.yml@5d24169b7acf6533b1eaba2399a25effc6c346d6 # pin a SHA, not @main ``` ### Release notifications @@ -474,7 +474,7 @@ no diff for a caller's reviewer to see. Pin to a tag or SHA so an upgrade is a reviewable change in the caller: ```yaml -uses: citratenetwork/.github/.github/workflows/reusable-rust-ci.yml@v1 # or @<40-hex-sha> +uses: citratenetwork/.github/.github/workflows/reusable-rust-ci.yml@<40-hex-sha> # no release tags exist yet ``` When breaking-change updates are made, cut a new tag here so consumer repos can pin against it. diff --git a/SECURITY.md b/SECURITY.md index bad0619..6a70dc7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,9 +8,9 @@ This document covers all repositories under the [`CitrateNetwork`](https://githu Preferred (encrypted, no key exchange): use **GitHub private vulnerability reporting** — on the affected repository, open the **Security** tab → **Report a vulnerability**. This gives a private, GitHub-encrypted channel with no PGP key to fetch. -Alternatively, email **security@citrate.ai**. To encrypt an emailed report, fetch our PGP public key from `keys.openpgp.org` (search `security@citrate.ai`) or via the `Encryption` field of our [`security.txt`](https://citrate.ai/.well-known/security.txt). +Alternatively, email **security@citrate.ai**. We do not publish a PGP key yet, so send sensitive details through private vulnerability reporting rather than plain email. Our [`security.txt`](https://citrate.ai/.well-known/security.txt) lists the same contacts. -> GH-B-012: the previous PGP path pointed at `keys/security@citrate.ai.asc` in the **private** `citrate-monorepo-archive` repo, which no external reporter can read — the documented encryption path did not work. Use private vulnerability reporting instead. +Bounty policy: coming soon. It will be linked here once counsel has reviewed it (OWNER). Include in your report: - The repo + commit SHA (or version tag) where you observed the issue @@ -22,14 +22,18 @@ We acknowledge within **72 hours** and aim to triage within **5 business days**. ## Scope -Severity tiers and audit cadence per repo are documented in each repo's `AUDIT_TIER.md`. The TL;DR: +Severity tiers and audit cadence per repo are documented in each repo's `AUDIT_TIER.md`. The summary +below is the same table that appears on the [security posture page](https://docs.citrate.ai/security/posture). -| Tier | Audit policy | Vulnerability handling | -|---|---|---| -| **Tier 1** (chain, native app, SDKs, agent-runtime, gateway, compute-pool) | Full audit before every stable release | Coordinated disclosure; CVE assigned for high+ | -| **Tier 3** (docs, and other content/library repos) | Content review only | Triage as docs corrections, no CVE | +| Tier | Repositories | Audit policy | Vulnerability handling | +|---|---|---|---| +| **Tier 1**: consensus, value, keys, identity | `citrate-chain` (node, contracts, ZK), `citrate-core`, `citrate-identity`, `citrate-inference-gateway`, `citrate-compute-pool`, `citrate-coop`, `citrate-agent-runtime`, `citrate-sdk-js`, `citrate-sdk-python` | Full adversarial audit before every stable release | Coordinated disclosure; a GitHub Security Advisory (with a CVE request) for fixed High and Critical issues in released code | +| **Tier 3**: docs and content | `citrate-docs`, `.github`, and other content-only repositories | Content review | Triage as documentation corrections, no CVE | -Per-repo tier is authoritative in each repo's `AUDIT_TIER.md`. +A repository's own `AUDIT_TIER.md` is authoritative for that repository. A public repository without an +`AUDIT_TIER.md` is handled as Tier 1 for reports. + +No advisories have been published yet. ## Responsible disclosure @@ -53,13 +57,13 @@ We will **not** pursue legal action against researchers who: - Social engineering of team members. - Physical access attacks against operator hardware. -## Supply-chain integrity +## Supply-chain integrity (current practice) -- Crates published from `citrate-chain` are signed via cosign keyless OIDC. See the chain's `.github/workflows/release.yml` for the signing pipeline. -- npm packages from `citrate-sdk-*` are published with provenance attestations. -- SBOMs (CycloneDX) attach to every Tier-1 release. +- `citrate-chain`'s release workflow is built to sign artifacts with cosign (keyless OIDC) and attach CycloneDX SBOMs, but no public release carries signed assets yet: the signed `v0.5.0-beta2-tier2` build is still a draft. Treat current prereleases, including the `citrate-core` desktop builds, as unsigned and without SBOMs. +- `@citratelabs/sdk` on npm is published with a provenance attestation. `@citratelabs/marketplace-sdk` is not yet. +- Supply-chain hardening is in progress: required review and CI checks on every public repository, third-party GitHub Actions pinned to commit SHAs, and signed releases with SBOMs. -Verifying a release artifact: +Verifying a signed release artifact, once published: ```bash # cosign verify-blob with the issuer / identity from the release @@ -71,7 +75,7 @@ cosign verify-blob --certificate-identity-regexp 'https://github\.com/CitrateNet ## Audit firms + history -Per-repo audit history lives in each repo's `audits/` directory (when present) or in the [`citrate-monorepo-archive`](https://github.com/CitrateNetwork/citrate-monorepo-archive) for pre-split history. The next planned audit is the chain Tier-1 pass before the `v0.5.0` stable tag. +Per-repo audit history lives in each repo's `audits/` directory, when present. History from before the repositories were split is kept in a private archive and is not public. No external-firm audit has been completed yet; the next planned audit is the chain Tier-1 pass before the `v0.5.0` stable tag. ## Contact diff --git a/profile/README.md b/profile/README.md index 228f8f6..ad338ef 100644 --- a/profile/README.md +++ b/profile/README.md @@ -28,18 +28,18 @@ Licensor: **Citrate Inc.** Each repository's `LICENSE` file is authoritative. A - **`citrate-agent-runtime`** — Capability-scoped agent execution runtime + capsules. - **`citrate-sdk-js`** — TypeScript SDK (`@citratelabs/sdk`). - **`citrate-sdk-python`** — Python SDK. -- **`citrate-sdk-marketplace`** — Marketplace SDK (metered, pay-per-call inference). +- **`citrate-sdk-marketplace`**: Marketplace SDK (metered inference; the paid rails are not deployed yet). - **`citrate-docs`** — The Almanac: docs.citrate.ai. - **`citrate-explorer`** — CitrateScan, the AI-native BlockDAG explorer. ### Application layer / commercial core — BUSL-1.1 (source-available, converts to Apache-2.0) -- **`citrate-inference-gateway`** — x402-metered, pay-per-call AI inference gateway. +- **`citrate-inference-gateway`**: AI inference gateway. Paid calls (x402 and API-key metering) are not deployed yet. - **`citrate-compute-pool`** — Coordinator + workers for pooled AI training. - **`citrate-cluster`** — GPU-fleet and compute-cluster tooling. - **`citrate-core`** — Desktop app that turns your machine into a full node. - **`citrate-comms`** — End-to-end-encrypted, server-blind team workspace. -- **`citrate-quorum`** — Human-in-the-loop governance surface for AI. +- **`citrate-quorum`**: HIC (Human In Control) governance surface for AI. - **`citrate-identity`** — OIDC/OAuth2 authority with SIWE and passkeys. - **`citrate-memories`** — Content-addressed knowledge graph for agents. - **`citrate-native`** — Slint desktop wallet and agent client. @@ -65,9 +65,10 @@ Usage in any repo's `.github/workflows/ci.yml`: ```yaml jobs: rust: - # GH-B-003: pin to a release tag or a full commit SHA, never @main (a mutable - # branch: one push to this repo would change every caller's CI with no diff). - uses: CitrateNetwork/.github/.github/workflows/reusable-rust-ci.yml@v1 + # GH-B-003: pin to a full commit SHA, never @main (a mutable branch: one push + # to this repo would change every caller's CI with no diff). This repo has no + # release tags yet, so @v1 does not resolve; use a reviewed commit SHA. + uses: CitrateNetwork/.github/.github/workflows/reusable-rust-ci.yml@5d24169b7acf6533b1eaba2399a25effc6c346d6 with: working-directory: '.' apt-packages: 'libclang-dev cmake libssl-dev pkg-config libfontconfig1-dev' diff --git a/scripts/ci/public-truth.sh b/scripts/ci/public-truth.sh new file mode 100755 index 0000000..e88148f --- /dev/null +++ b/scripts/ci/public-truth.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# public-truth.sh: the org profile, README and security policy must not make claims +# the code or the live network contradict (public-claims accuracy). Logic lives in public_truth.py. +# Usage: bash scripts/ci/public-truth.sh [root] (exit 1 on any hit) +set -euo pipefail +exec python3 "$(dirname "${BASH_SOURCE[0]}")/public_truth.py" "${1:-.}" diff --git a/scripts/ci/public_truth.py b/scripts/ci/public_truth.py new file mode 100644 index 0000000..d408582 --- /dev/null +++ b/scripts/ci/public_truth.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Public-truth check for the org profile, README and security policy (public-claims accuracy). + +Text is normalised before matching (line breaks and runs of whitespace collapse to one +space, a hyphen split across a line break is joined, markdown emphasis is dropped), so a +reworded or re-wrapped claim is still caught. Exit 1 on any hit. + +Usage: python3 scripts/ci/public_truth.py [root] +""" +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +DASH = r"[\s\-‐-―]*" + + +def normalise(text: str) -> str: + t = re.sub(r"-\s*\n\s*", "-", text) # "Human-in-\n the-loop" -> "Human-in-the-loop" + t = re.sub(r"[*_`]+", "", t) # markdown emphasis / code ticks + t = re.sub(r"\s+", " ", t) + return t + + +def sentences(t: str) -> list[str]: + return re.split(r"(?<=[.!?|])\s+", t) + + +RULES: list[tuple[str, re.Pattern, re.Pattern | None]] = [ + ("owner rule: use HIC (Human In Control), never HITL", + re.compile(r"\bH\.?I\.?T\.?L(s|'s)?\b"), None), + ("owner rule: use HIC, never human-in-the-loop", + re.compile(r"human" + DASH + r"in" + DASH + r"(the" + DASH + r")?loop", re.I), None), + ("dead host (no DNS / 404 / 530)", + re.compile(r"wss?://ws\.citrate\.ai|scan\.citrate\.ai|rpc2\.citrate\.ai|mirror\.citrate\.ai", re.I), None), + ("citrate-chain/specs/testnet.toml does not exist (use node/config/testnet.toml)", + re.compile(r"specs/testnet\.toml"), None), + ("links a private repository", + re.compile(r"citrate-monorepo-archive|citrate-agentile-archive", re.I), None), + ("no PGP key is published for security@citrate.ai", + re.compile(r"keys\s*\.\s*openpgp\s*\.\s*org|PGP (public )?key (from|at|via)", re.I), None), + ("overstated supply-chain claim", + re.compile(r"(\b(are|is) cosign[- ]signed|cosign[- ]signed (releases|crates|artifacts)|signed (via|with) cosign|SBOMs? \(?CycloneDX\)? attach|every (tier-1 )?release (ships|carries|includes|has)|CVE assigned for high)", re.I), + re.compile(r"^.{0,25}(built to|once published|not yet|no public release|in progress)", re.I)), + ("paid inference rails described as live (not deployed)", + re.compile(r"(x402|pay" + DASH + r"per" + DASH + r"call|paid (inference|routes|calls))[^.|]{0,80}\b(live|available now|today|metered|generally available|in production)\b|x402-metered", re.I), + re.compile(r"not (yet )?(deployed|live|mounted)", re.I)), + ("names an audit finding ID; public copy must not describe open findings", + re.compile(r"\bPBA-[A-Za-z0-9]+-\d+\b"), None), +] + + +def remote_v_tags(root: Path) -> list[str]: + """Release tags on origin. Works under a shallow checkout, which fetches no tags.""" + for cmd in (["git", "-C", str(root), "ls-remote", "--tags", "origin", "v*"], + ["git", "-C", str(root), "tag", "-l", "v*"]): + try: + out = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + except Exception: + continue + if out.returncode == 0: + return [l.split("refs/tags/")[-1] for l in out.stdout.split() if "v" in l] + return [] + + +def main() -> int: + root = Path(sys.argv[1] if len(sys.argv) > 1 else ".") + files = sorted(p for p in list(root.glob("*.md")) + list((root / "profile").glob("*.md")) if p.is_file()) + errs: list[str] = [] + for f in files: + rel = f.relative_to(root) + norm = normalise(f.read_text(errors="replace")) + for label, rx, qual in RULES: + for s in sentences(norm): + m = rx.search(s) + if not m: + continue + if qual is not None: + window = s[max(0, m.start() - 40): m.end() + 40] + if re.search(r"not (yet )?(deployed|live|mounted)|built to|once published|not yet|no public release", window, re.I) and not re.search(r"\bare cosign|\bis cosign", s[m.start():m.end()], re.I): + continue + errs.append(f"{rel}: {label}: ...{s[max(0, m.start() - 40):m.end() + 60]}...") + + # Reusable-workflow pins must resolve. + tags = remote_v_tags(root) + for f in files: + for m in re.finditer(r"reusable-[a-z-]+\.yml@(v[0-9][\w.]*)", f.read_text(errors="replace")): + if m.group(1) not in tags: + errs.append(f"{f.relative_to(root)}: pins @{m.group(1)}, which is not a tag on origin; pin a commit SHA") + + bounty = root / "BOUNTY.md" + sec = root / "SECURITY.md" + if not bounty.exists(): + # No bounty policy is published yet: nothing may point readers at one. + for f in files: + if re.search(r"BOUNTY\.md", f.read_text(errors="replace")): + errs.append(f"{f.relative_to(root)} links BOUNTY.md, which does not exist yet (bounty policy: coming soon)") + else: + b = bounty.read_text() + for section in ("## In scope", "## Not deployed or not running", "## Safe harbor", "## Known issues", "## Rewards"): + if section not in b: + errs.append(f"BOUNTY.md lacks section '{section}'") + ki = b.split("## Known issues", 1)[-1].split("\n## ", 1)[0] + body = re.sub(r"\s+", " ", ki).strip() + if re.search(r"^\s*([-*|]|\d+[.)])\s", ki, re.M) or body not in ("Published per finding once fixed. `OWNER TO FILL`.",): + errs.append("BOUNTY.md Known issues must stay a placeholder until each finding is fixed and the owner publishes it") + if "not in force" in b: + for f in files: + if f.name != "BOUNTY.md" and re.search(r"BOUNTY\.md", f.read_text(errors="replace")): + errs.append(f"{f.relative_to(root)} links BOUNTY.md while BOUNTY.md is marked not in force (counsel sign-off pending)") + + for e in errs: + print(f"::error::public-truth: {e}") + if errs: + print(f"public-truth: FAIL ({len(errs)})") + return 1 + print(f"public-truth: OK ({len(files)} files)") + return 0 + + +if __name__ == "__main__": + sys.exit(main())