Compare commits

...

41 Commits

Author SHA1 Message Date
davida-ps b91e5e4c94 docs: add citation metadata (#246)
* docs: add citation metadata

* docs: add project release metadata
2026-05-27 03:10:02 +03:00
github-actions[bot] 2e793639f2 chore: update NVD/GHSA advisories - 0 NVD new, 1 NVD updated (#241)
Automated update from NVD CVE and GHSA advisory feeds.
Keywords: openclaw, nanoclaw, hermes, picoclaw
Poll window: 2026-05-16T22:02:27Z to 2026-05-24T18:50:11.000Z

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-25 00:37:22 +03:00
davida-ps 4dbac421ab feat(advisories): add provisional GHSA feed (#242)
* feat(advisories): add provisional ghsa feed

* fix(workflows): include advisory signatures in checksums

* fix(workflows): mirror ghsa feed at release root

* feat(advisories): consolidate ghsa into agent feed

* ci(advisories): consolidate ghsa during nvd poll

* fix(advisories): retain unreplaced ghsa feed entries

* chore(skills): bump advisory feed consumers

* fix(release): resolve ts import closure dry run

* fix(release): preserve urls while stripping comments

* fix(release): ignore skill test-only changes

* fix(advisories): follow ghsa pagination links

* test(advisories): add nvd ghsa pipeline dry run
2026-05-24 21:41:59 +03:00
dependabot[bot] 8a9bdfcd23 chore(deps): bump ruff from 0.15.12 to 0.15.13 in /.github (#237)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.12 to 0.15.13.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.12...0.15.13)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-19 11:07:13 +03:00
github-actions[bot] 0ee0d065ec chore: CVE advisories - 0 new, 19 updated (#233)
Automated update from NVD CVE feed.
Keywords: openclaw, nanoclaw, hermes, picoclaw
Poll window: 2026-05-12T06:56:03Z to 2026-05-16T22:00:50.000Z

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-17 01:04:46 +03:00
dependabot[bot] 5d2173226c chore(deps-dev): bump @types/node from 25.4.0 to 25.8.0 (#223)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.4.0 to 25.8.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 00:54:40 +03:00
David Abutbul 19c5113511 fix(attestation): include runtime libs in release sbom (#235)
* fix(attestation): include runtime libs in release sbom

* ci: verify staged skill release import closure

* fix(release): include missing skill runtime sbom files

* fix(release): require files for import closure

---------

Co-authored-by: David Abutbul <David.a@prompt.security>
2026-05-17 00:40:12 +03:00
David Abutbul 1e48a955cc fix(release): exclude tests from skill payloads (#230)
* fix(release): exclude tests from skill payloads

* fix(release): normalize test path filtering

* fix(release): prefer GitHub artifacts for non-OpenClaw installs

* fix(release): keep legacy ClawHub publishing

* fix(release): address skill packaging review feedback

* chore(skills): bump release versions

* feat(skills): surface recommended platforms

* docs(skills): add signed release verification

* fix(skills): normalize PR version bumps

---------

Co-authored-by: David Abutbul <David.a@prompt.security>
2026-05-14 14:38:58 +03:00
dependabot[bot] 0e503c3d5a chore(deps): bump github/codeql-action from 4.35.1 to 4.35.4 (#231)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.35.1 to 4.35.4.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/c10b8064de6f491fea524254123dbe5e09572f13...68bde559dea0fdcac2102bfdf6230c5f70eb485e)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.35.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-12 10:11:49 +03:00
github-actions[bot] 382ec4971b chore: CVE advisories - 18 new, 1 updated (#232)
Automated update from NVD CVE feed.
Keywords: openclaw, nanoclaw, hermes, picoclaw
Poll window: 2026-05-10T13:15:38Z to 2026-05-12T06:54:54.000Z

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-12 10:08:29 +03:00
dependabot[bot] 9595dad58b chore(deps): bump peter-evans/create-pull-request from 8.1.0 to 8.1.1 (#181)
Bumps [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) from 8.1.0 to 8.1.1.
- [Release notes](https://github.com/peter-evans/create-pull-request/releases)
- [Commits](https://github.com/peter-evans/create-pull-request/compare/c0f553fe549906ede9cf27b5156039d195d2ece0...5f6978faf089d4d20b00c7766989d076bb2fc7f1)

---
updated-dependencies:
- dependency-name: peter-evans/create-pull-request
  dependency-version: 8.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 10:43:12 +03:00
github-actions[bot] 6e512a5e43 chore: CVE advisories - 461 new, 0 updated (#228)
Automated update from NVD CVE feed.
Keywords: openclaw, nanoclaw, hermes, picoclaw
Poll window: 2026-01-10T13:13:55.000Z to 2026-05-10T13:13:55.000Z

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-11 08:56:04 +03:00
davida-ps e4c1e07544 fix(skills-catalog): resolve platform metadata fallbacks (#229)
* fix(skills-catalog): resolve platform metadata fallbacks

* fix(skills-catalog): harden platform metadata guards
2026-05-10 16:07:31 +03:00
davida-ps 369745821f feat(traffic-guardian): add runtime monitoring skill baselines (#217)
* feat(traffic-guardian): add runtime monitoring skill baselines

* fix(traffic-guardian): align changelog and i18n fallback docs

* chore(traffic-guardian): prepare beta1 release metadata
2026-05-10 15:04:17 +03:00
github-actions[bot] 85caad5601 chore: CVE advisories - 461 new, 0 updated (#227)
Automated update from NVD CVE feed.
Keywords: openclaw, nanoclaw, hermes, picoclaw
Poll window: 2026-01-07T12:10:52.000Z to 2026-05-07T12:10:52.000Z

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-07 15:14:30 +03:00
davida-ps dfe62457fb Include hermes-agent in NVD queries and export keywords to environment (#226)
* fix(workflow): expand NVD Hermes coverage and keep keyword export

* fix(workflow): export concise nvd summary keywords
2026-05-07 14:58:08 +03:00
dependabot[bot] 95f9d758ee chore(deps): bump actions/github-script from 8.0.0 to 9.0.0 (#180)
Bumps [actions/github-script](https://github.com/actions/github-script) from 8.0.0 to 9.0.0.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/ed597411d8f924073f98dfc5c65a23a2325f34cd...3a2844b7e9c422d3c10d287c895573f7108da1b3)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: 9.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-05 13:06:01 +03:00
dependabot[bot] f6afc80aa2 chore(deps): bump actions/setup-node from 6.3.0 to 6.4.0 (#218)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.3.0 to 6.4.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/53b83947a5a98c8d113130e565377fae1a50d02f...48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 6.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-05 12:58:47 +03:00
dependabot[bot] 9462fe7e1b chore(deps): bump actions/configure-pages from 5.0.0 to 6.0.0 (#219)
Bumps [actions/configure-pages](https://github.com/actions/configure-pages) from 5.0.0 to 6.0.0.
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/983d7736d9b0ae728b81ab479565c72886d7745b...45bfe0192ca1faeb007ade9deae92b16b8254a0d)

---
updated-dependencies:
- dependency-name: actions/configure-pages
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-05 12:43:36 +03:00
dependabot[bot] e3337d0f33 chore(deps): bump actions/upload-pages-artifact from 4.0.0 to 5.0.0 (#220)
Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 4.0.0 to 5.0.0.
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/7b1f4a764d45c48632c6b24a0339c27f5614fb0b...fc324d3547104276b827a68afc52ff2a11cc49c9)

---
updated-dependencies:
- dependency-name: actions/upload-pages-artifact
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-05 11:13:41 +03:00
davida-ps 72663ab80b fix(codeql): ignore generated dist artifacts (#216) 2026-05-04 11:37:37 +03:00
github-actions[bot] 4042a388a9 chore: CVE advisories - 0 new, 59 updated (#215)
Automated update from NVD CVE feed.
Keywords:
Poll window: 2026-04-30T06:50:23Z to 2026-05-03T06:48:42.000Z

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-03 13:28:47 +03:00
dependabot[bot] d491fde73a chore(deps): bump react-dom from 19.2.4 to 19.2.5 (#188)
Bumps [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) from 19.2.4 to 19.2.5.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.5/packages/react-dom)

---
updated-dependencies:
- dependency-name: react-dom
  dependency-version: 19.2.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-01 00:52:52 +03:00
dependabot[bot] 6e318384a9 chore(deps-dev): bump fast-check from 4.5.3 to 4.7.0 (#189)
Bumps [fast-check](https://github.com/dubzzz/fast-check/tree/HEAD/packages/fast-check) from 4.5.3 to 4.7.0.
- [Release notes](https://github.com/dubzzz/fast-check/releases)
- [Changelog](https://github.com/dubzzz/fast-check/blob/main/packages/fast-check/CHANGELOG.md)
- [Commits](https://github.com/dubzzz/fast-check/commits/v4.7.0/packages/fast-check)

---
updated-dependencies:
- dependency-name: fast-check
  dependency-version: 4.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-01 00:49:44 +03:00
dependabot[bot] d23f1f9612 chore(deps): bump aquasecurity/trivy-action (#184)
Bumps [aquasecurity/trivy-action](https://github.com/aquasecurity/trivy-action) from e368e328979b113139d6f9068e03accaed98a518 to ed142fd0673e97e23eac54620cfb913e5ce36c25.
- [Release notes](https://github.com/aquasecurity/trivy-action/releases)
- [Commits](https://github.com/aquasecurity/trivy-action/compare/e368e328979b113139d6f9068e03accaed98a518...ed142fd0673e97e23eac54620cfb913e5ce36c25)

---
updated-dependencies:
- dependency-name: aquasecurity/trivy-action
  dependency-version: 264c9c5e188ea085e7377fd77abd17bfbd4e5926
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-30 18:11:57 +03:00
dependabot[bot] ef6b5f63d4 chore(deps): bump actions/upload-artifact from 7.0.0 to 7.0.1 (#179)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 7.0.0 to 7.0.1.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/bbbca2ddaa5d8feaa63e36b76fdaad77386f024f...043fb46d1a93c77aae656e7c1c64a875d1fc6a0a)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-30 18:06:10 +03:00
dependabot[bot] 12afd15dd6 chore(deps): bump softprops/action-gh-release from 2.6.1 to 3.0.0 (#182)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.6.1 to 3.0.0.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/153bb8e04406b158c6c84fc1615b65b24149a1fe...b4309332981a82ec1c5618f44dd2e27cc8bfbfda)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-30 18:00:01 +03:00
github-actions[bot] 0e22d8f9bd chore: CVE advisories - 0 new, 12 updated (#214)
Automated update from NVD CVE feed.
Keywords:
Poll window: 2026-04-29T06:48:08Z to 2026-04-30T06:49:19.000Z

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-30 15:04:42 +03:00
github-actions[bot] f8614a21b3 chore: CVE advisories - 53 new, 28 updated (#213)
Automated update from NVD CVE feed.
Keywords:
Poll window: 2026-04-28T06:52:17Z to 2026-04-29T06:46:53.000Z

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-29 19:19:03 +03:00
David Abutbul b37162a33d feat(i18n): add multilingual wiki scaffolding, language switcher, and… (#212)
* feat(i18n): add multilingual wiki scaffolding, language switcher, and translation QA pipeline

* docs(readme): adopt picoclaw-style multilingual link bar

* fix(i18n): repair localized index links and tighten partial-pair QA

* ci(i18n): fail on broken markdown links in README/wiki

* ci(i18n): add changed-files mode for markdown link checks

* i18n(de): use local Argos MT to fill untranslated German sections

* i18n(es,fr): fill untranslated sections via local Argos workflow

* i18n(ja): fill untranslated sections with scoped local Argos pass

* i18n(ko): fill untranslated sections with scoped local Argos pass

* fix(i18n): address review feedback

---------

Co-authored-by: David Abutbul <David.a@prompt.security>
2026-04-29 09:00:31 +03:00
dependabot[bot] 627d20b7e1 chore(deps): bump ruff from 0.15.9 to 0.15.12 in /.github (#210)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.9 to 0.15.12.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.9...0.15.12)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-28 14:37:49 +03:00
dependabot[bot] 87afa0de2f chore(deps): bump postcss from 8.5.6 to 8.5.12 (#209)
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.6 to 8.5.12.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.6...8.5.12)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.10
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-28 13:51:19 +03:00
github-actions[bot] 5e298bc1f7 chore: CVE advisories - 11 new, 16 updated (#211)
Automated update from NVD CVE feed.
Keywords:
Poll window: 2026-04-26T11:27:34Z to 2026-04-28T06:51:12.000Z

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-28 13:29:28 +03:00
github-actions[bot] 808aefe40d chore: CVE advisories - 1 new, 1 updated (#207)
Automated update from NVD CVE feed.
Keywords:
Poll window: 2026-04-24T06:36:58Z to 2026-04-26T11:26:31.000Z

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-26 14:38:45 +03:00
David Abutbul 0d2e38ddfd Add Picoclaw guardian + posture-review skills at v0.0.1 with wiki docs (#208)
* Add Picoclaw guardian + posture-review skills at v0.0.1 with wiki docs

* fix(feed): add picoclaw to core platform taxonomy and filters

* fix(picoclaw): resolve eslint errors in new skills

* chore(nvd): include picoclaw in CVE polling and cleanup report

---------

Co-authored-by: David Abutbul <David.a@prompt.security>
2026-04-26 14:19:18 +03:00
github-actions[bot] c53463c445 chore: CVE advisories - 31 new, 1 updated (#205)
Automated update from NVD CVE feed.
Keywords:
Poll window: 2026-04-22T11:03:28Z to 2026-04-24T06:36:00.000Z

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-24 20:32:13 +03:00
github-actions[bot] 448a2bd577 chore: CVE advisories - 313 new, 0 updated (#193)
Automated update from NVD CVE feed.
Keywords:
Poll window: 2025-12-23T11:02:04.000Z to 2026-04-22T11:02:04.000Z

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-22 20:02:11 +03:00
davida-ps 1efb813ed4 fix(nvd): support full CVE rebuild without arg overflow (#204)
* fix(nvd): add hermes query specs to feed polling

* fix(nvd): derive platform fallback from matched targets

* fix(nvd): avoid arg overflow on full cve rescan

* fix(feed): add other platform filter for nonstandard slugs

* refactor(feed): centralize advisory platform badge mapping

* fix(feed): share platform normalization and fix tab callback typing

* refactor(feed): simplify platform descriptor fallback
2026-04-22 13:58:34 +03:00
davida-ps c54f09c3a4 fix(nvd): add hermes query specs to feed polling (#203)
* fix(nvd): add hermes query specs to feed polling

* fix(nvd): derive platform fallback from matched targets
2026-04-21 16:18:45 +03:00
David Abutbul 26af277afd feat(hermes-attestation-guardian): v0.1.0 release hardening (verify gate + trust policy + .mjs scan context) (#200)
* feat(hermes-attestation-guardian): release v0.0.2 hardening

* docs(wiki): add v0.0.2 hardening update note

* docs: add Hermes support coverage to README and compatibility report

* fix(hermes-attestation-guardian): address baz review on crontab detection and doc dedup

* feat(wiki): add PR-200 skill feature/platform matrix

* docs(wiki): rewrite PR-200 matrix as narrative capability mapping

* docs(readme): add skill feature matrix with requested headers

* docs(readme): replace unknowns with mapped yes/no feature matrix

* docs: move NanoClaw and CI/CD details from README to wiki modules

* docs(readme): remove platform/suite sections and keep wiki module pointers

* docs(readme): refresh project structure to match current repo

* feat(hermes-attestation-guardian): add signed advisory feed verification pipeline

* feat(hermes-attestation-guardian): add advisory-gated guarded skill verification

* feat(hermes-attestation-guardian): add advisory scheduler helper and phase-3 parity docs

* docs(wiki): expand hermes attestation guardian capability coverage

* fix(pr-200): address Baz review findings across Hermes parity rollout

* test(sandbox): extend Hermes regression to cover feed, guarded verify, and advisory scheduler

* fix(pr-200): address Baz semver parsing and feed-state fallback visibility

* fix(ci): suppress shellcheck false positives in sandbox inline docker script

* fix(hermes-attestation-guardian): fail closed on unsupported advisory ranges

* fix(hermes-attestation-guardian): restore safe install verdict in sandbox

* fix(sandbox): capture guarded verify exit under set -e

* fix(semver): fail closed on malformed affected specifiers

* docs(readme): clarify hermes capability matrix wording

* refactor(feed): share signed artifact verification flow

* refactor(cron): share managed block helpers across setup scripts

* fix(feed): require checksum manifest artifacts when enabled

* chore(hermes-skill): relocate sandbox test, refresh docs, and add v0.1.0 release notes

* chore(docs): remove remaining hermes parity plan file

* chore(release): roll hermes-attestation-guardian to v0.1.0

* chore(release): remove standalone v0.1.0 release notes file

* docs(hermes): update README status to v0.1.0

---------

Co-authored-by: David Abutbul <David.a@prompt.security>
2026-04-21 13:56:50 +03:00
davida-ps d0fe8c59c4 fix(release): guard duplicate clawhub versions and bump watchdog to 0.1.4 (#201) 2026-04-17 10:07:45 +03:00
264 changed files with 48339 additions and 1153 deletions
+4
View File
@@ -0,0 +1,4 @@
name: "ClawSec CodeQL Config"
paths-ignore:
- dist/**
+1 -1
View File
@@ -1,2 +1,2 @@
ruff==0.15.9
ruff==0.15.13
bandit==1.9.4
+29 -6
View File
@@ -20,7 +20,7 @@ jobs:
- windows-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '20'
cache: 'npm'
@@ -63,7 +63,7 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Trivy FS Scan
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # 0.34.1
with:
scan-type: 'fs'
scan-ref: '.'
@@ -71,7 +71,7 @@ jobs:
exit-code: '1'
ignore-unfixed: true
- name: Trivy Config Scan
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # 0.34.1
with:
scan-type: 'config'
scan-ref: '.'
@@ -83,7 +83,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '20'
cache: 'npm'
@@ -93,12 +93,35 @@ jobs:
- name: Check for outdated deps
run: npm outdated || true
advisory-feed-tests:
name: Advisory Feed Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: GHSA Without CVE Feed Tests
run: node scripts/test-ghsa-without-cve-feed.mjs
- name: GHSA Poll Workflow Tests
run: node scripts/test-ghsa-poll-workflow.mjs
- name: NVD GHSA Consolidation Workflow Tests
run: node scripts/test-nvd-ghsa-consolidation-workflow.mjs
- name: NVD + GHSA Pipeline Dry Run
run: node scripts/test-nvd-ghsa-pipeline-dry-run.mjs
- name: Skill Release Workflow Tests
run: node scripts/test-skill-release-workflow.mjs
- name: Deploy Pages Advisory Checksums Tests
run: node scripts/test-deploy-pages-checksums.mjs
clawsec-suite-tests:
name: ClawSec Suite Verification Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '20'
cache: 'npm'
@@ -123,7 +146,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '20'
cache: 'npm'
+3 -2
View File
@@ -27,9 +27,10 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Initialize CodeQL
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
with:
languages: ${{ matrix.language }}
config-file: ./.github/codeql/codeql-config.yml
- name: Install dependencies
run: npm ci
@@ -37,4 +38,4 @@ jobs:
- name: Build project
run: npm run build
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
+3 -3
View File
@@ -273,7 +273,7 @@ jobs:
- name: Create Pull Request
if: steps.parse.outputs.already_exists != 'true'
id: create-pr
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
token: ${{ secrets.POLL_NVD_CVES_PAT }}
branch: automated/community-advisory-${{ github.event.issue.number }}
@@ -302,7 +302,7 @@ jobs:
- name: Comment on issue
if: steps.parse.outputs.already_exists != 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ secrets.POLL_NVD_CVES_PAT }}
script: |
@@ -328,7 +328,7 @@ jobs:
- name: Comment if already exists
if: steps.parse.outputs.already_exists == 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ secrets.POLL_NVD_CVES_PAT }}
script: |
+84 -36
View File
@@ -183,16 +183,36 @@ jobs:
done
# Build skill entry for index
SKILL_DATA=$(jq -c --arg tag "$TAG" '{
id: .name,
name: .name,
version: .version,
description: .description,
emoji: .openclaw.emoji,
category: .openclaw.category,
trust: .trust.level,
tag: $tag
}' "$MIRROR_DIR/skill.json")
SKILL_DATA=$(jq -c --arg tag "$TAG" '
. as $skill |
def object_or_empty($value):
if ($value | type) == "object" then $value else {} end;
def object_field($name):
object_or_empty($skill[$name]?);
def platform_meta:
($skill.platform as $platform
| if ($platform | type) == "string" then object_or_empty($skill[$platform]?)
else {}
end);
def platform_list:
([]
+ (if ($skill.platforms | type) == "array" then $skill.platforms else [] end)
+ (if ($skill.platform | type) == "string" then [$skill.platform] else [] end)
+ (["openclaw", "hermes", "nanoclaw", "picoclaw"] | map(select((object_field(.) | length) > 0))))
| map(select(type == "string") | ascii_downcase)
| unique;
{
id: .name,
name: .name,
version: .version,
description: .description,
emoji: (platform_meta.emoji // object_field("openclaw").emoji // object_field("hermes").emoji // object_field("nanoclaw").emoji // object_field("picoclaw").emoji // "📦"),
category: (platform_meta.category // object_field("openclaw").category // object_field("hermes").category // object_field("nanoclaw").category // object_field("picoclaw").category // "utility"),
platforms: platform_list,
trust: .trust.level,
tag: $tag
}
' "$MIRROR_DIR/skill.json")
# Append to index (handle first entry without comma)
if [ -f "public/skills/.first_done" ]; then
@@ -229,16 +249,51 @@ jobs:
set -euo pipefail
mkdir -p public/advisories
cp advisories/feed.json public/advisories/feed.json
if [ -f advisories/ghsa-without-cve.json ]; then
cp advisories/ghsa-without-cve.json public/advisories/ghsa-without-cve.json
fi
echo "Copied advisory feed to public/advisories/"
cat public/advisories/feed.json | jq '.advisories | length' | xargs -I {} echo "Feed contains {} advisories"
if [ -f public/advisories/ghsa-without-cve.json ]; then
cat public/advisories/ghsa-without-cve.json | jq '.advisories | length' | xargs -I {} echo "GHSA provisional feed contains {} advisories"
fi
- name: Sign advisory feed and verify
uses: ./.github/actions/sign-and-verify
with:
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
input_file: public/advisories/feed.json
signature_file: public/advisories/feed.json.sig
public_key_output: public/signing-public.pem
- name: Sign provisional GHSA feed and verify
if: hashFiles('public/advisories/ghsa-without-cve.json') != ''
uses: ./.github/actions/sign-and-verify
with:
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
input_file: public/advisories/ghsa-without-cve.json
signature_file: public/advisories/ghsa-without-cve.json.sig
- name: Generate advisory checksums manifest
run: |
set -euo pipefail
FEED_FILE="public/advisories/feed.json"
FEED_SHA=$(sha256sum "$FEED_FILE" | awk '{print $1}')
FEED_SIZE=$(stat -c%s "$FEED_FILE" 2>/dev/null || stat -f%z "$FEED_FILE")
FILES_JSON="{}"
ADVISORY_ARTIFACTS=(public/advisories/*.json public/advisories/*.json.sig)
for file in "${ADVISORY_ARTIFACTS[@]}"; do
[ -e "$file" ] || continue
REL_PATH="${file#public/}"
FILE_SHA=$(sha256sum "$file" | awk '{print $1}')
FILE_SIZE=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file")
FILES_JSON=$(jq \
--arg path "$REL_PATH" \
--arg sha "$FILE_SHA" \
--argjson size "$FILE_SIZE" \
'. + {($path): {sha256: $sha, size: $size, path: $path, url: ("https://clawsec.prompt.security/" + $path)}}' \
<<< "$FILES_JSON")
done
# Generate checksums manifest conforming to parseChecksumsManifest expectations:
# - schema_version: "1" (manifest format version)
@@ -252,36 +307,19 @@ jobs:
--arg version "1.1.0" \
--arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg repo "${{ github.repository }}" \
--arg sha "$FEED_SHA" \
--argjson size "$FEED_SIZE" \
--argjson files "$FILES_JSON" \
'{
schema_version: $schema_version,
algorithm: $algorithm,
version: $version,
generated_at: $generated,
repository: $repo,
files: {
"advisories/feed.json": {
sha256: $sha,
size: $size,
path: "advisories/feed.json",
url: "https://clawsec.prompt.security/advisories/feed.json"
}
}
files: $files
}' > public/checksums.json
echo "Generated public/checksums.json"
jq . public/checksums.json
- name: Sign advisory feed and verify
uses: ./.github/actions/sign-and-verify
with:
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
input_file: public/advisories/feed.json
signature_file: public/advisories/feed.json.sig
public_key_output: public/signing-public.pem
- name: Sign checksums and verify
uses: ./.github/actions/sign-and-verify
with:
@@ -314,11 +352,11 @@ jobs:
- name: Show signed advisory artifacts
run: |
echo "Signed advisory artifacts:"
ls -la public/advisories/feed.json*
ls -la public/advisories/*.json*
ls -la public/checksums.json public/checksums.sig public/signing-public.pem
- name: Setup Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '20'
cache: 'npm'
@@ -367,6 +405,16 @@ jobs:
cp "public/advisories/feed.json.sig" "$MIRROR_LATEST_DIR/advisories/feed.json.sig"
cp "public/advisories/feed.json.sig" "$MIRROR_LATEST_DIR/feed.json.sig"
fi
if [ -f "public/advisories/ghsa-without-cve.json" ]; then
mkdir -p "$MIRROR_LATEST_DIR/advisories"
cp "public/advisories/ghsa-without-cve.json" "$MIRROR_LATEST_DIR/advisories/ghsa-without-cve.json"
cp "public/advisories/ghsa-without-cve.json" "$MIRROR_LATEST_DIR/ghsa-without-cve.json"
fi
if [ -f "public/advisories/ghsa-without-cve.json.sig" ]; then
mkdir -p "$MIRROR_LATEST_DIR/advisories"
cp "public/advisories/ghsa-without-cve.json.sig" "$MIRROR_LATEST_DIR/advisories/ghsa-without-cve.json.sig"
cp "public/advisories/ghsa-without-cve.json.sig" "$MIRROR_LATEST_DIR/ghsa-without-cve.json.sig"
fi
if [ -f "public/checksums.json" ]; then
cp "public/checksums.json" "$MIRROR_LATEST_DIR/checksums.json"
fi
@@ -406,10 +454,10 @@ jobs:
run: touch dist/.nojekyll
- name: Setup Pages
uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Upload artifact
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: ./dist
+35
View File
@@ -0,0 +1,35 @@
name: i18n QA
on:
pull_request:
branches: [main]
paths:
- 'README*.md'
- 'wiki/**/*.md'
- 'scripts/i18n/**'
- '.github/workflows/i18n-qa.yml'
workflow_dispatch:
permissions: read-all
jobs:
i18n-qa:
name: Translation Integrity Checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
- name: Run i18n QA
run: python scripts/i18n/qa_check.py
- name: Check markdown links (README/wiki)
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
python scripts/i18n/link_check.py --changed-only --base-ref "origin/${{ github.base_ref }}"
else
python scripts/i18n/link_check.py
fi
+29 -14
View File
@@ -27,14 +27,26 @@ jobs:
set -euo pipefail
mkdir -p public/advisories
cp advisories/feed.json public/advisories/feed.json
if [ -f advisories/ghsa-without-cve.json ]; then
cp advisories/ghsa-without-cve.json public/advisories/ghsa-without-cve.json
fi
- name: Generate advisory checksums manifest
run: |
set -euo pipefail
FEED_FILE="public/advisories/feed.json"
FEED_SHA=$(sha256sum "$FEED_FILE" | awk '{print $1}')
FEED_SIZE=$(stat -c%s "$FEED_FILE" 2>/dev/null || stat -f%z "$FEED_FILE")
FILES_JSON="{}"
for file in public/advisories/*.json; do
REL_PATH="${file#public/}"
FILE_SHA=$(sha256sum "$file" | awk '{print $1}')
FILE_SIZE=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file")
FILES_JSON=$(jq \
--arg path "$REL_PATH" \
--arg sha "$FILE_SHA" \
--argjson size "$FILE_SIZE" \
'. + {($path): {sha256: $sha, size: $size, path: $path, url: ("https://clawsec.prompt.security/" + $path)}}' \
<<< "$FILES_JSON")
done
jq -n \
--arg schema_version "1" \
@@ -42,22 +54,14 @@ jobs:
--arg version "1.1.0" \
--arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg repo "${{ github.repository }}" \
--arg sha "$FEED_SHA" \
--argjson size "$FEED_SIZE" \
--argjson files "$FILES_JSON" \
'{
schema_version: $schema_version,
algorithm: $algorithm,
version: $version,
generated_at: $generated,
repository: $repo,
files: {
"advisories/feed.json": {
sha256: $sha,
size: $size,
path: "advisories/feed.json",
url: "https://clawsec.prompt.security/advisories/feed.json"
}
}
files: $files
}' > public/checksums.json
- name: Generate ephemeral signing key for PR verification
@@ -81,6 +85,14 @@ jobs:
signature_file: public/advisories/feed.json.sig
public_key_output: public/signing-public.pem
- name: Sign provisional GHSA feed and verify
if: hashFiles('public/advisories/ghsa-without-cve.json') != ''
uses: ./.github/actions/sign-and-verify
with:
private_key: ${{ steps.test_key.outputs.private_key }}
input_file: public/advisories/ghsa-without-cve.json
signature_file: public/advisories/ghsa-without-cve.json.sig
- name: Sign checksums and verify
uses: ./.github/actions/sign-and-verify
with:
@@ -89,7 +101,7 @@ jobs:
signature_file: public/checksums.sig
- name: Setup Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '20'
cache: 'npm'
@@ -107,5 +119,8 @@ jobs:
set -euo pipefail
test -f dist/index.html
test -f public/advisories/feed.json.sig
if [ -f public/advisories/ghsa-without-cve.json ]; then
test -f public/advisories/ghsa-without-cve.json.sig
fi
test -f public/checksums.sig
test -f public/signing-public.pem
+158
View File
@@ -0,0 +1,158 @@
name: Poll GHSA Without CVE
on:
workflow_dispatch:
permissions: read-all
concurrency:
group: poll-ghsa-without-cve
cancel-in-progress: false
env:
FEED_PATH: advisories/feed.json
FEED_SIG_PATH: advisories/feed.json.sig
GHSA_FEED_PATH: advisories/ghsa-without-cve.json
GHSA_FEED_SIG_PATH: advisories/ghsa-without-cve.json.sig
SKILL_FEED_PATH: skills/clawsec-feed/advisories/feed.json
SKILL_FEED_SIG_PATH: skills/clawsec-feed/advisories/feed.json.sig
jobs:
poll-and-update:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '20'
cache: 'npm'
- name: Run GHSA feed tests
run: node scripts/test-ghsa-without-cve-feed.mjs
- name: Poll GitHub Security Advisories
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
node scripts/ghsa-without-cve-feed.mjs \
--output "$GHSA_FEED_PATH" \
--consolidated-feed "$FEED_PATH" \
--existing-feed "$GHSA_FEED_PATH" \
--nvd-feed "$FEED_PATH" \
--stale-after-days 60
- name: Sync consolidated feed to clawsec-feed skill
run: |
set -euo pipefail
mkdir -p "$(dirname "$SKILL_FEED_PATH")"
cp "$FEED_PATH" "$SKILL_FEED_PATH"
- name: Detect feed changes
id: changes
run: |
set -euo pipefail
GHSA_CHANGED=false
AGENT_CHANGED=false
if ! git diff --quiet -- "$GHSA_FEED_PATH" || [ ! -f "$GHSA_FEED_SIG_PATH" ]; then
GHSA_CHANGED=true
fi
if ! git diff --quiet -- "$FEED_PATH" "$SKILL_FEED_PATH" || [ ! -f "$FEED_SIG_PATH" ] || [ ! -f "$SKILL_FEED_SIG_PATH" ]; then
AGENT_CHANGED=true
fi
echo "ghsa_changed=$GHSA_CHANGED" >> "$GITHUB_OUTPUT"
echo "agent_changed=$AGENT_CHANGED" >> "$GITHUB_OUTPUT"
if [ "$GHSA_CHANGED" = "true" ] || [ "$AGENT_CHANGED" = "true" ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
fi
- name: Sign GHSA feed and verify
if: steps.changes.outputs.ghsa_changed == 'true'
uses: ./.github/actions/sign-and-verify
with:
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
input_file: ${{ env.GHSA_FEED_PATH }}
signature_file: ${{ env.GHSA_FEED_SIG_PATH }}
- name: Sign consolidated agent feed and verify
if: steps.changes.outputs.agent_changed == 'true'
uses: ./.github/actions/sign-and-verify
with:
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
input_file: ${{ env.FEED_PATH }}
signature_file: ${{ env.FEED_SIG_PATH }}
verify_files: |
${{ env.FEED_PATH }}
${{ env.SKILL_FEED_PATH }}
- name: Sync consolidated signature to clawsec-feed skill
if: steps.changes.outputs.agent_changed == 'true'
run: cp "$FEED_SIG_PATH" "$SKILL_FEED_SIG_PATH"
- name: Create Pull Request
if: steps.changes.outputs.changed == 'true'
id: create-pr
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
token: ${{ github.token }}
branch: automated/ghsa-without-cve-feed
delete-branch: true
title: 'chore: update provisional GHSA advisory feed'
body: |
## Summary
Updates the provisional GHSA advisory feed and the consolidated agent advisory feed.
- Feed: `${{ env.GHSA_FEED_PATH }}`
- Agent feed: `${{ env.FEED_PATH }}`
- Stale threshold: 60 days without a CVE
- Statuses: `active`, `matured`, `stale`
---
*This PR was automatically generated by the GHSA-without-CVE polling workflow.*
commit-message: |
chore: update provisional GHSA advisory feed
Poll public GitHub Security Advisories without CVE identifiers.
add-paths: |
${{ env.FEED_PATH }}
${{ env.FEED_SIG_PATH }}
${{ env.GHSA_FEED_PATH }}
${{ env.GHSA_FEED_SIG_PATH }}
${{ env.SKILL_FEED_PATH }}
${{ env.SKILL_FEED_SIG_PATH }}
- name: Summary
run: |
set -euo pipefail
echo "## GHSA Without CVE Poll Summary" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Metric | Value |" >> "$GITHUB_STEP_SUMMARY"
echo "|--------|-------|" >> "$GITHUB_STEP_SUMMARY"
echo "| Feed changed | ${{ steps.changes.outputs.changed }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Agent feed changed | ${{ steps.changes.outputs.agent_changed }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| GHSA source feed changed | ${{ steps.changes.outputs.ghsa_changed }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Feed path | $GHSA_FEED_PATH |" >> "$GITHUB_STEP_SUMMARY"
echo "| Agent feed path | $FEED_PATH |" >> "$GITHUB_STEP_SUMMARY"
echo "| Total advisories | $(jq '.advisories | length' "$GHSA_FEED_PATH") |" >> "$GITHUB_STEP_SUMMARY"
echo "| Active | $(jq '[.advisories[] | select(.status == "active")] | length' "$GHSA_FEED_PATH") |" >> "$GITHUB_STEP_SUMMARY"
echo "| Matured | $(jq '[.advisories[] | select(.status == "matured")] | length' "$GHSA_FEED_PATH") |" >> "$GITHUB_STEP_SUMMARY"
echo "| Stale | $(jq '[.advisories[] | select(.status == "stale")] | length' "$GHSA_FEED_PATH") |" >> "$GITHUB_STEP_SUMMARY"
if [ -n "${{ steps.create-pr.outputs.pull-request-url }}" ]; then
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Upserted PR: ${{ steps.create-pr.outputs.pull-request-url }}" >> "$GITHUB_STEP_SUMMARY"
fi
+222 -83
View File
@@ -21,10 +21,10 @@ concurrency:
env:
FEED_PATH: advisories/feed.json
FEED_SIG_PATH: advisories/feed.json.sig
GHSA_FEED_PATH: advisories/ghsa-without-cve.json
GHSA_FEED_SIG_PATH: advisories/ghsa-without-cve.json.sig
SKILL_FEED_PATH: skills/clawsec-feed/advisories/feed.json
SKILL_FEED_SIG_PATH: skills/clawsec-feed/advisories/feed.json.sig
KEYWORDS: "OpenClaw clawdbot Moltbot NanoClaw WhatsApp-bot baileys"
GITHUB_REF_PATTERN: "github.com/openclaw/openclaw github.com/qwibitai/NanoClaw"
jobs:
poll-and-update:
@@ -85,8 +85,10 @@ jobs:
id: fetch
run: |
set -euo pipefail
source scripts/feed-utils.sh
mkdir -p tmp
FORCE_FULL_SCAN="${{ inputs.force_full_scan }}"
NVD_QUERY_SPECS="$(nvd_query_specs)"
START_DATE="${{ steps.dates.outputs.start_date }}"
END_DATE="${{ steps.dates.outputs.end_date }}"
@@ -97,24 +99,26 @@ jobs:
echo "=== Fetching CVEs from NVD ==="
FAILED_KEYWORDS=()
FAILED_QUERIES=()
# Fetch for each keyword
for KEYWORD in $KEYWORDS; do
echo "Fetching keyword: $KEYWORD"
while IFS='|' read -r QUERY_KIND QUERY_VALUE; do
[ -n "$QUERY_KIND" ] || continue
QUERY_SLUG="$(nvd_query_slug "$QUERY_KIND" "$QUERY_VALUE")"
echo "Fetching $QUERY_KIND query: $QUERY_VALUE"
keyword_ok=false
last_http_code=""
if [ "$FORCE_FULL_SCAN" = "true" ]; then
echo "Full scan mode enabled: paginating complete NVD history for keyword '$KEYWORD'"
echo '{"vulnerabilities":[]}' > "tmp/nvd_${KEYWORD}.json"
echo "Full scan mode enabled: paginating complete NVD history for '$QUERY_KIND:$QUERY_VALUE'"
echo '{"vulnerabilities":[]}' > "tmp/nvd_${QUERY_SLUG}.json"
START_INDEX=0
RESULTS_PER_PAGE=2000
while true; do
URL="https://services.nvd.nist.gov/rest/json/cves/2.0?keywordSearch=${KEYWORD}&startIndex=${START_INDEX}&resultsPerPage=${RESULTS_PER_PAGE}"
PAGE_FILE="tmp/nvd_${KEYWORD}_${START_INDEX}.json"
URL="$(nvd_build_url "$QUERY_KIND" "$QUERY_VALUE" "&startIndex=${START_INDEX}&resultsPerPage=${RESULTS_PER_PAGE}")"
PAGE_FILE="tmp/nvd_${QUERY_SLUG}_${START_INDEX}.json"
echo "URL: $URL"
page_ok=false
@@ -129,13 +133,13 @@ jobs:
page_ok=true
break
fi
echo "Invalid JSON for $KEYWORD page $START_INDEX, retry $i..."
echo "Invalid JSON for $QUERY_KIND:$QUERY_VALUE page $START_INDEX, retry $i..."
sleep 5
elif [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "429" ]; then
echo "Rate limited, waiting 30s before retry $i..."
sleep 30
else
echo "HTTP $HTTP_CODE for $KEYWORD page $START_INDEX, retry $i..."
echo "HTTP $HTTP_CODE for $QUERY_KIND:$QUERY_VALUE page $START_INDEX, retry $i..."
sleep 5
fi
done
@@ -145,8 +149,8 @@ jobs:
fi
jq -s '.[0].vulnerabilities += .[1].vulnerabilities | .[0]' \
"tmp/nvd_${KEYWORD}.json" "$PAGE_FILE" > "tmp/nvd_${KEYWORD}_merged.json"
mv "tmp/nvd_${KEYWORD}_merged.json" "tmp/nvd_${KEYWORD}.json"
"tmp/nvd_${QUERY_SLUG}.json" "$PAGE_FILE" > "tmp/nvd_${QUERY_SLUG}_merged.json"
mv "tmp/nvd_${QUERY_SLUG}_merged.json" "tmp/nvd_${QUERY_SLUG}.json"
PAGE_COUNT=$(jq '.vulnerabilities | length' "$PAGE_FILE")
TOTAL_RESULTS=$(jq '.totalResults // 0' "$PAGE_FILE")
@@ -162,45 +166,45 @@ jobs:
sleep 6
done
else
URL="https://services.nvd.nist.gov/rest/json/cves/2.0?keywordSearch=${KEYWORD}&lastModStartDate=${START_ENC}&lastModEndDate=${END_ENC}"
URL="$(nvd_build_url "$QUERY_KIND" "$QUERY_VALUE" "&lastModStartDate=${START_ENC}&lastModEndDate=${END_ENC}")"
echo "URL: $URL"
# Fetch with retry logic
for i in 1 2 3; do
HTTP_CODE=$(curl -sS -w "%{http_code}" -o "tmp/nvd_${KEYWORD}.json" "$URL" || true)
HTTP_CODE=$(curl -sS -w "%{http_code}" -o "tmp/nvd_${QUERY_SLUG}.json" "$URL" || true)
if [ -z "$HTTP_CODE" ]; then
HTTP_CODE="000"
fi
last_http_code="$HTTP_CODE"
if [ "$HTTP_CODE" = "200" ]; then
if jq -e . "tmp/nvd_${KEYWORD}.json" >/dev/null 2>&1; then
echo "Success for $KEYWORD"
if jq -e . "tmp/nvd_${QUERY_SLUG}.json" >/dev/null 2>&1; then
echo "Success for $QUERY_KIND:$QUERY_VALUE"
keyword_ok=true
break
fi
echo "Invalid JSON for $KEYWORD, retry $i..."
echo "Invalid JSON for $QUERY_KIND:$QUERY_VALUE, retry $i..."
sleep 5
elif [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "429" ]; then
echo "Rate limited, waiting 30s before retry $i..."
sleep 30
else
echo "HTTP $HTTP_CODE for $KEYWORD, retry $i..."
echo "HTTP $HTTP_CODE for $QUERY_KIND:$QUERY_VALUE, retry $i..."
sleep 5
fi
done
fi
if [ "$keyword_ok" != "true" ]; then
echo "::error::Failed to fetch valid NVD response for keyword '$KEYWORD' (last HTTP code: ${last_http_code:-unknown})."
FAILED_KEYWORDS+=("$KEYWORD")
echo "::error::Failed to fetch valid NVD response for '$QUERY_KIND:$QUERY_VALUE' (last HTTP code: ${last_http_code:-unknown})."
FAILED_QUERIES+=("${QUERY_KIND}:${QUERY_VALUE}")
fi
# NVD recommends 6 second delay between requests
sleep 6
done
done <<< "$NVD_QUERY_SPECS"
if [ "${#FAILED_KEYWORDS[@]}" -gt 0 ]; then
echo "::error::NVD fetch failed for keyword(s): ${FAILED_KEYWORDS[*]}"
if [ "${#FAILED_QUERIES[@]}" -gt 0 ]; then
echo "::error::NVD fetch failed for query spec(s): ${FAILED_QUERIES[*]}"
exit 1
fi
@@ -210,11 +214,22 @@ jobs:
- name: Merge and filter CVEs
id: process
run: |
source scripts/feed-utils.sh
NVD_QUERY_SPECS="$(nvd_query_specs)"
KEYWORDS_PATTERN="$(nvd_keyword_pattern)"
GITHUB_PATTERN="$(nvd_github_ref_pattern)"
CPE_PATTERN="$(nvd_cpe_pattern)"
# Export concise project keyword groups for PR body + workflow summary steps
KEYWORDS="$(nvd_summary_keywords)"
echo "KEYWORDS=$KEYWORDS" >> "$GITHUB_ENV"
# Combine all fetched CVEs
echo '{"vulnerabilities":[]}' > tmp/combined.json
for KEYWORD in $KEYWORDS; do
FILE="tmp/nvd_${KEYWORD}.json"
while IFS='|' read -r QUERY_KIND QUERY_VALUE; do
[ -n "$QUERY_KIND" ] || continue
QUERY_SLUG="$(nvd_query_slug "$QUERY_KIND" "$QUERY_VALUE")"
FILE="tmp/nvd_${QUERY_SLUG}.json"
if [ -f "$FILE" ] && [ -s "$FILE" ]; then
# Check if file has vulnerabilities array
if jq -e '.vulnerabilities' "$FILE" > /dev/null 2>&1; then
@@ -227,7 +242,7 @@ jobs:
mv tmp/combined_new.json tmp/combined.json
fi
fi
done
done <<< "$NVD_QUERY_SPECS"
# Deduplicate by CVE ID
jq '.vulnerabilities | unique_by(.cve.id)' tmp/combined.json > tmp/unique_cves.json
@@ -235,16 +250,16 @@ jobs:
echo "Total unique CVEs from NVD: $TOTAL"
# Post-filter: keep only CVEs where description contains keywords OR references contain github pattern
KEYWORDS_PATTERN="OpenClaw|clawdbot|Moltbot|openclaw|NanoClaw|nanoclaw|WhatsApp-bot|baileys"
GITHUB_PATTERN="${GITHUB_REF_PATTERN}"
jq --arg kw "$KEYWORDS_PATTERN" --arg gh "$GITHUB_PATTERN" '
jq --arg kw "$KEYWORDS_PATTERN" --arg gh "$GITHUB_PATTERN" --arg cpe "$CPE_PATTERN" '
[.[] | select(
# Check if any description contains keywords (case insensitive)
(.cve.descriptions[]? | select(.lang == "en") | .value | test($kw; "i"))
or
# Check if any reference URL contains the github pattern
(.cve.references[]? | .url | test($gh; "i"))
or
# Check if any CPE criteria contain the Hermes product identifier
([.cve.configurations[]? | .. | objects | .criteria? | strings | test($cpe; "i")] | any)
)]
' tmp/unique_cves.json > tmp/filtered_cves.json
@@ -371,11 +386,12 @@ jobs:
| unique
);
def context_blob:
def detection_blob:
(
[
(.cve.descriptions[]? | select(.lang == "en") | .value),
(.cve.references[]?.url // empty)
(.cve.references[]?.url // empty),
(.cve.configurations[]? | .. | objects | .criteria? // empty)
]
| map(strings | ascii_downcase)
| join(" ")
@@ -383,41 +399,65 @@ jobs:
def inferred_targets:
(
context_blob as $blob
detection_blob as $blob
| (
(if ($blob | test("github\\.com/openclaw/openclaw|\\bopenclaw\\b|\\bclawdbot\\b|\\bmoltbot\\b")) then ["openclaw@*"] else [] end)
+ (if ($blob | test("github\\.com/qwibitai/nanoclaw|\\bnanoclaw\\b|whatsapp-bot|\\bbaileys\\b")) then ["nanoclaw@*"] else [] end)
+ (if ($blob | test("github\\.com/softwarepub/hermes|github\\.com/nousresearch/hermes-agent|cpe:2\\.3:a:software-metadata\\.pub:hermes|\\bhermes workflow\\b|\\bhermes-agent\\b|software publication with rich metadata")) then ["hermes@*"] else [] end)
+ (if ($blob | test("github\\.com/[^/]+/picoclaw|\\bpicoclaw\\b|cpe:2\\.3:[aho]:[^:]*:picoclaw(?::|$)")) then ["picoclaw@*"] else [] end)
)
);
def normalized_affected:
def matched_targets:
(
(cpe_criteria + inferred_targets)
| unique
| .[0:5]
| if length == 0 then ["openclaw@*", "nanoclaw@*"] else . end
);
def platforms_from_targets($targets):
(
[
(if ($targets | map(strings | ascii_downcase | select(startswith("openclaw@") or test("^cpe:2\\.3:[aho]:openclaw:openclaw(?::|$)"))) | length > 0) then "openclaw" else empty end),
(if ($targets | map(strings | ascii_downcase | select(startswith("nanoclaw@") or test("^cpe:2\\.3:[aho]:[^:]*:nanoclaw(?::|$)"))) | length > 0) then "nanoclaw" else empty end),
(if ($targets | map(strings | ascii_downcase | select(startswith("hermes@") or test("^cpe:2\\.3:[aho]:software-metadata\\.pub:hermes(?::|$)"))) | length > 0) then "hermes" else empty end),
(if ($targets | map(strings | ascii_downcase | select(startswith("picoclaw@") or test("^cpe:2\\.3:[aho]:[^:]*:picoclaw(?::|$)"))) | length > 0) then "picoclaw" else empty end)
]
);
def normalized_affected:
(
matched_targets
| if length == 0 then ["openclaw@*", "nanoclaw@*", "hermes@*", "picoclaw@*"] else . end
);
def normalized_platforms:
(
inferred_targets as $targets
| ($targets | map(select(startswith("openclaw@"))) | length > 0) as $has_openclaw
| ($targets | map(select(startswith("nanoclaw@"))) | length > 0) as $has_nanoclaw
| if $has_openclaw and $has_nanoclaw then ["openclaw", "nanoclaw"]
elif $has_openclaw then ["openclaw"]
elif $has_nanoclaw then ["nanoclaw"]
else ["openclaw", "nanoclaw"]
inferred_targets as $inferred
| platforms_from_targets($inferred) as $from_inferred
| if ($from_inferred | length) > 0 then $from_inferred
else
matched_targets as $targets
| platforms_from_targets($targets) as $from_targets
| if ($from_targets | length) > 0 then $from_targets else ["openclaw", "nanoclaw", "hermes", "picoclaw"] end
end
);
def preferred_description:
(
(.cve.descriptions[]? | select(.lang == "en") | .value)
// .cve.descriptions[0]?.value
// "No description provided by NVD."
);
[.[] | {
id: .cve.id,
severity: (get_cvss_score | map_severity),
type: nvd_category_name,
nvd_category_id: nvd_category_raw,
cvss_score: get_cvss_score,
description: (.cve.descriptions[] | select(.lang == "en") | .value),
title: (.cve.descriptions[] | select(.lang == "en") | .value | .[0:100] + (if length > 100 then "..." else "" end)),
description: preferred_description,
title: (preferred_description | .[0:100] + (if length > 100 then "..." else "" end)),
affected: normalized_affected,
platforms: normalized_platforms,
references: [.cve.references[]?.url // empty] | unique | .[0:3],
@@ -490,16 +530,16 @@ jobs:
- name: Transform CVEs to advisories
id: transform
run: |
# Read existing IDs into a jq-friendly format
# Read existing IDs into a jq-friendly file for jq (avoids huge CLI args on full scans).
if [ "${{ inputs.force_full_scan }}" = "true" ]; then
echo "Full scan mode enabled: rebuilding CVE advisories from scratch."
EXISTING_IDS='[]'
echo '[]' > tmp/existing_ids.json
else
EXISTING_IDS=$(cat tmp/existing_ids.txt | jq -R -s 'split("\n") | map(select(length > 0))')
jq -R -s 'split("\n") | map(select(length > 0))' < tmp/existing_ids.txt > tmp/existing_ids.json
fi
# Transform NVD CVEs to our advisory format
jq --argjson existing "$EXISTING_IDS" '
jq --slurpfile existing tmp/existing_ids.json '
def map_severity:
if . == null then "medium"
elif . >= 9.0 then "critical"
@@ -588,11 +628,12 @@ jobs:
| unique
);
def context_blob:
def detection_blob:
(
[
(.cve.descriptions[]? | select(.lang == "en") | .value),
(.cve.references[]?.url // empty)
(.cve.references[]?.url // empty),
(.cve.configurations[]? | .. | objects | .criteria? // empty)
]
| map(strings | ascii_downcase)
| join(" ")
@@ -600,42 +641,66 @@ jobs:
def inferred_targets:
(
context_blob as $blob
detection_blob as $blob
| (
(if ($blob | test("github\\.com/openclaw/openclaw|\\bopenclaw\\b|\\bclawdbot\\b|\\bmoltbot\\b")) then ["openclaw@*"] else [] end)
+ (if ($blob | test("github\\.com/qwibitai/nanoclaw|\\bnanoclaw\\b|whatsapp-bot|\\bbaileys\\b")) then ["nanoclaw@*"] else [] end)
+ (if ($blob | test("github\\.com/softwarepub/hermes|github\\.com/nousresearch/hermes-agent|cpe:2\\.3:a:software-metadata\\.pub:hermes|\\bhermes workflow\\b|\\bhermes-agent\\b|software publication with rich metadata")) then ["hermes@*"] else [] end)
+ (if ($blob | test("github\\.com/[^/]+/picoclaw|\\bpicoclaw\\b|cpe:2\\.3:[aho]:[^:]*:picoclaw(?::|$)")) then ["picoclaw@*"] else [] end)
)
);
def normalized_affected:
def matched_targets:
(
(cpe_criteria + inferred_targets)
| unique
| .[0:5]
| if length == 0 then ["openclaw@*", "nanoclaw@*"] else . end
);
def platforms_from_targets($targets):
(
[
(if ($targets | map(strings | ascii_downcase | select(startswith("openclaw@") or test("^cpe:2\\.3:[aho]:openclaw:openclaw(?::|$)"))) | length > 0) then "openclaw" else empty end),
(if ($targets | map(strings | ascii_downcase | select(startswith("nanoclaw@") or test("^cpe:2\\.3:[aho]:[^:]*:nanoclaw(?::|$)"))) | length > 0) then "nanoclaw" else empty end),
(if ($targets | map(strings | ascii_downcase | select(startswith("hermes@") or test("^cpe:2\\.3:[aho]:software-metadata\\.pub:hermes(?::|$)"))) | length > 0) then "hermes" else empty end),
(if ($targets | map(strings | ascii_downcase | select(startswith("picoclaw@") or test("^cpe:2\\.3:[aho]:[^:]*:picoclaw(?::|$)"))) | length > 0) then "picoclaw" else empty end)
]
);
def normalized_affected:
(
matched_targets
| if length == 0 then ["openclaw@*", "nanoclaw@*", "hermes@*", "picoclaw@*"] else . end
);
def normalized_platforms:
(
inferred_targets as $targets
| ($targets | map(select(startswith("openclaw@"))) | length > 0) as $has_openclaw
| ($targets | map(select(startswith("nanoclaw@"))) | length > 0) as $has_nanoclaw
| if $has_openclaw and $has_nanoclaw then ["openclaw", "nanoclaw"]
elif $has_openclaw then ["openclaw"]
elif $has_nanoclaw then ["nanoclaw"]
else ["openclaw", "nanoclaw"]
inferred_targets as $inferred
| platforms_from_targets($inferred) as $from_inferred
| if ($from_inferred | length) > 0 then $from_inferred
else
matched_targets as $targets
| platforms_from_targets($targets) as $from_targets
| if ($from_targets | length) > 0 then $from_targets else ["openclaw", "nanoclaw", "hermes", "picoclaw"] end
end
);
def preferred_description:
(
(.cve.descriptions[]? | select(.lang == "en") | .value)
// .cve.descriptions[0]?.value
// "No description provided by NVD."
);
[.[] |
select(.cve.id as $id | $existing | index($id) | not) |
select(.cve.id as $id | (($existing[0] // []) | index($id) | not)) |
{
id: .cve.id,
severity: (get_cvss_score | map_severity),
type: nvd_category_name,
nvd_category_id: nvd_category_raw,
title: (.cve.descriptions[] | select(.lang == "en") | .value | .[0:100] + (if length > 100 then "..." else "" end)),
description: (.cve.descriptions[] | select(.lang == "en") | .value),
title: (preferred_description | .[0:100] + (if length > 100 then "..." else "" end)),
description: preferred_description,
affected: normalized_affected,
platforms: normalized_platforms,
action: "Review and update affected components. See NVD for remediation details.",
@@ -652,6 +717,14 @@ jobs:
NEW_COUNT=$(jq 'length' tmp/new_advisories.json)
echo "New advisories to add: $NEW_COUNT"
echo "new_count=$NEW_COUNT" >> $GITHUB_OUTPUT
if [ "${{ inputs.force_full_scan }}" = "true" ]; then
FILTERED_COUNT="${{ steps.process.outputs.filtered_count }}"
if [ "$NEW_COUNT" -ne "$FILTERED_COUNT" ]; then
echo "::error::Full scan transform mismatch: filtered CVEs=$FILTERED_COUNT transformed advisories=$NEW_COUNT"
exit 1
fi
fi
if [ "$NEW_COUNT" -gt 0 ]; then
echo "=== New advisories ==="
@@ -705,11 +778,11 @@ jobs:
if [ -f "$FEED_PATH" ] && [ "$FORCE_FULL_SCAN" = "true" ]; then
# Full scan mode: replace all CVE advisories with rebuilt set and keep non-CVE entries.
jq --argjson rebuilt "$(cat tmp/new_advisories.json)" --arg now "$NOW" '
jq --slurpfile rebuilt tmp/new_advisories.json --arg now "$NOW" '
.updated = $now |
.advisories = (
((.advisories // []) | map(select((.id // "") | startswith("CVE-") | not)))
+ $rebuilt
+ ($rebuilt[0] // [])
| sort_by(.published)
| reverse
)
@@ -731,16 +804,16 @@ jobs:
' "$FEED_PATH" > tmp/feed_with_updates.json
# Step 2: Add new advisories
jq --argjson new "$(cat tmp/new_advisories.json)" --arg now "$NOW" '
jq --slurpfile new tmp/new_advisories.json --arg now "$NOW" '
.updated = $now |
.advisories = (.advisories + $new | sort_by(.published) | reverse)
.advisories = (.advisories + ($new[0] // []) | sort_by(.published) | reverse)
' tmp/feed_with_updates.json > tmp/updated_feed.json
else
jq -n --argjson advisories "$(cat tmp/new_advisories.json)" --arg now "$NOW" '{
jq -n --slurpfile advisories tmp/new_advisories.json --arg now "$NOW" '{
version: "1.0.0",
updated: $now,
description: "Community-driven security advisory feed for ClawSec",
advisories: ($advisories | sort_by(.published) | reverse)
advisories: (($advisories[0] // []) | sort_by(.published) | reverse)
}' > tmp/updated_feed.json
fi
@@ -762,8 +835,54 @@ jobs:
exit 1
fi
- name: Poll GHSA without CVE and consolidate feed
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
node scripts/ghsa-without-cve-feed.mjs \
--output "$GHSA_FEED_PATH" \
--consolidated-feed "$FEED_PATH" \
--existing-feed "$GHSA_FEED_PATH" \
--nvd-feed "$FEED_PATH" \
--stale-after-days 60
mkdir -p "$(dirname "$SKILL_FEED_PATH")"
cp "$FEED_PATH" "$SKILL_FEED_PATH"
- name: Detect advisory feed changes
id: feed_changes
run: |
set -euo pipefail
NVD_CHANGED=false
GHSA_CHANGED=false
AGENT_CHANGED=false
if [ "${{ steps.transform.outputs.new_count }}" != "0" ] || [ "${{ steps.updates.outputs.update_count }}" != "0" ]; then
NVD_CHANGED=true
fi
if ! git diff --quiet -- "$GHSA_FEED_PATH" || [ ! -f "$GHSA_FEED_SIG_PATH" ]; then
GHSA_CHANGED=true
fi
if ! git diff --quiet -- "$FEED_PATH" "$SKILL_FEED_PATH" || [ ! -f "$FEED_SIG_PATH" ] || [ ! -f "$SKILL_FEED_SIG_PATH" ]; then
AGENT_CHANGED=true
fi
echo "nvd_changed=$NVD_CHANGED" >> "$GITHUB_OUTPUT"
echo "ghsa_changed=$GHSA_CHANGED" >> "$GITHUB_OUTPUT"
echo "agent_changed=$AGENT_CHANGED" >> "$GITHUB_OUTPUT"
if [ "$GHSA_CHANGED" = "true" ] || [ "$AGENT_CHANGED" = "true" ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
fi
- name: Guard dependency manifests from NVD updates
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
if: steps.feed_changes.outputs.changed == 'true'
run: |
set -euo pipefail
@@ -780,8 +899,17 @@ jobs:
exit 1
fi
- name: Sign GHSA feed and verify
if: steps.feed_changes.outputs.ghsa_changed == 'true'
uses: ./.github/actions/sign-and-verify
with:
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
input_file: ${{ env.GHSA_FEED_PATH }}
signature_file: ${{ env.GHSA_FEED_SIG_PATH }}
- name: Sign advisory feed and verify
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
if: steps.feed_changes.outputs.agent_changed == 'true'
uses: ./.github/actions/sign-and-verify
with:
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
@@ -793,18 +921,18 @@ jobs:
${{ env.SKILL_FEED_PATH }}
- name: Sync advisory signature to skill feed
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
if: steps.feed_changes.outputs.agent_changed == 'true'
run: cp "$FEED_SIG_PATH" "$SKILL_FEED_SIG_PATH"
- name: Clean workspace for PR
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
if: steps.feed_changes.outputs.changed == 'true'
run: |
# Reset any unintended changes, keep only feed files
git checkout -- .github/ 2>/dev/null || true
git clean -fd .github/ 2>/dev/null || true
- name: Upsert NVD advisory PR
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
if: steps.feed_changes.outputs.changed == 'true'
id: upsert-pr
env:
GH_TOKEN: ${{ github.token }}
@@ -813,9 +941,14 @@ jobs:
BRANCH_PREFIX="automated/nvd-cve-update"
PR_COMMENT="Superseded by newer automated NVD advisory update."
TITLE="chore: CVE advisories - ${{ steps.transform.outputs.new_count }} new, ${{ steps.updates.outputs.update_count }} updated"
TITLE="chore: update NVD/GHSA advisories - ${{ steps.transform.outputs.new_count }} NVD new, ${{ steps.updates.outputs.update_count }} NVD updated"
COMMIT_SUBJECT="$TITLE"
COMMIT_BODY=$'Automated update from NVD CVE feed.\nKeywords: ${{ env.KEYWORDS }}\nPoll window: ${{ steps.dates.outputs.start_date }} to ${{ steps.dates.outputs.end_date }}'
COMMIT_BODY=$'Automated update from NVD CVE and GHSA advisory feeds.\nKeywords: ${{ env.KEYWORDS }}\nPoll window: ${{ steps.dates.outputs.start_date }} to ${{ steps.dates.outputs.end_date }}'
GHSA_TOTAL="$(jq '.advisories | length' "$GHSA_FEED_PATH")"
GHSA_ACTIVE="$(jq '[.advisories[] | select(.status == "active")] | length' "$GHSA_FEED_PATH")"
GHSA_MATURED="$(jq '[.advisories[] | select(.status == "matured")] | length' "$GHSA_FEED_PATH")"
GHSA_STALE="$(jq '[.advisories[] | select(.status == "stale")] | length' "$GHSA_FEED_PATH")"
if [ "${{ inputs.force_full_scan }}" = "true" ]; then
MODE="full-rebuild (ignore feed state)"
@@ -826,16 +959,19 @@ jobs:
BODY_FILE="$(mktemp)"
cat > "$BODY_FILE" <<EOF
## Summary
Automated update from NVD CVE feed.
Automated update from NVD CVE and GHSA advisory feeds.
- **Mode:** ${MODE}
- **New advisories:** ${{ steps.transform.outputs.new_count }}
- **Updated advisories:** ${{ steps.updates.outputs.update_count }}
- **New NVD advisories:** ${{ steps.transform.outputs.new_count }}
- **Updated NVD advisories:** ${{ steps.updates.outputs.update_count }}
- **GHSA source feed changed:** ${{ steps.feed_changes.outputs.ghsa_changed }}
- **Consolidated agent feed changed:** ${{ steps.feed_changes.outputs.agent_changed }}
- **GHSA provisional advisories:** ${GHSA_TOTAL} total (${GHSA_ACTIVE} active, ${GHSA_MATURED} matured, ${GHSA_STALE} stale)
- **Poll window:** ${{ steps.dates.outputs.start_date }} → ${{ steps.dates.outputs.end_date }}
- **Keywords:** ${{ env.KEYWORDS }}
---
*This PR was automatically generated by the NVD CVE polling workflow.*
*This PR was automatically generated by the NVD CVE polling workflow with GHSA consolidation.*
EOF
PR_LIST_JSON="$(
@@ -882,7 +1018,7 @@ jobs:
git fetch origin main
git checkout -B "$TARGET_BRANCH" origin/main
git add "$FEED_PATH" "$FEED_SIG_PATH" "$SKILL_FEED_PATH" "$SKILL_FEED_SIG_PATH"
git add "$FEED_PATH" "$FEED_SIG_PATH" "$GHSA_FEED_PATH" "$GHSA_FEED_SIG_PATH" "$SKILL_FEED_PATH" "$SKILL_FEED_SIG_PATH"
if git diff --cached --quiet; then
echo "::error::Expected advisory feed changes but none were staged."
exit 1
@@ -962,6 +1098,9 @@ jobs:
echo "| CVEs Found (filtered) | ${{ steps.process.outputs.filtered_count }} |" >> $GITHUB_STEP_SUMMARY
echo "| New Advisories | ${{ steps.transform.outputs.new_count }} |" >> $GITHUB_STEP_SUMMARY
echo "| Updated Advisories | ${{ steps.updates.outputs.update_count }} |" >> $GITHUB_STEP_SUMMARY
echo "| GHSA source feed changed | ${{ steps.feed_changes.outputs.ghsa_changed }} |" >> $GITHUB_STEP_SUMMARY
echo "| Consolidated agent feed changed | ${{ steps.feed_changes.outputs.agent_changed }} |" >> $GITHUB_STEP_SUMMARY
echo "| GHSA provisional advisories | $(jq '.advisories | length' "$GHSA_FEED_PATH") |" >> $GITHUB_STEP_SUMMARY
if [ "${{ steps.transform.outputs.new_count }}" != "0" ]; then
echo "" >> $GITHUB_STEP_SUMMARY
+2 -2
View File
@@ -75,7 +75,7 @@ jobs:
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: SARIF file
path: results.sarif
@@ -84,6 +84,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard (optional).
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
with:
sarif_file: results.sarif
+234 -70
View File
@@ -6,8 +6,7 @@ on:
- '*-v[0-9]*.[0-9]*.[0-9]*'
pull_request:
paths:
- 'skills/*/skill.json'
- 'skills/*/SKILL.md'
- 'skills/**'
workflow_dispatch:
inputs:
tag:
@@ -39,7 +38,7 @@ jobs:
- name: Verify signing key consistency (repo + docs)
run: ./scripts/ci/verify_signing_key_consistency.sh
- name: Validate version parity for bumped skills
- name: Validate version parity for changed skills
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
@@ -79,12 +78,15 @@ jobs:
}
touched_skills_file="$(mktemp)"
git diff --name-only "${BASE_SHA}...${HEAD_SHA}" -- 'skills/*/skill.json' 'skills/*/SKILL.md' \
git diff --name-only "${BASE_SHA}...${HEAD_SHA}" -- \
'skills/*/**' \
':(exclude)skills/*/test/**' \
':(exclude)skills/*/tests/**' \
| awk -F/ 'NF >= 3 {print $1 "/" $2}' \
| sort -u > "${touched_skills_file}"
if [ ! -s "${touched_skills_file}" ]; then
echo "No skill metadata files changed in this PR."
echo "No release-relevant skill package files changed in this PR."
rm -f "${touched_skills_file}"
exit 0
fi
@@ -129,6 +131,8 @@ jobs:
continue
fi
checked_skills=$((checked_skills + 1))
json_version_changed=false
md_version_changed=false
@@ -141,11 +145,11 @@ jobs:
fi
if [ "${json_version_changed}" != "true" ] && [ "${md_version_changed}" != "true" ]; then
echo "No version bump detected for ${skill_dir}; skipping."
echo "::error file=${skill_dir}::Changed skill package has no version bump. Update skill.json and SKILL.md versions and add CHANGELOG.md release notes."
failures=$((failures + 1))
continue
fi
checked_skills=$((checked_skills + 1))
echo "Version bump detected for ${skill_dir} (skill.json changed: ${json_version_changed}, SKILL.md changed: ${md_version_changed})"
if [ ! -f "${json_path}" ]; then
@@ -375,6 +379,26 @@ jobs:
failures=0
mkdir -p dist/dry-run
normalize_release_path() {
local path="$1"
path="${path//\\//}"
while [[ "$path" == ./* ]]; do
path="${path#./}"
done
while [[ "$path" == *//* ]]; do
path="${path//\/\//\/}"
done
if [[ -z "$path" || "$path" == /* || "$path" == [A-Za-z]:* || "$path" == ".." || "$path" == ../* || "$path" == */.. || "$path" == */../* ]]; then
return 1
fi
printf '%s\n' "$path"
}
is_test_release_path() {
local lower="${1,,}"
[[ "$lower" == test/* || "$lower" == tests/* || "$lower" == */test/* || "$lower" == */tests/* ]]
}
while IFS= read -r skill_dir; do
json_path="${skill_dir}/skill.json"
md_path="${skill_dir}/SKILL.md"
@@ -477,8 +501,17 @@ jobs:
temp_sbom_file="$(mktemp)"
jq -r '.sbom.files[].path' "${json_path}" > "${temp_sbom_file}"
while IFS= read -r file; do
[ -z "${file}" ] && continue
while IFS= read -r raw_file; do
[ -z "${raw_file}" ] && continue
if ! file="$(normalize_release_path "${raw_file}")"; then
echo "::error file=${json_path}::SBOM references unsafe file path: ${raw_file}"
failures=$((failures + 1))
continue
fi
if is_test_release_path "${file}"; then
echo " [Dry-run] Skipping test-only release file: ${file}"
continue
fi
full_path="${skill_dir}/${file}"
if [ -f "${full_path}" ]; then
mkdir -p "${inner_dir}/$(dirname "${file}")"
@@ -501,9 +534,17 @@ jobs:
echo " [Dry-run] Removed test signatures from release staging"
fi
# --- Verify staged runtime import closure before archiving ---
python3 scripts/ci/verify_skill_release_import_closure.py "${inner_dir}"
# --- Create zip preserving directory structure ---
zip_name="${skill_name}-v${version}.zip"
(cd "${staging_dir}" && zip -qr "${OLDPWD}/${out_assets}/${zip_name}" .)
if unzip -Z1 "${out_assets}/${zip_name}" | grep -Eiq '(^|/)(test|tests)/'; then
echo "::error::Dry-run release archive contains test-only files: ${zip_name}"
unzip -Z1 "${out_assets}/${zip_name}" | grep -Ei '(^|/)(test|tests)/' || true
failures=$((failures + 1))
fi
# --- Clean up test artifacts from source directory ---
if [ -d "${skill_dir}/advisories" ]; then
@@ -515,8 +556,14 @@ jobs:
# --- Generate checksums.json via jq ---
files_json="{}"
while IFS= read -r file; do
[ -z "${file}" ] && continue
while IFS= read -r raw_file; do
[ -z "${raw_file}" ] && continue
if ! file="$(normalize_release_path "${raw_file}")"; then
continue
fi
if is_test_release_path "${file}"; then
continue
fi
full_path="${skill_dir}/${file}"
if [ -f "${full_path}" ]; then
sha256="$(sha256sum "${full_path}" | awk '{print $1}')"
@@ -615,6 +662,8 @@ jobs:
version: ${{ steps.parse.outputs.version }}
skill_path: ${{ steps.parse.outputs.skill_path }}
publishable: ${{ steps.publishable.outputs.publishable }}
openclaw_skill: ${{ steps.publishable.outputs.openclaw_skill }}
publish_clawhub: ${{ steps.publishable.outputs.publish_clawhub }}
steps:
- name: Parse tag
id: parse
@@ -686,26 +735,39 @@ jobs:
echo "SKILL.md version validated: $MD_VERSION"
fi
else
echo "No SKILL.md found, skipping frontmatter validation"
echo "::error::Missing required SKILL.md: $SKILL_PATH/SKILL.md"
exit 1
fi
- name: Detect publishability
- name: Detect publishability and install defaults
id: publishable
run: |
SKILL_PATH="${{ steps.parse.outputs.skill_path }}"
INTERNAL=$(jq -r '.openclaw.internal // false' "$SKILL_PATH/skill.json")
INTERNAL=$(jq -r 'if (.openclaw | type) == "object" then (.openclaw.internal // false) else false end' "$SKILL_PATH/skill.json")
OPENCLAW_SKILL=false
if jq -e '(.openclaw | type == "object") and ((.openclaw | length) > 0)' "$SKILL_PATH/skill.json" >/dev/null; then
OPENCLAW_SKILL=true
fi
PUBLISHABLE=true
if [ "$INTERNAL" = "true" ]; then
PUBLISHABLE=false
echo "Skill marked internal=true; will skip ClawHub publish."
echo "Skill marked internal=true; will skip ClawHub publishing."
fi
PUBLISH_CLAWHUB=false
if [ "$PUBLISHABLE" = "true" ]; then
PUBLISH_CLAWHUB=true
fi
echo "internal=${INTERNAL}" >> $GITHUB_OUTPUT
echo "openclaw_skill=${OPENCLAW_SKILL}" >> $GITHUB_OUTPUT
echo "publish_clawhub=${PUBLISH_CLAWHUB}" >> $GITHUB_OUTPUT
echo "publishable=${PUBLISHABLE}" >> $GITHUB_OUTPUT
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
@@ -788,6 +850,26 @@ jobs:
mkdir -p release-assets
normalize_release_path() {
local path="$1"
path="${path//\\//}"
while [[ "$path" == ./* ]]; do
path="${path#./}"
done
while [[ "$path" == *//* ]]; do
path="${path//\/\//\/}"
done
if [[ -z "$path" || "$path" == /* || "$path" == [A-Za-z]:* || "$path" == ".." || "$path" == ../* || "$path" == */.. || "$path" == */../* ]]; then
return 1
fi
printf '%s\n' "$path"
}
is_test_release_path() {
local lower="${1,,}"
[[ "$lower" == test/* || "$lower" == tests/* || "$lower" == */test/* || "$lower" == */tests/* ]]
}
# --- Stage SBOM files preserving directory structure ---
STAGING_DIR="$(mktemp -d)"
INNER_DIR="$STAGING_DIR/$SKILL_NAME"
@@ -795,8 +877,16 @@ jobs:
TEMPFILE="$(mktemp)"
jq -r '.sbom.files[].path' "$SKILL_PATH/skill.json" > "$TEMPFILE"
while IFS= read -r file; do
[ -z "$file" ] && continue
while IFS= read -r raw_file; do
[ -z "$raw_file" ] && continue
if ! file="$(normalize_release_path "$raw_file")"; then
echo "::error file=$SKILL_PATH/skill.json::SBOM references unsafe file path: $raw_file"
exit 1
fi
if is_test_release_path "$file"; then
echo "Skipping test-only release file: $file"
continue
fi
FULL_PATH="$SKILL_PATH/$file"
if [ -f "$FULL_PATH" ]; then
mkdir -p "$INNER_DIR/$(dirname "$file")"
@@ -809,14 +899,28 @@ jobs:
cp "$SKILL_PATH/skill.json" "$INNER_DIR/skill.json"
# --- Verify staged runtime import closure before archiving ---
python3 scripts/ci/verify_skill_release_import_closure.py "$INNER_DIR"
# --- Create zip preserving directory structure ---
ZIP_NAME="${SKILL_NAME}-v${VERSION}.zip"
(cd "$STAGING_DIR" && zip -qr "$OLDPWD/release-assets/$ZIP_NAME" .)
if unzip -Z1 "release-assets/$ZIP_NAME" | grep -Eiq '(^|/)(test|tests)/'; then
echo "::error::Release archive contains test-only files: $ZIP_NAME"
unzip -Z1 "release-assets/$ZIP_NAME" | grep -Ei '(^|/)(test|tests)/' || true
exit 1
fi
# --- Generate checksums.json via jq ---
FILES_JSON="{}"
while IFS= read -r file; do
[ -z "$file" ] && continue
while IFS= read -r raw_file; do
[ -z "$raw_file" ] && continue
if ! file="$(normalize_release_path "$raw_file")"; then
continue
fi
if is_test_release_path "$file"; then
continue
fi
FULL_PATH="$SKILL_PATH/$file"
if [ -f "$FULL_PATH" ]; then
SHA256=$(sha256sum "$FULL_PATH" | awk '{print $1}')
@@ -946,8 +1050,73 @@ jobs:
echo "EOF"
} >> $GITHUB_OUTPUT
- name: Build quick install instructions
id: install
run: |
set -euo pipefail
SKILL_NAME="${{ steps.parse.outputs.skill_name }}"
VERSION="${{ steps.parse.outputs.version }}"
REPO="${{ github.repository }}"
TAG="${{ github.ref_name }}"
{
echo "quick_install<<INSTALL_EOF"
if [ "${{ steps.publishable.outputs.publish_clawhub }}" = "true" ] && [ "${{ steps.publishable.outputs.openclaw_skill }}" = "true" ]; then
cat <<EOF
### Quick Install
**Via ClawHub (recommended):**
\`\`\`bash
npx clawhub@latest install ${SKILL_NAME}
\`\`\`
**If you already have \`clawsec-suite\` installed:**
Ask your agent to pull \`${SKILL_NAME}\` from the ClawSec catalog and it will handle setup and verification automatically.
EOF
else
cat <<EOF
### Quick Install
**GitHub release artifact (recommended):**
Ask your agent to read the published skill instructions from this GitHub release and follow them:
https://github.com/${REPO}/releases/download/${TAG}/SKILL.md
Or download them locally:
\`\`\`bash
curl -sLO https://github.com/${REPO}/releases/download/${TAG}/SKILL.md
\`\`\`
EOF
fi
cat <<EOF
**Manual download with verification:**
\`\`\`bash
# 1. Download the release archive, checksums, and signing material
curl -sLO https://github.com/${REPO}/releases/download/${TAG}/${SKILL_NAME}-v${VERSION}.zip
curl -sLO https://github.com/${REPO}/releases/download/${TAG}/checksums.json
curl -sLO https://github.com/${REPO}/releases/download/${TAG}/checksums.sig
curl -sLO https://github.com/${REPO}/releases/download/${TAG}/signing-public.pem
# 2. Verify the checksums manifest signature (Ed25519)
openssl base64 -d -A -in checksums.sig -out checksums.sig.bin
openssl pkeyutl -verify -rawin -pubin -inkey signing-public.pem -sigfile checksums.sig.bin -in checksums.json
# 3. Verify archive checksum from the signed manifest
echo "\$(jq -r '.archive.sha256' checksums.json) ${SKILL_NAME}-v${VERSION}.zip" | sha256sum -c
# 4. Extract (creates ${SKILL_NAME}/ directory)
unzip ${SKILL_NAME}-v${VERSION}.zip
\`\`\`
EOF
echo "INSTALL_EOF"
} >> "$GITHUB_OUTPUT"
- name: Create GitHub Release
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
name: "${{ steps.parse.outputs.skill_name }} ${{ steps.parse.outputs.version }}"
tag_name: ${{ github.ref_name }}
@@ -957,34 +1126,7 @@ jobs:
${{ steps.changelog.outputs.changelog }}
### Quick Install
**Via clawhub (recommended):**
```bash
npx clawhub@latest install ${{ steps.parse.outputs.skill_name }}
```
**If you already have `clawsec-suite` installed:**
Ask your agent to pull `${{ steps.parse.outputs.skill_name }}` from the ClawSec catalog and it will handle setup and verification automatically.
**Manual download with verification:**
```bash
# 1. Download the release archive, checksums, and signing material
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/${{ steps.parse.outputs.skill_name }}-v${{ steps.parse.outputs.version }}.zip
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.json
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.sig
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/signing-public.pem
# 2. Verify the checksums manifest signature (Ed25519)
openssl base64 -d -A -in checksums.sig -out checksums.sig.bin
openssl pkeyutl -verify -rawin -pubin -inkey signing-public.pem -sigfile checksums.sig.bin -in checksums.json
# 3. Verify archive checksum from the signed manifest
echo "$(jq -r '.archive.sha256' checksums.json) ${{ steps.parse.outputs.skill_name }}-v${{ steps.parse.outputs.version }}.zip" | sha256sum -c
# 4. Extract (creates ${{ steps.parse.outputs.skill_name }}/ directory)
unzip ${{ steps.parse.outputs.skill_name }}-v${{ steps.parse.outputs.version }}.zip
```
${{ steps.install.outputs.quick_install }}
### Verification
@@ -1061,28 +1203,28 @@ jobs:
CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
steps:
- name: Check if publishable
if: needs.release-tag.outputs.publishable != 'true'
if: needs.release-tag.outputs.publish_clawhub != 'true'
run: |
echo "Skill marked as internal, skipping ClawHub publish"
echo "Skill is not eligible for ClawHub publishing; skipping"
exit 0
- name: Checkout
if: needs.release-tag.outputs.publishable == 'true'
if: needs.release-tag.outputs.publish_clawhub == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node
if: needs.release-tag.outputs.publishable == 'true'
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
if: needs.release-tag.outputs.publish_clawhub == 'true'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
- name: Install clawhub CLI
if: needs.release-tag.outputs.publishable == 'true' && env.CLAWHUB_TOKEN != ''
if: needs.release-tag.outputs.publish_clawhub == 'true' && env.CLAWHUB_TOKEN != ''
run: npm install -g clawhub@${CLAWHUB_CLI_VERSION}
- name: Patch clawhub publish payload workaround
# Temporary: clawhub@0.7.0 publish payload is missing acceptLicenseTerms.
if: needs.release-tag.outputs.publishable == 'true' && env.CLAWHUB_TOKEN != ''
if: needs.release-tag.outputs.publish_clawhub == 'true' && env.CLAWHUB_TOKEN != ''
run: |
node <<'NODE'
const { execSync } = require("node:child_process");
@@ -1125,7 +1267,7 @@ jobs:
NODE
- name: Login to ClawHub
if: needs.release-tag.outputs.publishable == 'true' && env.CLAWHUB_TOKEN != ''
if: needs.release-tag.outputs.publish_clawhub == 'true' && env.CLAWHUB_TOKEN != ''
run: |
set -euo pipefail
SITE=${CLAWHUB_SITE:-https://clawhub.ai}
@@ -1135,8 +1277,38 @@ jobs:
CLAWHUB_DISABLE_TELEMETRY=1 CLAWHUB_SITE="$SITE" CLAWHUB_REGISTRY="$REGISTRY" \
clawhub login --token "$CLAWHUB_TOKEN" --site "$SITE" --no-input
- name: Guard duplicate ClawHub version
if: needs.release-tag.outputs.publish_clawhub == 'true' && env.CLAWHUB_TOKEN != ''
run: |
set -euo pipefail
SITE=${CLAWHUB_SITE:-https://clawhub.ai}
REGISTRY=${CLAWHUB_REGISTRY:-$SITE}
SKILL_NAME="${{ needs.release-tag.outputs.skill_name }}"
VERSION="${{ needs.release-tag.outputs.version }}"
export CLAWHUB_CONFIG_PATH="$HOME/.clawhub-ci/config.json"
set +e
CLAWHUB_DISABLE_TELEMETRY=1 CLAWHUB_SITE="$SITE" CLAWHUB_REGISTRY="$REGISTRY" \
clawhub inspect "$SKILL_NAME" --version "$VERSION" --json \
> /tmp/clawhub-existing-version.json 2> /tmp/clawhub-existing-version.err
STATUS=$?
set -e
if [ "$STATUS" -eq 0 ]; then
echo "::error::ClawHub already contains ${SKILL_NAME}@${VERSION}. Bump the version before tagging."
exit 1
fi
if grep -Eqi "Version not found|Skill not found" /tmp/clawhub-existing-version.err; then
echo "No existing ${SKILL_NAME}@${VERSION} detected in ClawHub. Proceeding."
else
echo "::error::Failed to verify ClawHub version precondition."
cat /tmp/clawhub-existing-version.err
exit 1
fi
- name: Publish to ClawHub
if: needs.release-tag.outputs.publishable == 'true' && env.CLAWHUB_TOKEN != ''
if: needs.release-tag.outputs.publish_clawhub == 'true' && env.CLAWHUB_TOKEN != ''
run: |
set -euo pipefail
SITE=${CLAWHUB_SITE:-https://clawhub.ai}
@@ -1149,7 +1321,6 @@ jobs:
export CLAWHUB_CONFIG_PATH="$HOME/.clawhub-ci/config.json"
# Publish with idempotent retry handling
if ! CLAWHUB_DISABLE_TELEMETRY=1 CLAWHUB_SITE="$SITE" CLAWHUB_REGISTRY="$REGISTRY" \
clawhub publish "$SKILL_PATH" \
--slug "$SKILL_NAME" \
@@ -1158,15 +1329,8 @@ jobs:
--changelog "$CHANGELOG" \
--tags "latest" \
--no-input 2>&1 | tee /tmp/clawhub-publish.log; then
# Check if it's a "version already exists" error (which means previous run partially succeeded)
if grep -qi "version already exists" /tmp/clawhub-publish.log; then
echo "::warning::Version $VERSION already published to ClawHub (from previous run)"
exit 0
else
echo "::error::ClawHub publish failed. Check logs above for details."
exit 1
fi
echo "::error::ClawHub publish failed. Check logs above for details."
exit 1
fi
echo "✓ Successfully published $SKILL_NAME@$VERSION to ClawHub"
@@ -1218,7 +1382,7 @@ jobs:
id: publishable
run: |
SKILL_PATH="${{ steps.parse.outputs.skill_path }}"
INTERNAL=$(jq -r '.openclaw.internal // false' "$SKILL_PATH/skill.json")
INTERNAL=$(jq -r 'if (.openclaw | type) == "object" then (.openclaw.internal // false) else false end' "$SKILL_PATH/skill.json")
if [ "$INTERNAL" = "true" ]; then
echo "::error::Skill is marked internal and cannot be published to ClawHub"
@@ -1228,7 +1392,7 @@ jobs:
echo "Skill is publishable to ClawHub"
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
+30
View File
@@ -0,0 +1,30 @@
cff-version: 1.2.0
message: "If you use ClawSec in research or security tooling, please cite it as below."
title: "ClawSec"
version: "0.1.0"
date-released: "2026-05-26"
abstract: >-
ClawSec is a security skill suite for AI agent platforms. It provides
advisory monitoring, cryptographic signature verification, guarded skill
installation, file integrity checks, and platform-specific security
capabilities for OpenClaw, NanoClaw, Hermes, and Picoclaw deployments.
type: software
license: "AGPL-3.0-or-later"
url: "https://clawsec.prompt.security/"
repository-code: "https://github.com/prompt-security/clawsec"
keywords:
- ai-security
- agent-security
- prompt-injection
- security-advisories
- software-supply-chain
- integrity-verification
- openclaw
- nanoclaw
- hermes
- picoclaw
authors:
- given-names: David
family-names: Abutbul
affiliation: "Prompt Security"
orcid: "https://orcid.org/0009-0001-7883-3593"
+434
View File
@@ -0,0 +1,434 @@
<!-- AUTO-GENERATED TRANSLATION SCAFFOLD (de)
Source: README.md
Review status: draft
-->
# Deutsch Translation Scaffold
This file is currently a draft scaffold. Use README.md as the canonical source.
<h1 align="center">
<img src="/img/prompt-icon.svg" alt="prompt-icon" breit="40">
ClawSec: Security Skill Suite für KI-Agenten
<img src="/img/prompt-icon.svg" alt="prompt-icon" breit="40">
</h1>
<div align="center">
Sichern Sie Ihre OpenClaw, NanoClaw und Hermes Agents mit einer kompletten Sicherheits-Fähigkeits-Suite
<h4>Brought to you von <a href="https://prompt.security">Prompt Security</a>, the Platform of AI Security</h4>
</div>
<div align="center">
![Prompt Security Logo](./img/Black+Color.png)
<img src="/public/img/mascot.png" alt="clawsec mascot" breit="200" />
</div>
<div align="center">
🌐 **Live at: [https://clawsec.prompt.security](https://clawsec.prompt.security)[https://prompt.security/clawsec](https://prompt.security/clawsec)**
[![CI](https://github.com/prompt-security/clawsec/actions/workflows/ci.yml/badge.svg)](https://github.com/prompt-security/clawsec/actions/workflows/ci.yml)
[![Deploy Pages](https://github.com/prompt-security/clawsec/actions/workflows/deploy-pages.yml/badge.svg)](https://github.com/prompt-security/clawsec/actions/workflows/deploy-pages.yml)
[![Poll NVD CVEs](https://github.com/prompt-security/clawsec/actions/workflows/poll-nvd-cves.yml/badge.svg)](https://github.com/prompt-security/clawsec/actions/workflows/poll-nvd-cves.yml)
</div>
--
Übersetzungen
- Español: [README.es.md](README.es.md)
- 한국어: [README.ko.md](README.ko.md)
Was ist ClawSec?
ClawSec ist eine ** umfassende Sicherheits-Fähigkeits-Suite für AI-Agent-Plattformen*. Es bietet eine einheitliche Sicherheitsüberwachung, Integritätsprüfung und Bedrohung Intelligenz-Schutz der kognitiven Architektur Ihres Agenten gegen schnelle Injektion, Drift und schädliche Anweisungen.
### Unterstützte Plattformen
- **OpenClaw** (MoltBot, Clawdbot und Klone) - Komplette Suite mit Skill-Installer, Dateiintegritätsschutz und Sicherheitsaudits
- **NanoKlaue** - Gebindet App-Bot-Sicherheit mit MCP-Tools für die Überwachung, Unterschriftsprüfung und Dateiintegrität
- **Hermes** - Hermes-native Sicherheitskompetenzen für eine unterzeichnete Beratungs-Feed-Verifikation, beratungssichere Verifikation, deterministische Attestations-Generierung, fehlgeschlossene Verifikation und grundlegende Drifterkennung
- **Picoclaw** - Leichte AI-Gateway-Sicherheitsüberprüfungen mit Beratendem Bewusstsein, config Drift-Erkennung, Release-artifact-Verifikation und einem optionalen separaten Selbst-Pen-Testpaket
### Skill Feature Matrix
| Skill name | unterstützte Plattform| Sicherheits-Feed-Verifikation- config Drift | Agent Self Pen-Tests- Supply-Chain-Verifikation |
...
| claw-release | OpenClaw | Nein | Nein | Nein | Nein | Ja |
| clawsec-clawhub-checker | OpenClaw + clawsec-suite Integration | Nein | Nein | Nein | Ja |
| clawsec-feed | OpenClaw | Ja | Nein | Nein | Ja |
Ja | Ja | Ja | Ja | Ja | Ja |
| clawsec-scanner | OpenClaw | Ja | Nein | Ja | Ja | Ja |
| clawsec-suite | OpenClaw | Ja | Ja | Nein | Ja |
| Clawtributor | OpenClaw | Ja | Nein | Nein
| hermes-attestation-guardian | Hermes | Ja (signierte beratende Feed-Verifikation) | Ja | Nein | Limited (nur Vorabbeleuchtung; keine Artefaktsignatur/provenance install-Verifikation) |
| Openclaw-audit-watchdog | OpenClaw | Nein | Nein | Ja | Nein
| Picoclaw-security-guardian | Picoclaw | Ja | Ja | Nein
| Picoclaw-self-pen-testing
| Seelenhüter | OpenClaw | Nein | Nein | Nein
### Core Caps
- **📦 Suite Installer** - One-Command-Installation aller Sicherheitsfertigkeiten mit Integritätsprüfung
- **🛡️ Datei-Integrity-Schutz* - Drift-Erkennung und Auto-Restore für kritische Agent-Dateien (SOUL.md, IDENTITY.md, etc.)
- **📡 Live Security Advisories* - Automatisierte NVD CVE Umfragen und Community-Drohung Intelligenz
- **🔍 Security Audits** - Self-Check-Skripte, um schnelle Injektionsmarker und Schwachstellen zu erkennen
- **🔐 Prüfsummenverifikation** - SHA256 Prüfsummen für alle Fähigkeiten Artefakte
- **Health Checks* - Automatisierte Updates und Integritätsprüfung für alle installierten Fähigkeiten
--
Produktdemonstrationen
Animierte Vorschauen unten sind GIFs (keine Audio). Klicken Sie auf jede Vorschau, um das volle MP4 mit Audio zu öffnen.
### Demo installieren (`clawsec-suite`)
[![Install demo animated preview](public/video/install-demo-preview.gif)(öffentlich/video/install-demo.mp4)
Direkter Link: [install-demo.mp4](public/video/install-demo.mp4)
### Drift Detection Demo (`soul-guardian`)
[![Drift detection animated preview](public/video/soul-guardian-demo-preview.gif)(öffentlich/video/soul-guardian-demo.mp4)
Direkter Link: [soul-guardian-demo.mp4](public/video/soul-guardian-demo.mp4)
--
🚀 Schneller Start
### For AI Agents
```bash
# Install the ClawSec security suite
npx clawhub@latest install clawsec-suite
```
Nach der Installation kann die Suite:
ANHANG Entdecken Sie installierbare Schutze aus dem veröffentlichten Kompetenzkatalog
2. Verifizieren Sie die Freigabeintegrität mit unterzeichneten Prüfsummen
3. Einrichtung von Beratungs- und hakenbasierten Schutzströmen
4. Optionale geplante Überprüfungen hinzufügen
Manual/source-first option:
> Weiterlesen https://github.com/prompt-security/clawsec/releases/latest/download/SKILL.md und folgen den Installationsanweisungen.
### For Humans
Kopieren Sie diese Anleitung zu Ihrem KI-Agent:
> Installieren Sie ClawSec mit `npx clawhub@latest install clawsec-suite`, füllen Sie dann die Setup-Schritte aus den generierten Anweisungen aus.
### Shell and OS Notes
ClawSec-Skripte werden aufgeteilt zwischen:
- Cross-Plattform Node/Python-Tooling (`npm run build`, Haken/Setup `.mjs`_ `utils/*.py`_
- POSIX Shell Workflows (`*.sh`, die meisten manuellen Installationsschnipsel)
Für Linux/macOS (`bash`/`zsh`):
- Verwenden Sie nicht zitiertes oder doppelt zitiertes Zuhause vars: `export INSTALL_ROOT="$HOME/.openclaw/skills"`
- Do **not** Einquoten-Expandierbare Vars (zum Beispiel `'$HOME/.openclaw/skills'`)
Für Windows (PowerShell):
- Präferen Sie explizite Pfadaufbau:
- Was?
- Was?
- POSIX `.sh` Skripte benötigen WSL oder Git Bash.
Fehlerbehebung: Wenn Sie Verzeichnisse wie `~/.openclaw/workspace/$HOME/...` sehen, wurde eine Heimvariable buchstäblich übergeben. Re-run mit einem absoluten Pfad oder einem nicht zitierten Heimausdruck.
--
Plattform & Suite Dokumentation
Detaillierte Plattform und Suiten docs live in den Wiki-Modulen:
- NanoClaw: [wiki/modules/nanoclaw-integration.md](wiki/modules/nanoclaw-integration.md)
- Hermes: [wiki/modules/hermes-attestation-guardian.md](wiki/modules/hermes-attestation-guardian.md)
- Picoclaw: [wiki/modules/picoclaw-security-guardian.md](wiki/modules/picoclaw-security-guardian.md)
- Picoclaw Selbstprüfung: [wiki/modules/picoclaw-self-pen-testing.md](wiki/modules/picoclaw-self-pen-testing.md)
- ClawSec Suite (OpenClaw): [wiki/modules/clawsec-suite.md](wiki/modules/clawsec-suite.md)
- CI/CD-Pipelines: [wiki/modules/automation-release.md](wiki/modules/automation-release.md)
Schnelle Installation von Links:
- NanoClaw installiert: [skills/clawsec-nanoclaw/INSTALL.md](skills/clawsec-nanoclaw/INSTALL.md)
- Hermes Geschick Paket: `skills/hermes-attestation-guardian/`
- Picoclaw Schutzpaket: `skills/picoclaw-security-guardian/`
- Picoclaw Selbstprüfungspaket: `skills/picoclaw-self-pen-testing/`
- Suite-Paket: `skills/clawsec-suite/`
--
📡 Sicherheitsberatung Fütterung
ClawSec unterhält einen kontinuierlich aktualisierten Sicherheitsberatungsfeed, der automatisch aus der NIST National Vulnerability Database (NVD) besiedelt wird.
### Feed URL
```bash
# Fetch latest advisories
curl -s https://clawsec.prompt.security/advisories/feed.json | jq '.advisories[] | select(.severity == "critical" or .severity == "high")'
```
Kanonischer Endpunkt: `https://clawsec.prompt.security/advisories/feed.json`
Kompatibilitätsspiegel (Legalacy): `https://clawsec.prompt.security/releases/latest/download/feed.json`
### Überwachte Keywords
Die Feed-Quoten CVEs bezogen auf:
**OpenClaw Platform**: `OpenClaw`, `clawdbot`__, `Moltbot`
**NanoClaw Platform**: `NanoClaw`____________________________________________________
- **Picoclaw Platform*: `Picoclaw`, `picoclaw`, leichte AI Gateways, MCP Gateway Belichtung
- Prompt Injektionsmuster
- Sicherheitslücken von Agenten
### Exploitability Context
ClawSec bereichert CVE-Advisories mit **-Exploitability-Kontext**, um Agenten dabei zu helfen, das reale Risiko über die rohen CVSS-Score hinaus zu bewerten. Neu analysierte Berater können:
- **Exploit Evidence**: Ob öffentliche Ausbeutungen in der Wildnis existieren
- **Beantwortungsstatus**: Wenn Exploits in gemeinsame Angriffsrahmen integriert werden
- **Anforderungen**: Voraussetzungen für eine erfolgreiche Nutzung (Netzwerkzugriff, Authentifizierung, Benutzerinteraktion)
- **Risikobewertung**: Kontextualisiertes Risikoniveau, das technische Schwere mit Ausbeutbarkeit kombiniert
Diese Funktion hilft Agenten, Schwachstellen zu priorisieren, die unmittelbare Bedrohungen gegenüber theoretischen Risiken darstellen und intelligentere Sicherheitsentscheidungen ermöglichen.
### Advisory Schema
**NVD CVE Beratung:**
```json
{
"id": "CVE-2026-XXXXX",
"severity": "critical|high|medium|low",
"type": "vulnerable_skill",
"platforms": ["openclaw", "nanoclaw"],
"title": "Short description",
"description": "Full CVE description from NVD",
"published": "2026-02-01T00:00:00Z",
"cvss_score": 8.8,
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-XXXXX",
"exploitability_score": "high|medium|low|unknown",
"exploitability_rationale": "Why this CVE is or is not likely exploitable in agent deployments",
"references": ["..."],
"action": "Recommended remediation"
}
```
**Gemeinschaftsbeirat:**
```json
{
"id": "CLAW-2026-0042",
"severity": "high",
"type": "prompt_injection|vulnerable_skill|tampering_attempt",
"platforms": ["nanoclaw"],
"title": "Short description",
"description": "Detailed description from issue",
"published": "2026-02-01T00:00:00Z",
"affected": ["skill-name@1.0.0"],
"source": "Community Report",
"github_issue_url": "https://github.com/.../issues/42",
"action": "Recommended remediation"
}
```
**Platformwerte:**
- `"openclaw"` - OpenClaw/Clawdbot/Molt Nur
- `"nanoclaw"` - NanoClaw nur
- Nur Hermes
- Nur Picoclaw
- `["openclaw", "nanoclaw", "hermes", "picoclaw"]` - Alle Kernplattformen
- (leer/missing) - Alle Plattformen (backward kompatibel)
--
🔄 CI/CD Pipelines
CI/CD Pipelinedetails wurden auf die Wiki-Modulseite verschoben:
- Was?
Ähnliche Arbeitspunkte:
- Was?
- Was?
--
🛠️ Offline Tools
ClawSec umfasst Python utilities für lokale Fähigkeiten Entwicklung und Validierung.
### Skill Validator
Validiert einen Geschicksordner gegen das erforderliche Schema:
```bash
python utils/validate_skill.py skills/clawsec-feed
```
Kontrollen:
- `skill.json` existiert und ist gültig JSON
- Erforderliche Felder vorhanden (Name, Version, Beschreibung, Autor, Lizenz)
- SBOM-Dateien existieren und sind lesbar
- OpenClaw Metadaten sind richtig strukturiert
### Skill Checksums Generator
Erzeugt `checksums.json` mit SHA256 Hashes für ein Geschick:
```bash
python utils/package_skill.py skills/clawsec-feed ./dist
```
Ausgänge:
- `checksums.json` - SHA256 hathes zur Überprüfung
--
Lokale Entwicklung
### Voraussetzungen
- Node.js 20+
- Python 3.10+ (für Offline-Tools)
- npm
### Setup
```bash
# Install dependencies
npm install
# Start development server
npm run dev
```
### Lokale Daten ausfüllen
```bash
# Populate skills catalog from local skills/ directory
./scripts/populate-local-skills.sh
# Populate advisory feed with real NVD CVE data
./scripts/populate-local-feed.sh --days 120
# Generate wiki llms exports from wiki/ (for local preview)
./scripts/populate-local-wiki.sh
# Direct generator entrypoint (used by predev/prebuild)
npm run gen:wiki-llms
```
Anmerkungen:
- `npm run dev` und `npm run build` regenerieren automatisch wiki `llms.txt` Exporte (`predev`_`prebuild` Haken).
- `public/wiki/` wird ausgegeben (lokal + CI) und ist absichtlich gitignored.
### Build
```bash
npm run build
```
--
Projektstruktur
```
├── advisories/
│ ├── feed.json # Main advisory feed
│ ├── feed.json.sig # Detached signature for feed.json
│ └── feed-signing-public.pem # Public key for feed verification
├── components/ # React components
├── pages/ # Route/page components
├── wiki/ # Source-of-truth docs (synced to GitHub Wiki)
├── scripts/
│ ├── generate-wiki-llms.mjs # wiki/*.md -> public/wiki/**/llms.txt
│ ├── populate-local-feed.sh # Local CVE feed populator
│ ├── populate-local-skills.sh # Local skills catalog populator
│ ├── populate-local-wiki.sh # Local wiki llms export populator
│ ├── prepare-to-push.sh # Local CI-style quality gate
│ ├── validate-release-links.sh # Release link checks
│ └── release-skill.sh # Manual skill release helper
├── skills/
│ ├── claw-release/ # 🚀 Release automation workflow skill
│ ├── clawsec-suite/ # 📦 Suite installer (skill-of-skills)
│ ├── clawsec-feed/ # 📡 Advisory feed skill
│ ├── clawsec-scanner/ # 🔍 Vulnerability scanner (deps + SAST + OpenClaw DAST)
│ ├── clawsec-nanoclaw/ # 📱 NanoClaw platform security suite
│ ├── clawsec-clawhub-checker/ # 🧪 ClawHub reputation checks
│ ├── clawtributor/ # 🤝 Community reporting skill
│ ├── hermes-attestation-guardian/ # 🛡️ Hermes attestation + drift verification
│ ├── openclaw-audit-watchdog/ # 🔭 Automated audit skill
│ ├── picoclaw-security-guardian/ # 🦐 Picoclaw posture/advisory/drift/supply-chain checks
│ ├── picoclaw-self-pen-testing/ # 🧪 Picoclaw self-pen-testing checks (separate package)
│ └── soul-guardian/ # 👻 File integrity skill
├── utils/
│ ├── package_skill.py # Skill packager utility
│ └── validate_skill.py # Skill validator utility
├── .github/workflows/
│ ├── ci.yml # Cross-platform lint/type/build + tests
│ ├── pages-verify.yml # PR-only pages build/signing verification
│ ├── poll-nvd-cves.yml # CVE polling pipeline
│ ├── community-advisory.yml # Approved issue -> advisory PR
│ ├── skill-release.yml # Skill release/signing pipeline
│ ├── deploy-pages.yml # GitHub Pages deployment
│ ├── wiki-sync.yml # Sync repo wiki/ to GitHub Wiki
│ ├── codeql.yml # CodeQL security analysis
│ └── scorecard.yml # OpenSSF Scorecard checks
└── public/ # Static assets + generated wiki exports
```
--
Beiträge
Wir begrüßen Beiträge! Siehe [CONTRIBUTING.md](CONTRIBUTING.md) für Richtlinien.
### Sicherheitsberater einfügen
Haben Sie einen schnellen Injektionsvektor, bösartige Fähigkeiten oder Sicherheitslücke gefunden? Über GitHub Issues melden:
ANHANG Öffne ein neues Problem mit der Vorlage **Security Incident Report***
2. Füllen Sie die erforderlichen Felder aus (Stärke, Art, Beschreibung, Betroffene Fähigkeiten)
3. Ein Betreuer überprüft und fügt das `advisory-approved` Label hinzu
4. Die Beratung wird automatisch im Feed veröffentlicht als `CLAW-{YEAR}-{ISSUE#}`
Siehe `CLAW-{YEAR}-{ISSUE#}` für detaillierte Richtlinien.
### Neue Fähigkeiten hinzufügen
ANHANG Erstellen Sie einen Kompetenzordner unter `skills/`
2. Hinzufügen `skill.json` mit benötigten Metadaten und SBOM
3. `SKILL.md` mit agentenlesbaren Anweisungen hinzufügen
4. Gültig mit `python utils/validate_skill.py skills/your-skill`
5. Eine PR zur Überprüfung einreichen
📚 Dokumentation Quelle der Wahrheit
Für alle Wiki-Inhalte bearbeiten Sie Dateien unter `wiki/` in diesem Repository. Das GitHub Wiki (`<repo>.wiki.git`) wird von `wiki/`_ durch `.github/workflows/wiki-sync.yml` synchronisiert, wenn `wiki/**`_ auf `main`__ wechselt.
LLM-Exporte werden von `wiki/` in `public/wiki/`_ generiert:
- `/wiki/llms.txt` ist der LLM-ready Export für `wiki/INDEX.md` (oder ein generierter Fallback-Index, wenn `INDEX.md` fehlt).
- `/wiki/<page>/llms.txt` ist der LLM-ready Export für diese einzelne Wiki-Seite.
--
📄 Lizenz
- Quellcode: GNU AGPL v3.0 oder später - Siehe [LICENSE](LICENSE) für Details.
- Schriften in `font/`: separat lizenziert - Siehe [`font/README.md`](font/README.md).
--
<div align="center">
**ClawSec** · Sicherheitsleistung, SentinelOne
🦞 Härten Agentic Workflows, eine Fähigkeit zu einer Zeit.
</div>
+277
View File
@@ -0,0 +1,277 @@
■h1 align="center"
■img src="./img/prompt-icon.svg" alt="prompt-icon" ancho="40"
ClawSec: Suite de habilidades de seguridad para agentes de IA
■img src="./img/prompt-icon.svg" alt="prompt-icon" ancho="40"
■/h1 título
"Cierto"
## Protege tus agentes OpenClaw, NanoClaw, Hermes y Picoclaw con una suite de seguridad completa
<h4>Traído por <a href="https://prompt.security">Prompt Security</a>, la plataforma de seguridad para IA</h4>
■/div titulada
"Cierto"
! [Prompt Security Logo](./img/Black+Color.png)
■img src="./public/img/mascot.png" alt="clawsec mascot" ancho="200" /
■/div titulada
"Cierto"
🌐 **En vivo en: [https://clawsec.prompt.security](https://clawsec.prompt.security) [https://prompt.security/clawsec](https://prompt.security/clawsec)**
[![CI](https://github.com/prompt-security/clawsec/actions/workflows/ci.yml/badge.svg)](https://github.com/prompt-security/clawsec/actions/workflows/ci.yml)
[![Deploy Pages](https://github.com/prompt-security/clawsec/actions/workflows/deploy-pages.yml/badge.svg)](https://github.com/prompt-security/clawsec/actions/workflows/deploy-pages.yml)
[![Poll NVD CVEs](https://github.com/prompt-security/clawsec/actions/workflows/poll-nvd-cves.yml/badge.svg)](https://github.com/prompt-security/clawsec/actions/workflows/poll-nvd-cves.yml)
■/div titulada
-...
## 🌍 Traducciones
- English (source of truth): [README.md](README.md)
- Español: `README.es.md`
## ✅ Estado de traducción (Fase 1)
Esta traducción en español cubre el flujo principal de onboarding y operación.
Para detalles de bajo nivel, schema completo y notas avanzadas de CI/CD, consulta el README en inglés.
## 🦞 ¿Qué es ClawSec?
ClawSec es una **suite completa de habilidades de seguridad para plataformas de agentes de IA**. Proporciona monitoreo de seguridad unificado, verificación de integridad e inteligencia de amenazas para proteger la arquitectura cognitiva del agente frente a inyección de prompts, deriva y/o instrucciones maliciosas.
### Plataformas compatibles
- **OpenClaw** (MoltBot, Clawdbot y clones): suite completa con instalador de habilidades, protección de integridad de archivos y auditorías de seguridad
- **NanoClaw**: seguridad para bot de WhatsApp en contenedor, con herramientas MCP para monitoreo de advisories, verificación de firmas e integridad de archivos
- **Hermes**: habilidades nativas de Hermes para verificación de feed firmado, verificación protegida con contexto de advisories, generación determinista de attestation, fail-closed verification y detección de drift de baseline
- **Picoclaw**: chequeos livianos de postura de seguridad en gateway de IA, con awareness de advisories, detección de drift de configuración, verificación de artefactos de release y paquete opcional separado de self-pen-testing
### Capacidades principales
- **📦 Instalador de suite**: instalación de todas las habilidades de seguridad con un solo comando y verificación de integridad
- **🛡️ Protección de integridad de archivos**: detección de drift y auto-restauración de archivos críticos del agente (`SOUL.md`, `IDENTITY.md`, etc.)
- **📡 Advisories de seguridad en vivo**: polling automatizado de CVEs del NVD e inteligencia comunitaria
- **🔍 Auditorías de seguridad**: scripts de auto-chequeo para detectar marcadores de prompt injection y vulnerabilidades
- **🔐 Verificación por checksums**: hashes SHA256 para artefactos de habilidades
- **Health checks**: actualizaciones y verificación de integridad automatizadas para habilidades instaladas
-...
## 🚀 Inicio rápido
### Para agentes de IA
```bash
# Instala la suite de seguridad de ClawSec
npx clawhub@latest install clawsec-suite
```
Después de instalar, la suite puede:
1. Descubrir protecciones instalables desde el catálogo de habilidades publicado
2. Verificar la integridad de releases con checksums firmados
3. Configurar monitoreo de advisories y flujos de protección basados en hooks
4. Añadir chequeos programados opcionales
Opción manual/source-first:
> Lee https://github.com/prompt-security/clawsec/releases/latest/download/SKILL.md y sigue las instrucciones de instalación.
### Para humanos
Copia esta instrucción a tu agente:
> Instala ClawSec con `npx clawhub@latest install clawsec-suite`, y luego completa los pasos de setup desde las instrucciones generadas.
### Notas de shell y SO
Los scripts de ClawSec se dividen entre:
- Tooling cross-platform en Node/Python (`npm run build`, hooks/setup `.mjs`, `utils/*.py`)
- Flujos POSIX shell (`*.sh`, la mayoría de snippets de instalación manual)
Para Linux/macOS (`bash`/`zsh`):
- Usa variables home sin comillas simples o con comillas dobles: `export INSTALL_ROOT="$HOME/.openclaw/skills"`
- **No** uses comillas simples con variables expandibles (por ejemplo, evita `'$HOME/.openclaw/skills'`)
Para Windows (PowerShell):
- Prefiere construir rutas de forma explícita:
- `$env:INSTALL_ROOT = Join-Path $HOME ".openclaw\\skills"`
- `node "$env:INSTALL_ROOT\\clawsec-suite\\scripts\\setup_advisory_hook.mjs"`
- Los scripts POSIX `.sh` requieren WSL o Git Bash.
Si ves rutas tipo `~/.openclaw/workspace/$HOME/...`, una variable home se pasó de forma literal. Re-ejecuta con ruta absoluta o con expresión home expandible.
-...
## 🧭 Documentación por plataforma y suite
La documentación detallada vive en los módulos del wiki:
- NanoClaw: [wiki/modules/nanoclaw-integration.md](wiki/modules/nanoclaw-integration.md)
- Hermes: [wiki/modules/hermes-attestation-guardian.md](wiki/modules/hermes-attestation-guardian.md)
- Picoclaw: [wiki/modules/picoclaw-security-guardian.md](wiki/modules/picoclaw-security-guardian.md)
- Picoclaw auto-pen-testing: [wiki/modules/picoclaw-self-pen-testing.md](wiki/modules/picoclaw-self-pen-testing.md)
- ClawSec Suite (OpenClaw): [wiki/modules/clawsec-suite.md](wiki/modules/clawsec-suite.md)
- Pipelines CI/CD: [wiki/modules/automation-release.md](wiki/modules/automation-release.md)
Instalación rápida:
- NanoClaw: [skills/clawsec-nanoclaw/INSTALL.md](skills/clawsec-nanoclaw/INSTALL.md)
- Hermes: `skills/hermes-attestation-guardian/`
- Picoclaw guardian: `skills/picoclaw-security-guardian/`
- Picoclaw self-pen-testing: `skills/picoclaw-self-pen-testing/`
- Suite: `skills/clawsec-suite/`
-...
## 📡 Feed de advisories de seguridad
ClawSec mantiene un feed de advisories continuamente actualizado, poblado automáticamente desde la National Vulnerability Database (NVD) de NIST.
### URL del feed
```bash
# Obtener advisories recientes
curl -s https://clawsec.prompt.security/advisories/feed.json | jq '.advisories[] | select(.severity == "critical" or .severity == "high")'
```
Endpoint canónico: `https://clawsec.prompt.security/advisories/feed.json`
Mirror de compatibilidad (legacy): `https://clawsec.prompt.security/releases/latest/download/feed.json`
### Palabras clave monitoreadas
El feed consulta CVEs relacionados con:
- **OpenClaw**: `OpenClaw`, `clawdbot`, `Moltbot`
- **NanoClaw**: `NanoClaw`, `WhatsApp-bot`, `baileys`
- **Picoclaw**: `Picoclaw`, gateways ligeros de IA, exposición de gateway MCP
- Patrones de prompt injection
- Vulnerabilidades de seguridad en agentes
### Contexto de explotabilidad
ClawSec enriquece advisories de CVE con **contexto de explotabilidad** para ayudar a evaluar riesgo real, más allá del score CVSS bruto.
Puede incluir:
- **Evidencia de exploit**: si hay exploits públicos activos
- **Estado de weaponization**: si hay integración en frameworks comunes de ataque
- **Requisitos de ataque**: prerequisitos para explotar (acceso de red, autenticación, interacción de usuario)
- **Evaluación de riesgo**: nivel contextualizado que combina severidad técnica y explotabilidad
Esto ayuda a priorizar vulnerabilidades de amenaza inmediata frente a riesgos teóricos.
-...
## 🛠️ Desarrollo local
### Prerrequisitos
- Node.js 20+
- Python 3.10+ (herramientas offline)
- Npm
#### Setup
```bash
# Instala dependencias
npm install
# Arranca servidor de desarrollo
npm run dev
```
### Poblar datos locales
```bash
# Poblar catálogo de skills desde skills/
./scripts/populate-local-skills.sh
# Poblar advisory feed con CVEs reales del NVD
./scripts/populate-local-feed.sh --days 120
# Generar exportes llms desde wiki/ (preview local)
./scripts/populate-local-wiki.sh
# Entry point directo del generador (usado por predev/prebuild)
npm run gen:wiki-llms
```
Notas:
- `npm run dev` y `npm run build` regeneran automáticamente exportes `llms.txt` (`predev`/`prebuild`).
- `public/wiki/` es salida generada (local + CI) y está gitignored intencionalmente.
### Build
```bash
npm run build
```
-...
## 📁 Estructura del proyecto
```text
├── advisories/
├── components/
├── pages/
├── wiki/
├── scripts/
├── skills/
├── utils/
├── .github/workflows/
└── public/
```
Para la estructura detallada (scripts, workflows y paquetes de habilidades), consulta el README principal en inglés: [README.md](README.md).
-...
## 🤝 Contribuir
¡Contribuciones bienvenidas! Revisa [CONTRIBUTING.md](CONTRIBUTING.md).
### Enviar advisories de seguridad
¿Encontraste un vector de prompt injection, skill malicioso o vulnerabilidad? Repórtalo en GitHub Issues:
1. Abre un issue nuevo con la plantilla **Security Incident Report**
2. Completa campos requeridos (severidad, tipo, descripción, skills afectados)
3. Un maintainer revisa y agrega `advisory-approved`
4. El advisory se publica automáticamente en el feed como `CLAW-{YEAR}-{ISSUE#}`
Guía detallada: [CONTRIBUTING.md#submitting-security-advisories](CONTRIBUTING.md#submitting-security-advisories)
### Añadir nuevas skills
1. Crea carpeta bajo `skills/`
2. Agrega `skill.json` con metadata requerida y SBOM
3. Agrega `SKILL.md` con instrucciones legibles por agente
4. Valida con `python utils/validate_skill.py skills/your-skill`
5. Envía PR para review
## 📚 Fuente de verdad de la documentación
Todo el contenido wiki se edita en `wiki/` dentro de este repo. El GitHub Wiki (`<repo>.wiki.git`) se sincroniza desde `wiki/` vía `.github/workflows/wiki-sync.yml` cuando cambia `wiki/**` en `main`.
Los exportes para LLM se generan desde `wiki/` hacia `public/wiki/`:
- `/wiki/llms.txt` exporta `wiki/INDEX.md` (o índice fallback generado si falta `INDEX.md`)
- `/wiki/<page>/llms.txt` exporta la página wiki individual
-...
## 📄 Licencia
- Código fuente: GNU AGPL v3.0 o posterior — ver [LICENSE](LICENSE)
- Fuentes en `font/`: licencia separada — ver [`font/README.md`](font/README.md)
-...
"Cierto"
**ClawSec** · Seguridad Prompt, SentinelOne
🦞 Fortaleciendo flujos agentic, skill por skill.
■/div titulada
+434
View File
@@ -0,0 +1,434 @@
<!-- AUTO-GENERATED TRANSLATION SCAFFOLD (fr)
Source: README.md
Review status: draft
-->
# Français Translation Scaffold
This file is currently a draft scaffold. Use README.md as the canonical source.
<h1 align="center">
<img src="/img/prompt-icon.svg" alt="prompt-icon" largeur="40">
ClawSec: Suite de compétences en sécurité pour les agents d'IA
<img src="/img/prompt-icon.svg" alt="prompt-icon" largeur="40">
</h1>
<div align="center">
## Sécurisez vos agents OpenClaw, NanoClaw et Hermes avec une suite complète de compétences en sécurité
<h4>Présenté par <a href="https://prompt.security">Prompt Security</a>, la plateforme pour la sécurité de l'IA</h4>
</div>
<div align="center">
- Oui. [Prompt Security Logo](./img/Black+Color.png)
<img src="/public/img/mascot.png" alt="clawsec mascotte" width="200" />
</div>
<div align="center">
C'est-à-dire **Live at: [https://clawsec.prompt.security](https://clawsec.prompt.security) [https://prompt.security/clawsec](https://prompt.security/clawsec)**
[![CI](https://github.com/prompt-security/clawsec/actions/workflows/ci.yml/badge.svg)](https://github.com/prompt-security/clawsec/actions/workflows/ci.yml)
[![Deploy Pages](https://github.com/prompt-security/clawsec/actions/workflows/deploy-pages.yml/badge.svg)](https://github.com/prompt-security/clawsec/actions/workflows/deploy-pages.yml)
[![Poll NVD CVEs](https://github.com/prompt-security/clawsec/actions/workflows/poll-nvd-cves.yml/badge.svg)](https://github.com/prompt-security/clawsec/actions/workflows/poll-nvd-cves.yml)
</div>
---
Traductions
- Español: [README.es.md](README.es.md)
- 한국어: [README.ko.md](README.ko.md)
Qu'est-ce que ClawSec ?
ClawSec est une suite complète de compétences en sécurité pour les plateformes d'agents d'IA**. Il fournit une surveillance de sécurité unifiée, la vérification de l'intégrité et la protection de l'architecture cognitive de votre agent contre l'injection rapide, la dérive et les instructions malveillantes.
Plateformes prises en charge
- **OpenClaw** (MoltBot, Clawdbot et clones) - Suite complète avec installateur de compétences, protection de l'intégrité des fichiers et audits de sécurité
- **NanoClaw** - Containerized Whats Sécurité des applications bot avec des outils MCP pour la surveillance consultative, la vérification des signatures et l'intégrité des fichiers
- **Hermès** - Compétences en sécurité Hermès-native pour la vérification de l'alimentation, la vérification de l'information-conseil, la génération d'attestations déterministes, la vérification fermée et la détection de la dérive de base
- **Picoclaw** - Contrôles de la posture de sécurité de la passerelle légère AI avec sensibilisation, détection de la dérive de config, vérification de l'artéfact de relâchement, et un ensemble d'auto-essais facultatifs
### Matrix des compétences
- Oui. Nom de la compétence Plate-forme prise en charge de la sécurité de la vérification de l'alimentation de config driving de l'agent testing de l'auto stylo de la chaîne d'approvisionnement de vérification d'installation de l'installation de l'agent
-- -- -- -- -- -- -- -- -- -- -- -- --
Claw-release
Clawsec-clawhub-checker
Oui Non Oui Oui
Oui Oui Oui Oui
Oui Non Oui Oui
Oui Oui Oui Oui
Clawtributor OpenClaw OpenClaw OpenClaw OpenClaw OpenClaw
Hermes-attestation-guardian.
Openclaw-audit-watchdog
Picoclaw-security-guardian.
Picoclaw-auto-test de stylos
Un gardien de l'âme OpenClaw OpenClaw OpenClaw OpenClaw
Capacités de base
- ** Installateur Suite** - Installation unique de toutes les compétences de sécurité avec vérification de l'intégrité
- Oui. Protection de l'intégrité des fichiers** - Détection et récupération automatique des fichiers d'agents critiques (SOUL.md, IDENTITY.md, etc.)
- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
- Oui. Audits de sécurité** - Auto-vérifier les scripts pour détecter les marqueurs d'injection et les vulnérabilités
- Oui. Vérification des contrôles** - SHA256 somme de contrôle pour tous les artefacts de compétence
- **Vérifications santé** - Mises à jour automatisées et vérification de l'intégrité pour toutes les compétences installées
---
Démos de produits
Les aperçus animés ci-dessous sont des GIF (pas d'audio). Cliquez sur n'importe quel aperçu pour ouvrir le MP4 complet avec audio.
### Installer la démo (`clawsec-suite`)
[![Install demo animated preview](public/video/install-demo-preview.gif)](public/video/install-demo.mp4)
Lien direct: [install-demo.mp4](public/video/install-demo.mp4)
### Démo de détection de la dérive (`soul-guardian`)
[![Drift detection animated preview](public/video/soul-guardian-demo-preview.gif)](public/video/soul-guardian-demo.mp4)
Lien direct: [soul-guardian-demo.mp4](public/video/soul-guardian-demo.mp4)
---
Début rapide
Pour les agents de l'IA
```bash
# Install the ClawSec security suite
npx clawhub@latest install clawsec-suite
```
Après l'installation, la suite peut :
1. Découvrez les protections installables à partir du catalogue de compétences publié
2. Vérifier l'intégrité de la libération à l'aide de comptes de vérification signés
3. Mettre en place une surveillance consultative et des flux de protection par crochet
4. Ajouter des contrôles programmés facultatifs
Manuel/source-première option:
> Lire https://github.com/prompt-security/clawsec/releases/latest/download/SKILL.md et suivre les instructions d'installation.
Pour les humains
Copiez cette instruction à votre agent d'IA :
> Installez ClawSec avec `npx clawhub@latest install clawsec-suite`, puis remplissez les étapes de configuration à partir des instructions générées.
### Notes Shell et OS
Les scripts ClawSec sont divisés entre :
- Plateforme transversale Outillage Noeud/Python (`npm run build`, crochet/réglage `.mjs`, `utils/*.py`)
- Workflows shell POSIX (`*.sh`, la plupart des snipets d'installation manuelle)
Pour Linux/macOS (`bash`/`zsh`):
- Utiliser des vars d'habitation sans ou double citations: `export INSTALL_ROOT="$HOME/.openclaw/skills"`
- Ne pas **** vars extensibles à simple cote (par exemple, éviter `'$HOME/.openclaw/skills'`)
Pour Windows (PowerShell):
- Préférez la construction explicite du chemin :
- `$env:INSTALL_ROOT = Join-Path $HOME ".openclaw\\skills"`
- `node "$env:INSTALL_ROOT\\clawsec-suite\\scripts\\setup_advisory_hook.mjs"`
- POSIX `.sh` nécessitent WSL ou Git Bash.
Dépannage : si vous voyez des répertoires tels que `~/.openclaw/workspace/$HOME/...`, une variable d'accueil a été transmise littéralement. Re-exécuter en utilisant un chemin absolu ou une expression de la maison non citée.
---
Documentation sur la plateforme et la suite
Des documents détaillés sur la plateforme et la suite sont disponibles dans les modules wiki :
- NanoClaw: [wiki/modules/nanoclaw-integration.md](wiki/modules/nanoclaw-integration.md)
- Hermès: [wiki/modules/hermes-attestation-guardian.md](wiki/modules/hermes-attestation-guardian.md)
- Picoclaw: [wiki/modules/picoclaw-security-guardian.md](wiki/modules/picoclaw-security-guardian.md)
- Picoclaw auto-test : [wiki/modules/picoclaw-self-pen-testing.md](wiki/modules/picoclaw-self-pen-testing.md)
- ClawSec Suite (OpenClaw): [wiki/modules/clawsec-suite.md](wiki/modules/clawsec-suite.md)
- pipelines CI/CD: [wiki/modules/automation-release.md](wiki/modules/automation-release.md)
Liens d'installation rapide :
- Installation de NanoClaw : [skills/clawsec-nanoclaw/INSTALL.md](skills/clawsec-nanoclaw/INSTALL.md)
- Pack de compétences Hermes: `skills/hermes-attestation-guardian/`
- Paquet de protection Picoclaw: `skills/picoclaw-security-guardian/`
- Paquet Picoclaw auto-test: `skills/picoclaw-self-pen-testing/`
- Paquet Suite: `skills/clawsec-suite/`
---
No # # # Avis de sécurité
ClawSec tient à jour en permanence un avis de sécurité, alimenté automatiquement par la base de données nationale sur la vulnérabilité (NVD) du NIST.
URL du flux
```bash
# Fetch latest advisories
curl -s https://clawsec.prompt.security/advisories/feed.json | jq '.advisories[] | select(.severity == "critical" or .severity == "high")'
```
Critère canonique: `https://clawsec.prompt.security/advisories/feed.json`
Miroir de compatibilité (légère): `https://clawsec.prompt.security/releases/latest/download/feed.json`
Mots-clés surveillés
Les sondages sur les aliments du bétail ont porté sur :
- ** Plateforme OpenClaw** : `OpenClaw`, `clawdbot`, `Moltbot`
- **NanoClaw Platform**: `NanoClaw`, `WhatsApp-bot`, `baileys`
- **Picoclaw Platform**: `Picoclaw`, `picoclaw`, passerelles AI légères, exposition aux passerelles MCP
- Modèles d'injection rapides
- Vulnérabilités de sécurité des agents
Contexte d'exploitation
ClawSec enrichit les avis CVE avec **contexte d'exploitation** pour aider les agents à évaluer le risque réel au-delà des scores CVSS bruts. Les avis nouvellement analysés peuvent comprendre :
- **Exploiter des preuves**: si des exploits publics existent dans la nature
- ** État des armes** : Si les exploits sont intégrés dans des cadres d'attaque communs
- **Exigences d'adaptation**: Prérequis pour une exploitation réussie (accès au réseau, authentification, interaction utilisateur)
- **Évaluation des risques**: Niveau de risque contextuel combinant gravité technique et exploitabilité
Cette fonctionnalité aide les agents à prioriser les vulnérabilités qui posent des menaces immédiates par rapport aux risques théoriques, permettant ainsi des décisions de sécurité plus intelligentes.
Schéma consultatif
** NVD CVE Conseil :**
```json
{
"id": "CVE-2026-XXXXX",
"severity": "critical|high|medium|low",
"type": "vulnerable_skill",
"platforms": ["openclaw", "nanoclaw"],
"title": "Short description",
"description": "Full CVE description from NVD",
"published": "2026-02-01T00:00:00Z",
"cvss_score": 8.8,
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-XXXXX",
"exploitability_score": "high|medium|low|unknown",
"exploitability_rationale": "Why this CVE is or is not likely exploitable in agent deployments",
"references": ["..."],
"action": "Recommended remediation"
}
```
**Conseil communautaire:**
```json
{
"id": "CLAW-2026-0042",
"severity": "high",
"type": "prompt_injection|vulnerable_skill|tampering_attempt",
"platforms": ["nanoclaw"],
"title": "Short description",
"description": "Detailed description from issue",
"published": "2026-02-01T00:00:00Z",
"affected": ["skill-name@1.0.0"],
"source": "Community Report",
"github_issue_url": "https://github.com/.../issues/42",
"action": "Recommended remediation"
}
```
**Valeurs de la plateforme:**
- `"openclaw"` - OpenClaw/Clawdbot/Molt Bot seulement
- `"nanoclaw"` - NanoClaw seulement
- `"hermes"` - Hermès seulement
- `"picoclaw"` - Picoclaw seulement
- `["openclaw", "nanoclaw", "hermes", "picoclaw"]` - Toutes les plateformes centrales
- (vide/manque) - Toutes les plates-formes (compatible avec l'arrière)
---
Lignes de pipelines CI/CD
Les détails du pipeline CI/CD ont été déplacés vers la page du module wiki :
- [wiki/modules/automation-release.md](wiki/modules/automation-release.md)
Opérations connexes docs:
- [wiki/security-signing-runbook.md](wiki/security-signing-runbook.md)
- [wiki/migration-signed-feed.md](wiki/migration-signed-feed.md)
---
Outils hors ligne
ClawSec comprend Utilitaires Python pour le développement et la validation des compétences locales.
### Validateur de compétences
Valide un dossier de compétences en fonction du schéma requis :
```bash
python utils/validate_skill.py skills/clawsec-feed
```
Contrôles :
- `skill.json` existe et est valide JSON
- Champs obligatoires présents (nom, version, description, auteur, licence)
- Les fichiers SBOM existent et sont lisibles
- Les métadonnées OpenClaw sont bien structurées
Générateur de contrôles de compétences
Génére `checksums.json` avec SHA256 haches pour une compétence:
```bash
python utils/package_skill.py skills/clawsec-feed ./dist
```
Produits :
- `checksums.json` - SHA256 haches pour vérification
---
Développement local
Préalables
- Node.js 20+
- Python 3.10+ (pour les outils hors ligne)
- npm
Configuration
```bash
# Install dependencies
npm install
# Start development server
npm run dev
```
- Oui. Populer des données locales
```bash
# Populate skills catalog from local skills/ directory
./scripts/populate-local-skills.sh
# Populate advisory feed with real NVD CVE data
./scripts/populate-local-feed.sh --days 120
# Generate wiki llms exports from wiki/ (for local preview)
./scripts/populate-local-wiki.sh
# Direct generator entrypoint (used by predev/prebuild)
npm run gen:wiki-llms
```
Remarques:
- `npm run dev` et `npm run build` régénèrent automatiquement les exportations de wikis `llms.txt` (Hooks `predev`/`prebuild`).
- `public/wiki/` est généré en sortie (locale + CI) et est intentionnellement gitagnolé.
Construire
```bash
npm run build
```
---
Structure du projet
```
├── advisories/
│ ├── feed.json # Main advisory feed
│ ├── feed.json.sig # Detached signature for feed.json
│ └── feed-signing-public.pem # Public key for feed verification
├── components/ # React components
├── pages/ # Route/page components
├── wiki/ # Source-of-truth docs (synced to GitHub Wiki)
├── scripts/
│ ├── generate-wiki-llms.mjs # wiki/*.md -> public/wiki/**/llms.txt
│ ├── populate-local-feed.sh # Local CVE feed populator
│ ├── populate-local-skills.sh # Local skills catalog populator
│ ├── populate-local-wiki.sh # Local wiki llms export populator
│ ├── prepare-to-push.sh # Local CI-style quality gate
│ ├── validate-release-links.sh # Release link checks
│ └── release-skill.sh # Manual skill release helper
├── skills/
│ ├── claw-release/ # 🚀 Release automation workflow skill
│ ├── clawsec-suite/ # 📦 Suite installer (skill-of-skills)
│ ├── clawsec-feed/ # 📡 Advisory feed skill
│ ├── clawsec-scanner/ # 🔍 Vulnerability scanner (deps + SAST + OpenClaw DAST)
│ ├── clawsec-nanoclaw/ # 📱 NanoClaw platform security suite
│ ├── clawsec-clawhub-checker/ # 🧪 ClawHub reputation checks
│ ├── clawtributor/ # 🤝 Community reporting skill
│ ├── hermes-attestation-guardian/ # 🛡️ Hermes attestation + drift verification
│ ├── openclaw-audit-watchdog/ # 🔭 Automated audit skill
│ ├── picoclaw-security-guardian/ # 🦐 Picoclaw posture/advisory/drift/supply-chain checks
│ ├── picoclaw-self-pen-testing/ # 🧪 Picoclaw self-pen-testing checks (separate package)
│ └── soul-guardian/ # 👻 File integrity skill
├── utils/
│ ├── package_skill.py # Skill packager utility
│ └── validate_skill.py # Skill validator utility
├── .github/workflows/
│ ├── ci.yml # Cross-platform lint/type/build + tests
│ ├── pages-verify.yml # PR-only pages build/signing verification
│ ├── poll-nvd-cves.yml # CVE polling pipeline
│ ├── community-advisory.yml # Approved issue -> advisory PR
│ ├── skill-release.yml # Skill release/signing pipeline
│ ├── deploy-pages.yml # GitHub Pages deployment
│ ├── wiki-sync.yml # Sync repo wiki/ to GitHub Wiki
│ ├── codeql.yml # CodeQL security analysis
│ └── scorecard.yml # OpenSSF Scorecard checks
└── public/ # Static assets + generated wiki exports
```
---
Contribution
Nous saluons les contributions! Voir [CONTRIBUTING.md](CONTRIBUTING.md) pour les lignes directrices.
### Soumettre des avis de sécurité
Trouvé un vecteur d'injection rapide, une compétence malveillante ou une vulnérabilité à la sécurité? Signalez-le via GitHub Questions :
1. Ouvrir un nouveau numéro en utilisant le modèle **Rapport d'incident de sécurité**
2. Remplissez les champs requis (série, type, description, compétences affectées)
3. Un responsable examinera et ajoutera l'étiquette `advisory-approved`
4. L'avis est automatiquement publié sur le flux sous la forme de `CLAW-{YEAR}-{ISSUE#}`
Voir [CONTRIBUTING.md](CONTRIBUTING.md#submitting-security-advisories) pour des lignes directrices détaillées.
Ajouter de nouvelles compétences
1. Créer un dossier de compétences sous `skills/`
2. Ajouter `skill.json` avec les métadonnées requises et SBOM
3. Ajouter `SKILL.md` avec des instructions lisibles par agent
4. Valider avec `python utils/validate_skill.py skills/your-skill`
5. Soumettre un rapport de situation pour examen
Source de documentation de la vérité
Pour tout le contenu wiki, éditer des fichiers sous `wiki/` dans ce dépôt. Le Wiki GitHub (`<repo>.wiki.git`) est synchronisé depuis `wiki/` par `.github/workflows/wiki-sync.yml` lorsque `wiki/**` change sur `main`.
Les exportations de LLM sont générées par `wiki/` vers `public/wiki/`:
- `/wiki/llms.txt` est l'exportation prête à LLM pour `wiki/INDEX.md` (ou un indice de repli généré si `INDEX.md` est manquant).
- `/wiki/<page>/llms.txt` est l'export LLM-ready pour cette seule page wiki.
---
Licence
- Code source : GNU AGPL v3.0 ou ultérieur - Voir [LICENSE](LICENSE) pour plus de détails.
- Polices dans `font/`: Licence séparée - Voir [`font/README.md`](font/README.md).
---
<div align="center">
**ClawSec** · Sécurité rapide, SentinelOne
C'est-à-dire Renforcer les workflows d'agents, une compétence à la fois.
</div>
+434
View File
@@ -0,0 +1,434 @@
<!-- AUTO-GENERATED TRANSLATION SCAFFOLD (ja)
Source: README.md
Review status: draft
-->
# 日本語 Translation Scaffold
This file is currently a draft scaffold. Use README.md as the canonical source.
<h1 align="center">
<img src="./img/prompt-icon.svg"alt="prompt-icon" 幅="40">
ClawSec:AIエージェントのセキュリティスキルスイート
<img src="./img/prompt-icon.svg"alt="prompt-icon" 幅="40">
</h1>
<div align="center">
## 完全なセキュリティスキルスイートでOpenClaw、NanoClaw、およびヘルメスエージェントをセキュアに
<h4>AIセキュリティプラットフォーム</h4>
</div>
<div align="center">
お問い合わせ [Prompt Security Logo](./img/Black+Color.png)の特長
<img src="./public/img/mascot.png"alt="clawsec mascot" 幅="200" />
</div>
<div align="center">
ブーツ **ライブ時: [https://clawsec.prompt.security](https://clawsec.prompt.security) [https://prompt.security/clawsec](https://prompt.security/clawsec)**
[![CI](https://github.com/prompt-security/clawsec/actions/workflows/ci.yml/badge.svg)(https://github.com/prompt-security/clawsec/actions/workflows/ci.yml)
[![Deploy Pages](https://github.com/prompt-security/clawsec/actions/workflows/deploy-pages.yml/badge.svg)(https://github.com/prompt-security/clawsec/actions/workflows/deploy-pages.yml)
[![Poll NVD CVEs](https://github.com/prompt-security/clawsec/actions/workflows/poll-nvd-cves.yml/badge.svg)(https://github.com/prompt-security/clawsec/actions/workflows/poll-nvd-cves.yml)
</div>
お問い合わせ
## 翻訳
- Español: [README.es.md](README.es.md)
- 한국어: [README.ko.md](README.ko.md)
## 🦞 ClawSecとは?
ClawSec は、AI エージェント プラットフォームの**完全なセキュリティ スキル スイートです。 エージェントの認知アーキテクチャを迅速注入、ドリフト、悪意のある指示に対して、統一されたセキュリティ監視、完全性検証、脅威インテリジェンス保護を提供します。
## 対応プラットフォーム
- **OpenClaw**(MoltBot、Clawdbot、およびクローン) - スキルインストーラ、ファイルの完全性保護、およびセキュリティ監査とフルスイート
- **ナノクロー** - コンテナ化されたもの 諮問監視、署名検証、ファイルの整合性のためのMCPツールを使用したAppボットセキュリティ
-**Hermes** - 署名された諮問的フィード検証、アドバイザリーアウェアガード検証、決定的な検証生成、フェイルクローズド検証、ベースラインドリフト検出のためのヘルメスネイティブセキュリティスキル
-**Picoclaw** - 軽量AIゲートウェイセキュリティの姿勢は、アドバイザリーの意識、構成の漂流検出、リリースアーティファクト検証、およびオプションの別々のセルフペンテストパッケージでチェックします
##スキル機能マトリックス
お問い合わせ スキル名 | 対応プラットフォーム| 安全検証| 構成漂流 | エージェントセルフペンテスト| サプライチェーンインストール検証 |
お問い合わせ
| プレスリリース | 営業部 | 営業部 | 営業部 | 営業部 | 営業部 | 営業部 | 営業部 | 営業部 | 営業部 | 営業部 | 営業部 | 営業部 | 営業部 | 営業部 | 営業部 | 営業部 | 営業部 | 営業部 | 営業部 | 営業部 | 営業部 | 営業部
| クローム・クローブ・チェッカー | OpenClaw + clawsec スイート・インテグレーション | ノー | ノー | ノー | ノー | ノー | ノー |
| クロームフィード | OpenClaw | 有り | なし | なし | なし | 有り |
| クローム・ナンクロー | ナノクロー | 可 | 可 | 有 | 有 | 有 | 有 | 有 | 有 | 有 |
| クローム・スキャナ | オープンクロー | 有り | なし | 有り | 可 | 可 | 可 | 可 | 可 |
| クロームスイート | オープンクローラ | 有り | 有り | なし | 有り | なし | 有り | なし | なし | なし | なし | なし | なし | なし | なし | なし | なし | なし |
| プライバシーポリシー | 免責事項 | 免責事項 | 免責事項 | 免責事項 | 免責事項 | 免責事項 | 免責事項 |
| ヘルメス・アテスタンス・ガーディアン | ヘルメス | はい(アドバイザリー・フィード・検証) | はい | なし | 限定(アドバイザリー・プレッションのみ・アーティファクト・シグネチャ・プロテナンス・インストール・検証なし) |
| Openclaw-audit-watchdog | OpenClaw | ノー | ノー | ノー | ノー | ノー |
| picoclaw-security-guardian | ピッコロー | 有り | なし | 有り | 有り |
| picoclaw-self-pen-testing | ピッコロー | ノー | ノー | ノー | ノー |
| 魂を守る人 | 営業部 | 営業部 | 営業部 | 営業部 | 営業部 | 営業部 | 営業部 | 営業部 |
## コア機能
- ** レース スイート インストーラ** - 完全性検証ですべてのセキュリティ スキルのワンコマンド インストール
お問い合わせ ファイル整合性保護** - 重要なエージェントファイル(SOUL.md、IDENTITY.mdなど)のドリフト検出と自動復元
- **セキュリティアドバイザリー** - 自動NVD CVEポーリングとコミュニティの脅威インテリジェンス
- ** 絶縁 セキュリティ監査** - プロンプトインジェクションマーカーと脆弱性を検出するためにスクリプトをセルフチェック
お問い合わせ チェックサム検証** - すべてのスキルアーティファクトのSHA256チェックサム
- **健康チェック** - インストールされたすべてのスキルの自動更新と完全性検証
お問い合わせ
## ✔製品デモ
下記のアニメーションプレビューはGIF(音声なし)です。 任意のプレビューをクリックして、オーディオでフルMP4を開きます。
## デモをインストール (`clawsec-suite`)
[![Install demo animated preview](public/video/install-demo-preview.gif)(パブリック/ビデオ/インストールデモ)
直接リンク: [install-demo.mp4](public/video/install-demo.mp4)
## ドリフト検出デモ(`soul-guardian`)
[![Drift detection animated preview](public/video/soul-guardian-demo-preview.gif)(公共/ビデオ/ソウル-保護者-demo.mp4)
直接リンク: [soul-guardian-demo.mp4](public/video/soul-guardian-demo.mp4)
お問い合わせ
## すぐにスタート
##AIエージェントの###
```bash
# Install the ClawSec security suite
npx clawhub@latest install clawsec-suite
```
インストール後、スイートは次のことができます。
1。 公開されたスキルカタログからインストール可能な保護を発見
2。 署名されたチェックサムを使用してリリースの完全性を確認します
3。 アドバイザリーモニタリングとホックベースの保護フローの設定
4。 オプションのスケジュールチェックを追加
手動/ソース優先オプション:
ツイート 採用情報 https://github.com/prompt-security/clawsec/releases/latest/download/SKILL.md とインストール手順に従います。
人間のための#####
この指示をAIエージェントにコピーします。
ツイート `npx clawhub@latest install clawsec-suite`でClawSecをインストールし、生成された指示からセットアップ手順を完了します。
##シェルとOSノート
ClawSec スクリプトは以下の間に分割されます。
- クロスプラットフォーム Node/Python ツーリング (`npm run build`、hook/setup `.mjs``utils/*.py`)
- POSIXシェルワークフロー(`*.sh`、ほとんどの手動インストールスニペット)
Linux/macOS (`bash`/`zsh`) の場合:
- 引用されていないか二重引用された家変数を使用して下さい:`export INSTALL_ROOT="$HOME/.openclaw/skills"`
- 単一の引用符の拡張可能なvars (例えば、`'$HOME/.openclaw/skills'`を避けるため) を**しない**
Windows用(PowerShell):
- プレファーの明示的な道の建物:
- `$env:INSTALL_ROOT = Join-Path $HOME ".openclaw\\skills"`
- `node "$env:INSTALL_ROOT\\clawsec-suite\\scripts\\setup_advisory_hook.mjs"`
- POSIX `.sh`スクリプトはWSLまたはGit Bashが必要です。
トラブルシューティング:`~/.openclaw/workspace/$HOME/...`などのディレクトリが表示された場合、ホーム変数は文字通り渡されました。 絶対パスまたは非引用のホーム式を使用して再実行します。
お問い合わせ
## 🧭プラットフォーム&スイートドキュメント
詳細なプラットフォームとスイート docs は wiki モジュールで動作します。
・ナノクロー:[wiki/modules/nanoclaw-integration.md](wiki/modules/nanoclaw-integration.md)
- ヘルメス:[wiki/modules/hermes-attestation-guardian.md](wiki/modules/hermes-attestation-guardian.md)
- ピコクロー:[wiki/modules/picoclaw-security-guardian.md](wiki/modules/picoclaw-security-guardian.md)
- ピコクローセルフペンテスト: [wiki/modules/picoclaw-self-pen-testing.md](wiki/modules/picoclaw-self-pen-testing.md)の特長
- ClawSec Suite (OpenClaw): [wiki/modules/clawsec-suite.md](wiki/modules/clawsec-suite.md)
- CI/CDのパイプライン: [wiki/modules/automation-release.md](wiki/modules/automation-release.md)の特長
クイックインストールリンク:
- NanoClawは取付けます: [skills/clawsec-nanoclaw/INSTALL.md](skills/clawsec-nanoclaw/INSTALL.md)の特長
- エルメススキルパッケージ:`skills/hermes-attestation-guardian/`
- Picoclawの保護者のパッケージ:`skills/picoclaw-security-guardian/`
- ピコクローセルフペンテストパッケージ:`skills/picoclaw-self-pen-testing/`
- スイートパッケージ:`skills/clawsec-suite/`
お問い合わせ
## 安全保障アドバイザリーフィード
ClawSecは、NISTのNational Vulnerability Database(NVD)から自動ポップアップし、継続的に更新されたセキュリティアドバイザリーフィードを維持します。
## フィード URL
```bash
# Fetch latest advisories
curl -s https://clawsec.prompt.security/advisories/feed.json | jq '.advisories[] | select(.severity == "critical" or .severity == "high")'
```
キヤノンのエンドポイント:`https://clawsec.prompt.security/advisories/feed.json`
互換性ミラー(レガシー):`https://clawsec.prompt.security/releases/latest/download/feed.json`
### 監視されたキーワード
フィードの投票 CVE に関連する:
- **OpenClawプラットフォーム**: `OpenClaw``clawdbot``Moltbot`
- **ナノクロープラットフォーム**:`NanoClaw``WhatsApp-bot``baileys`
- **Picoclaw Platform**:`Picoclaw``picoclaw`、軽量AIゲートウェイ、MCPゲートウェイ露出
- プロンプト射出パターン
- エージェントのセキュリティ脆弱性
###exploitability コンテキスト
ClawSec は、CVE のアドバイザリーを **exploitability context** で強化し、CVSS スコアを超えて、エージェントが現実的なリスクを評価するのを支援します。 新規に分析されたアドバイザリーには以下が含まれます。
-**Exploit Evidence**: 公共の悪用が野生に存在するかどうか
- **武器の状態**: 悪用が一般的な攻撃フレームワークに統合されている場合
- **攻撃要件**:成功した搾取(ネットワークアクセス、認証、ユーザーインタラクション)に必要な前提条件
-**リスクアセスメント**:技術的重大性を悪用性と組み合わせるコンテキストリスクレベル
この機能は、エージェントが直面する脅威を理論的なリスクに優先し、よりスマートなセキュリティ決定を可能にします。
##アドバイザリー・スキーマ
**NVD CVE ** アドバイザリー:**
```json
{
"id": "CVE-2026-XXXXX",
"severity": "critical|high|medium|low",
"type": "vulnerable_skill",
"platforms": ["openclaw", "nanoclaw"],
"title": "Short description",
"description": "Full CVE description from NVD",
"published": "2026-02-01T00:00:00Z",
"cvss_score": 8.8,
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-XXXXX",
"exploitability_score": "high|medium|low|unknown",
"exploitability_rationale": "Why this CVE is or is not likely exploitable in agent deployments",
"references": ["..."],
"action": "Recommended remediation"
}
```
**コミュニティアドバイザリー:**
```json
{
"id": "CLAW-2026-0042",
"severity": "high",
"type": "prompt_injection|vulnerable_skill|tampering_attempt",
"platforms": ["nanoclaw"],
"title": "Short description",
"description": "Detailed description from issue",
"published": "2026-02-01T00:00:00Z",
"affected": ["skill-name@1.0.0"],
"source": "Community Report",
"github_issue_url": "https://github.com/.../issues/42",
"action": "Recommended remediation"
}
```
**プラットフォーム値:**
- `"openclaw"` - OpenClaw/Clawdbot/Molt ボットのみ
- `"nanoclaw"` - ナノクローのみ
- `"hermes"` - ヘルメスのみ
- `"picoclaw"` - ピコクローのみ
- `["openclaw", "nanoclaw", "hermes", "picoclaw"]` - すべてのコアプラットフォーム
- (empty/missing) - すべてのプラットフォーム(後方互換)
お問い合わせ
## は、CI/CD パイプライン
CI/CD パイプラインの詳細は wiki モジュールページに移動しました。
- [wiki/modules/automation-release.md](wiki/modules/automation-release.md)
関連操作 docs:
- [wiki/security-signing-runbook.md](wiki/security-signing-runbook.md)
- [wiki/migration-signed-feed.md](wiki/migration-signed-feed.md)
お問い合わせ
## オフラインツール
ClawSecには ローカルスキル開発と検証のためのPythonユーティリティ。
##スキルバリデータ
必要なスキーマに対してスキルフォルダーを検証します。
```bash
python utils/validate_skill.py skills/clawsec-feed
```
チェック:
- `skill.json`が存在し、有効なJSON
- 必須フィールド(名前、バージョン、説明、著者、ライセンス)
- SBOMファイルが存在し、読みやすく
- OpenClawメタデータを適切に構造化
##スキルチェックサムジェネレーター
`checksums.json` を SHA256 のハッシュで生成します。
```bash
python utils/package_skill.py skills/clawsec-feed ./dist
```
出力:
- `checksums.json` - SHA256ハッシュの検証
お問い合わせ
## ローカル開発
### 前提条件
- Node.js 20 +
- Python 3.10+(オフラインツール用)
- 午後
### セットアップ
```bash
# Install dependencies
npm install
# Start development server
npm run dev
```
################################################################################################################################################################################################################################################################ ローカルデータを出力
```bash
# Populate skills catalog from local skills/ directory
./scripts/populate-local-skills.sh
# Populate advisory feed with real NVD CVE data
./scripts/populate-local-feed.sh --days 120
# Generate wiki llms exports from wiki/ (for local preview)
./scripts/populate-local-wiki.sh
# Direct generator entrypoint (used by predev/prebuild)
npm run gen:wiki-llms
```
注意:
- `npm run dev``npm run build` は自動的に wiki `llms.txt` のエクスポート (`predev`/`prebuild` のホック) を再生成します。
- `public/wiki/`は出力(ローカル+CI)を生成し、意図的に無視されます。
## ビルド
```bash
npm run build
```
お問い合わせ
## 📁 プロジェクト構造
```
├── advisories/
│ ├── feed.json # Main advisory feed
│ ├── feed.json.sig # Detached signature for feed.json
│ └── feed-signing-public.pem # Public key for feed verification
├── components/ # React components
├── pages/ # Route/page components
├── wiki/ # Source-of-truth docs (synced to GitHub Wiki)
├── scripts/
│ ├── generate-wiki-llms.mjs # wiki/*.md -> public/wiki/**/llms.txt
│ ├── populate-local-feed.sh # Local CVE feed populator
│ ├── populate-local-skills.sh # Local skills catalog populator
│ ├── populate-local-wiki.sh # Local wiki llms export populator
│ ├── prepare-to-push.sh # Local CI-style quality gate
│ ├── validate-release-links.sh # Release link checks
│ └── release-skill.sh # Manual skill release helper
├── skills/
│ ├── claw-release/ # 🚀 Release automation workflow skill
│ ├── clawsec-suite/ # 📦 Suite installer (skill-of-skills)
│ ├── clawsec-feed/ # 📡 Advisory feed skill
│ ├── clawsec-scanner/ # 🔍 Vulnerability scanner (deps + SAST + OpenClaw DAST)
│ ├── clawsec-nanoclaw/ # 📱 NanoClaw platform security suite
│ ├── clawsec-clawhub-checker/ # 🧪 ClawHub reputation checks
│ ├── clawtributor/ # 🤝 Community reporting skill
│ ├── hermes-attestation-guardian/ # 🛡️ Hermes attestation + drift verification
│ ├── openclaw-audit-watchdog/ # 🔭 Automated audit skill
│ ├── picoclaw-security-guardian/ # 🦐 Picoclaw posture/advisory/drift/supply-chain checks
│ ├── picoclaw-self-pen-testing/ # 🧪 Picoclaw self-pen-testing checks (separate package)
│ └── soul-guardian/ # 👻 File integrity skill
├── utils/
│ ├── package_skill.py # Skill packager utility
│ └── validate_skill.py # Skill validator utility
├── .github/workflows/
│ ├── ci.yml # Cross-platform lint/type/build + tests
│ ├── pages-verify.yml # PR-only pages build/signing verification
│ ├── poll-nvd-cves.yml # CVE polling pipeline
│ ├── community-advisory.yml # Approved issue -> advisory PR
│ ├── skill-release.yml # Skill release/signing pipeline
│ ├── deploy-pages.yml # GitHub Pages deployment
│ ├── wiki-sync.yml # Sync repo wiki/ to GitHub Wiki
│ ├── codeql.yml # CodeQL security analysis
│ └── scorecard.yml # OpenSSF Scorecard checks
└── public/ # Static assets + generated wiki exports
```
お問い合わせ
## 社会貢献
寄付を歓迎します! ガイドラインの[CONTRIBUTING.md](CONTRIBUTING.md)をご覧ください。
### 提出セキュリティアドバイザリー
迅速な注射ベクトル、悪意のあるスキル、またはセキュリティ脆弱性を発見しましたか? GitHub の問題で報告する:
1。 **セキュリティインシデントレポート**テンプレートを使用して新しい問題を開きます
2。 必須項目を記入(重度、種類、説明、影響を受けたスキル)
3。 メンテナーが`advisory-approved`ラベルを見直し、追加します
4。 アドバイザリーが`CLAW-{YEAR}-{ISSUE#}`としてフィードに自動的に公開されます
詳細は[CONTRIBUTING.md](CONTRIBUTING.md#submitting-security-advisories)をご覧ください。
##新規スキルの追加
1。 `skills/`でスキルフォルダを作成する
2。 必要なメタデータとSBOMで`skill.json`を追加
3。 エージェント読み取り可能な指示で`SKILL.md`を追加
4. `python utils/validate_skill.py skills/your-skill`と検証
5。 レビューのPRを提出する
## ドキュメント 真実のソース
すべてのwikiコンテンツについては、このリポジトリの`wiki/`でファイルを編集します。 GitHub Wiki (`<repo>.wiki.git`) は、`main``.github/workflows/wiki-sync.yml` から `wiki/**``main` で変更されたときに、`.github/workflows/wiki-sync.yml` から同期されます。
LLM エクスポートは `wiki/` から `public/wiki/` に生成されます。
- `/wiki/llms.txt``wiki/INDEX.md` の LLM-ready エクスポート (または `INDEX.md` が見つからない場合は生成されたフォールバックインデックス) です。
- `/wiki/<page>/llms.txt`は、その単一のwikiページのためのLM-readyエクスポートです。
お問い合わせ
## ライセンス
- ソースコード:GNU AGPL v3.0以降 - 詳細は[LICENSE](LICENSE)を参照してください。
- `font/`のフォント: ライセンス別 - [`font/README.md`](font/README.md) をご覧ください。
お問い合わせ
<div align="center">
**ClawSec**・Prompt Security、SentinelOne **
エージェントのワークフローを強化し、一度に1つのスキル。
</div>
+59
View File
@@ -0,0 +1,59 @@
# ClawSec: AI 에이전트를 위한 보안 스킬 스위트
> 한국어 번역 (초기 버전)
## 🌍 번역
- English (source of truth): [README.md](README.md)
- Español: [README.es.md](README.es.md)
- 한국어: `README.ko.md`
## ✅ 번역 상태 (Korean Phase 1)
이 한국어 문서는 핵심 온보딩/운영 흐름을 우선 제공합니다.
고급 스키마, 전체 CI/CD 세부사항, 최신 기준 문서는 영어 README를 참고하세요.
## 🦞 ClawSec란?
ClawSec은 **OpenClaw, NanoClaw, Hermes, Picoclaw 같은 AI 에이전트 플랫폼용 종합 보안 스킬 스위트**입니다.
프롬프트 인젝션, 드리프트, 악성 지시로부터 에이전트 동작을 보호하기 위해
통합 보안 모니터링, 무결성 검증, 위협 인텔리전스를 제공합니다.
## 🚀 빠른 시작
### AI 에이전트용
```bash
npx clawhub@latest install clawsec-suite
```
설치 후 ClawSec 스위트는 다음을 수행할 수 있습니다:
1. 공개 스킬 카탈로그에서 설치 가능한 보호 기능 탐색
2. 서명된 체크섬을 통한 릴리스 무결성 검증
3. advisory 모니터링 및 훅 기반 보호 흐름 설정
4. 선택적 스케줄 점검 추가
### 사람 운영자용
에이전트에게 다음 지시를 전달하세요:
· `npx clawhub@latest install clawsec-suite`로 ClawSec을 설치 한 다음 생성 된 지침에서 설정 단계를 완료하십시오.
## 🧭 위키 문서
플랫폼/스위트 상세 문서는 wiki 모듈을 참고하세요:
- [wiki/modules/clawsec-suite.md](wiki/modules/clawsec-suite.md)
- [wiki/modules/nanoclaw-integration.md](wiki/modules/nanoclaw-integration.md)
- [wiki/modules/hermes-attestation-guardian.md](wiki/modules/hermes-attestation-guardian.md)
- [wiki/modules/picoclaw-security-guardian.md](wiki/modules/picoclaw-security-guardian.md)
## 📡 보안 advisory 피드
정식 엔드포인트:
- `https://clawsec.prompt.security/advisories/feed.json`
## 🛠️ 로컬 개발
```bash
npm install
./scripts/populate-local-skills.sh
./scripts/populate-local-feed.sh --days 120
npm run gen:wiki-llms
npm run dev
```
## 📄 라이선스
- Source code: GNU AGPL v3.0 or later — [LICENSE](LICENSE)
- Font assets: [`font/README.md`](font/README.md)
+93 -168
View File
@@ -6,7 +6,7 @@
<div align="center">
## Secure Your OpenClaw and NanoClaw Agents with a Complete Security Skill Suite
## Secure Your OpenClaw, NanoClaw, and Hermes Agents with a Complete Security Skill Suite
<h4>Brought to you by <a href="https://prompt.security">Prompt Security</a>, the Platform for AI Security</h4>
@@ -31,6 +31,12 @@
---
## 🌍 Translations
[Deutsch](README.de.md) | [Español](README.es.md) | [Français](README.fr.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | **English**
Wiki indexes: [DE](wiki/de/INDEX.md) · [ES](wiki/es/INDEX.md) · [FR](wiki/fr/INDEX.md) · [JA](wiki/ja/INDEX.md) · [KO](wiki/ko/INDEX.md) · [EN](wiki/INDEX.md)
## 🦞 What is ClawSec?
ClawSec is a **complete security skill suite for AI agent platforms**. It provides unified security monitoring, integrity verification, and threat intelligence-protecting your agent's cognitive architecture against prompt injection, drift, and malicious instructions.
@@ -39,6 +45,31 @@ ClawSec is a **complete security skill suite for AI agent platforms**. It provid
- **OpenClaw** (MoltBot, Clawdbot, and clones) - Full suite with skill installer, file integrity protection, and security audits
- **NanoClaw** - Containerized WhatsApp bot security with MCP tools for advisory monitoring, signature verification, and file integrity
- **Hermes** - Hermes-native security skills for signed advisory feed verification, advisory-aware guarded verification, deterministic attestation generation, fail-closed verification, and baseline drift detection
- **Picoclaw** - Lightweight AI gateway security posture checks with advisory awareness, config drift detection, release-artifact verification, and an optional separate self-pen-testing package
### Skill Feature Matrix
| Skill name | supported platform| security feed verification| config drift | agent self pen testing| supply-chain install verification | runtime traffic monitoring |
|---|---|---|---|---|---|---|
| claw-release | OpenClaw | No | No | No | Yes | No |
| clawsec-clawhub-checker | OpenClaw + clawsec-suite integration | No | No | No | Yes | No |
| clawsec-feed | OpenClaw | Yes | No | No | Yes | No |
| clawsec-nanoclaw | NanoClaw | Yes | Yes | Yes | Yes | No |
| clawsec-scanner | OpenClaw | Yes | No | Yes | Yes | No |
| clawsec-suite | OpenClaw | Yes | Yes | No | Yes | No |
| clawtributor | OpenClaw | Yes | No | No | No | No |
| hermes-attestation-guardian | Hermes | Yes (signed advisory feed verification) | Yes | No | Limited (advisory preflight gating only; no artifact signature/provenance install verification) | No |
| hermes-traffic-guardian | Hermes | No | Planned posture export only | No | No | Spec baseline |
| nanoclaw-traffic-guardian | NanoClaw | No | No | No | No | Spec baseline |
| openclaw-audit-watchdog | OpenClaw | No | No | Yes | No | No |
| openclaw-traffic-guardian | OpenClaw | No | No | No | No | Spec baseline |
| picoclaw-security-guardian | Picoclaw | Yes | Yes | No | Yes | No |
| picoclaw-self-pen-testing | Picoclaw | No | No | Yes | No | No |
| picoclaw-traffic-guardian | Picoclaw | No | Planned profile export only | No | No | Spec baseline |
| soul-guardian | OpenClaw | No | Yes | No | No | No |
`Spec baseline` means the skill folder, metadata, frontmatter, and implementation contract exist, but runtime proxy code is intentionally left for platform-specific builders.
### Core Capabilities
@@ -47,6 +78,7 @@ ClawSec is a **complete security skill suite for AI agent platforms**. It provid
- **📡 Live Security Advisories** - Automated NVD CVE polling and community threat intelligence
- **🔍 Security Audits** - Self-check scripts to detect prompt injection markers and vulnerabilities
- **🔐 Checksum Verification** - SHA256 checksums for all skill artifacts
- **Runtime Traffic Monitoring Baselines** - Platform-specific specs for opt-in proxy inspection, exfiltration detection, and inbound injection detection
- **Health Checks** - Automated updates and integrity verification for all installed skills
---
@@ -114,72 +146,22 @@ Troubleshooting: if you see directories such as `~/.openclaw/workspace/$HOME/...
---
## 📱 NanoClaw Platform Support
## 🧭 Platform & Suite Documentation
ClawSec now supports **NanoClaw**, a containerized WhatsApp bot powered by Claude agents.
Detailed platform and suite docs live in the wiki modules:
- NanoClaw: [wiki/modules/nanoclaw-integration.md](wiki/modules/nanoclaw-integration.md)
- Hermes: [wiki/modules/hermes-attestation-guardian.md](wiki/modules/hermes-attestation-guardian.md)
- Picoclaw: [wiki/modules/picoclaw-security-guardian.md](wiki/modules/picoclaw-security-guardian.md)
- Picoclaw self-pen-testing: [wiki/modules/picoclaw-self-pen-testing.md](wiki/modules/picoclaw-self-pen-testing.md)
- ClawSec Suite (OpenClaw): [wiki/modules/clawsec-suite.md](wiki/modules/clawsec-suite.md)
- CI/CD pipelines: [wiki/modules/automation-release.md](wiki/modules/automation-release.md)
### clawsec-nanoclaw Skill
**Location**: `skills/clawsec-nanoclaw/`
A complete security suite adapted for NanoClaw's containerized architecture:
- **9 MCP Tools** for agents to check vulnerabilities
- Advisory checking and browsing
- Pre-installation safety checks
- Skill package signature verification (Ed25519)
- File integrity monitoring
- **Automatic Advisory Feed** - Fetches and caches advisories every 6 hours
- **Platform Filtering** - Shows only NanoClaw-relevant advisories
- **IPC-Based** - Container-safe host communication
- **Full Documentation** - Installation guide, usage examples, troubleshooting
### Advisory Feed for NanoClaw
The feed now monitors NanoClaw-specific keywords:
- `NanoClaw` - Direct product name
- `WhatsApp-bot` - Core functionality
- `baileys` - WhatsApp client library dependency
Advisories can specify `platforms: ["nanoclaw"]` for platform-specific issues.
### Quick Start for NanoClaw
See [`skills/clawsec-nanoclaw/INSTALL.md`](skills/clawsec-nanoclaw/INSTALL.md) for detailed setup instructions.
**Quick integration:**
1. Copy skill to NanoClaw deployment
2. Integrate MCP tools in container
3. Add IPC handlers and cache service on host
4. Restart NanoClaw
---
## 📦 ClawSec Suite (OpenClaw)
The **clawsec-suite** is a skill-of-skills manager that installs, verifies, and maintains security skills from the ClawSec catalog.
`clawsec-suite` is optional orchestration; skills can still be installed directly as standalone packages.
### ClawSec Skills
| Skill | Description | Installation | Compatibility |
|-------|-------------|--------------|---------------|
| 📡 **clawsec-feed** | Security advisory feed monitoring with live CVE updates | ✅ Included by default | All agents |
| 🔭 **openclaw-audit-watchdog** | Automated daily audits with DM delivery and optional email reporting | ⚙️ Optional (install separately) | OpenClaw/MoltBot/Clawdbot |
| 👻 **soul-guardian** | Drift detection and file integrity guard with auto-restore | ⚙️ Optional | All agents |
| 🤝 **clawtributor** | Community incident reporting | ❌ Optional (Explicit request) | All agents |
> ⚠️ **clawtributor** is not installed by default as it may share anonymized incident data. Install only on explicit user request.
> ⚠️ **openclaw-audit-watchdog** is tailored for the OpenClaw/MoltBot/Clawdbot agent family. Other agents receive the universal skill set.
### Suite Features
- **Integrity Verification** - Every skill package includes `checksums.json` with SHA256 hashes
- **Updates** - Automatic checks for new skill versions
- **Self-Healing** - Failed integrity checks trigger automatic re-download from trusted releases
- **Advisory Cross-Reference** - Installed skills are checked against the security advisory feed
Quick install links:
- NanoClaw install: [skills/clawsec-nanoclaw/INSTALL.md](skills/clawsec-nanoclaw/INSTALL.md)
- Hermes skill package: `skills/hermes-attestation-guardian/`
- Picoclaw guardian package: `skills/picoclaw-security-guardian/`
- Picoclaw self-pen-testing package: `skills/picoclaw-self-pen-testing/`
- Suite package: `skills/clawsec-suite/`
---
@@ -202,6 +184,7 @@ Compatibility mirror (legacy): `https://clawsec.prompt.security/releases/latest/
The feed polls CVEs related to:
- **OpenClaw Platform**: `OpenClaw`, `clawdbot`, `Moltbot`
- **NanoClaw Platform**: `NanoClaw`, `WhatsApp-bot`, `baileys`
- **Picoclaw Platform**: `Picoclaw`, `picoclaw`, lightweight AI gateways, MCP gateway exposure
- Prompt injection patterns
- Agent security vulnerabilities
@@ -257,89 +240,21 @@ This feature helps agents prioritize vulnerabilities that pose immediate threats
**Platform values:**
- `"openclaw"` - OpenClaw/Clawdbot/MoltBot only
- `"nanoclaw"` - NanoClaw only
- `["openclaw", "nanoclaw"]` - Both platforms
- `"hermes"` - Hermes only
- `"picoclaw"` - Picoclaw only
- `["openclaw", "nanoclaw", "hermes", "picoclaw"]` - All core platforms
- (empty/missing) - All platforms (backward compatible)
---
## 🔄 CI/CD Pipelines
ClawSec uses automated pipelines for continuous security updates and skill distribution.
CI/CD pipeline details were moved to the wiki module page:
- [wiki/modules/automation-release.md](wiki/modules/automation-release.md)
### Automated Workflows
| Workflow | Trigger | Description |
|----------|---------|-------------|
| **ci.yml** | PRs to `main`, pushes to `main` | Lint/type/build + skill test suites |
| **pages-verify.yml** | PRs to `main` | Verifies Pages build and signing outputs without publishing |
| **poll-nvd-cves.yml** | Daily cron (06:00 UTC) | Polls NVD for new CVEs, updates feed |
| **community-advisory.yml** | Issue labeled `advisory-approved` | Processes community reports into advisories |
| **skill-release.yml** | Skill tags + metadata PR changes | Validates version parity in PRs and publishes signed skill releases on tags |
| **deploy-pages.yml** | `workflow_run` after successful trusted CI/release or manual dispatch | Builds and deploys the web interface to GitHub Pages |
| **wiki-sync.yml** | Pushes to `main` touching `wiki/**` | Syncs `wiki/` to the GitHub Wiki mirror |
### Skill Release Pipeline
When a skill is tagged (e.g., `soul-guardian-v1.0.0`), the pipeline:
1. **Validates** - Checks `skill.json` version matches tag
2. **Enforces key consistency** - Verifies pinned release key references are consistent across repo PEMs and `skills/clawsec-suite/SKILL.md`
3. **Generates Checksums** - Creates `checksums.json` with SHA256 hashes for all SBOM files
4. **Signs + verifies** - Signs `checksums.json` and validates the generated `signing-public.pem` fingerprint against canonical repo key material
5. **Releases** - Publishes to GitHub Releases with all artifacts
6. **Supersedes Old Releases** - Deletes older versions within the same major line (tags remain)
7. **Triggers Pages Update** - Refreshes the skills catalog on the website
### Signing Key Consistency Guardrails
To prevent supply-chain drift, CI now fails fast when signing key references diverge.
Guardrail script:
- `scripts/ci/verify_signing_key_consistency.sh`
What it checks:
- `skills/clawsec-suite/SKILL.md` inline public key fingerprint matches `RELEASE_PUBKEY_SHA256`
- Canonical PEM files all match the same fingerprint:
- `clawsec-signing-public.pem`
- `advisories/feed-signing-public.pem`
- `skills/clawsec-suite/advisories/feed-signing-public.pem`
- Generated public key in workflows matches canonical key:
- `release-assets/signing-public.pem` (release workflow)
- `public/signing-public.pem` (pages workflow)
Where enforced:
- `.github/workflows/skill-release.yml`
- `.github/workflows/deploy-pages.yml`
### Release Versioning & Superseding
ClawSec follows [semantic versioning](https://semver.org/). When a new version is released:
| Scenario | Behavior |
|----------|----------|
| New patch/minor (e.g., 1.0.1, 1.1.0) | Previous releases with same major version are **deleted** |
| New major (e.g., 2.0.0) | Previous major version (1.x.x) remains for backwards compatibility |
**Why do old releases disappear?**
When you release `skill-v0.0.2`, the previous `skill-v0.0.1` release is automatically deleted to keep the releases page clean. Only the latest version within each major version is retained.
- **Git tags are preserved** - You can always recreate a release from an existing tag if needed
- **Major versions coexist** - Both `skill-v1.x.x` and `skill-v2.x.x` latest releases remain available for backwards compatibility
### Release Artifacts
Each skill release includes:
- `checksums.json` - SHA256 hashes for integrity verification
- `skill.json` - Skill metadata
- `SKILL.md` - Main skill documentation
- Additional files from SBOM (scripts, configs, etc.)
### Signing Operations Documentation
For feed/release signing rollout and operations guidance:
- [`wiki/security-signing-runbook.md`](wiki/security-signing-runbook.md) - key generation, GitHub secrets, rotation/revocation, incident response
- [`wiki/migration-signed-feed.md`](wiki/migration-signed-feed.md) - phased migration from unsigned feed, enforcement gates, rollback plan
Related operations docs:
- [wiki/security-signing-runbook.md](wiki/security-signing-runbook.md)
- [wiki/migration-signed-feed.md](wiki/migration-signed-feed.md)
---
@@ -424,37 +339,47 @@ npm run build
```
├── advisories/
── feed.json # Main advisory feed (auto-updated from NVD)
├── components/ # React components
├── pages/ # Page components
├── wiki/ # Source-of-truth docs (synced to GitHub Wiki)
── feed.json # Main advisory feed
│ ├── feed.json.sig # Detached signature for feed.json
│ └── feed-signing-public.pem # Public key for feed verification
├── components/ # React components
├── pages/ # Route/page components
├── wiki/ # Source-of-truth docs (synced to GitHub Wiki)
├── scripts/
│ ├── generate-wiki-llms.mjs # wiki/*.md -> public/wiki/**/llms.txt
│ ├── populate-local-feed.sh # Local CVE feed populator
│ ├── populate-local-skills.sh # Local skills catalog populator
│ ├── populate-local-wiki.sh # Local wiki llms export populator
── release-skill.sh # Manual skill release helper
│ ├── generate-wiki-llms.mjs # wiki/*.md -> public/wiki/**/llms.txt
│ ├── populate-local-feed.sh # Local CVE feed populator
│ ├── populate-local-skills.sh # Local skills catalog populator
│ ├── populate-local-wiki.sh # Local wiki llms export populator
── prepare-to-push.sh # Local CI-style quality gate
│ ├── validate-release-links.sh # Release link checks
│ └── release-skill.sh # Manual skill release helper
├── skills/
│ ├── clawsec-suite/ # 📦 Suite installer (skill-of-skills - start here and have your agent do the rest)
│ ├── clawsec-feed/ # 📡 Advisory feed skill
│ ├── clawsec-scanner/ # 🔍 Vulnerability scanner (deps + SAST + OpenClaw DAST)
│ ├── clawsec-nanoclaw/ # 📱 NanoClaw platform security suite
│ ├── clawsec-clawhub-checker/ # 🧪 ClawHub reputation checks
│ ├── clawtributor/ # 🤝 Community reporting skill
│ ├── openclaw-audit-watchdog/ # 🔭 Automated audit skill
── soul-guardian/ # 👻 File integrity skill
│ ├── claw-release/ # 🚀 Release automation workflow skill
│ ├── clawsec-suite/ # 📦 Suite installer (skill-of-skills)
│ ├── clawsec-feed/ # 📡 Advisory feed skill
│ ├── clawsec-scanner/ # 🔍 Vulnerability scanner (deps + SAST + OpenClaw DAST)
│ ├── clawsec-nanoclaw/ # 📱 NanoClaw platform security suite
│ ├── clawsec-clawhub-checker/ # 🧪 ClawHub reputation checks
│ ├── clawtributor/ # 🤝 Community reporting skill
── hermes-attestation-guardian/ # 🛡️ Hermes attestation + drift verification
│ ├── openclaw-audit-watchdog/ # 🔭 Automated audit skill
│ ├── picoclaw-security-guardian/ # 🦐 Picoclaw posture/advisory/drift/supply-chain checks
│ ├── picoclaw-self-pen-testing/ # 🧪 Picoclaw self-pen-testing checks (separate package)
│ └── soul-guardian/ # 👻 File integrity skill
├── utils/
│ ├── package_skill.py # Skill packager utility
│ └── validate_skill.py # Skill validator utility
│ ├── package_skill.py # Skill packager utility
│ └── validate_skill.py # Skill validator utility
├── .github/workflows/
│ ├── ci.yml # Cross-platform lint/type/build + tests
│ ├── pages-verify.yml # PR-only pages build verification
│ ├── poll-nvd-cves.yml # CVE polling pipeline
│ ├── community-advisory.yml # Approved issue -> advisory PR
│ ├── skill-release.yml # Skill release pipeline
│ ├── wiki-sync.yml # Sync repo wiki/ to GitHub Wiki
── deploy-pages.yml # Pages deployment
└── public/ # Static assets + generated publish artifacts
│ ├── ci.yml # Cross-platform lint/type/build + tests
│ ├── pages-verify.yml # PR-only pages build/signing verification
│ ├── poll-nvd-cves.yml # CVE polling pipeline
│ ├── community-advisory.yml # Approved issue -> advisory PR
│ ├── skill-release.yml # Skill release/signing pipeline
│ ├── deploy-pages.yml # GitHub Pages deployment
── wiki-sync.yml # Sync repo wiki/ to GitHub Wiki
│ ├── codeql.yml # CodeQL security analysis
│ └── scorecard.yml # OpenSSF Scorecard checks
└── public/ # Static assets + generated wiki exports
```
---
+10242 -92
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
Cz4Hx/UdUdx+ibsq4njd5NOx/0b3n5bXEKWFVY2eVrgaOGyBTojzO4KO3uiBb90cHlpRvync4tKZDhjOCh2kAg==
ie4iZN7vM+097ZsWnz+YExEB6fMbB2fWsrlmtF7+mJh5uhy7qzYmIgJ0wLWatl38mgNRutHT2PwIc7F5RzeaDA==
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
P0KWbrwl6ZiBEv2w8uJ7LrbKMHPeNJX0EQBz1QFVgPd9S8xRaE6GsXuOVnkOxkn7g3PpQ6Zh7ywrICd1npiMDA==
+13
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { Link } from 'react-router-dom';
import { ExternalLink, Github } from 'lucide-react';
import { Advisory } from '../types';
import { AdvisoryPlatformBadge } from './AdvisoryPlatformBadge';
interface AdvisoryCardProps {
advisory: Advisory;
@@ -65,6 +66,18 @@ export const AdvisoryCard: React.FC<AdvisoryCardProps> = ({ advisory, formatDate
{advisory.id}
</h3>
<p className="text-sm text-gray-400 line-clamp-3 mb-3">{advisory.title}</p>
{advisory.platforms && advisory.platforms.length > 0 && (
<div className="mb-3 flex flex-wrap gap-1.5">
{advisory.platforms.map((platform) => (
<AdvisoryPlatformBadge
key={`${advisory.id}-${platform}`}
platform={platform}
className="text-[11px] px-2 py-0.5 rounded"
/>
))}
</div>
)}
{/* External link - stop propagation to allow clicking without navigating to detail */}
{isCommunityReport && advisory.github_issue_url ? (
+19
View File
@@ -0,0 +1,19 @@
import React from 'react';
import { getPlatformDescriptor } from '../utils/advisoryPlatforms';
interface AdvisoryPlatformBadgeProps {
platform: string;
className?: string;
}
export const AdvisoryPlatformBadge: React.FC<AdvisoryPlatformBadgeProps> = ({
platform,
className,
}) => {
const { label, classes } = getPlatformDescriptor(platform);
const badgeClasses = ['uppercase tracking-wide', classes, className]
.filter(Boolean)
.join(' ');
return <span className={badgeClasses}>{label}</span>;
};
+21 -1
View File
@@ -2,16 +2,19 @@ import React from 'react';
import { Link } from 'react-router-dom';
import { ArrowRight } from 'lucide-react';
import type { SkillMetadata } from '../types';
import { getPlatformDescriptor } from '../utils/advisoryPlatforms';
interface SkillCardProps {
skill: SkillMetadata;
}
export const SkillCard: React.FC<SkillCardProps> = ({ skill }) => {
const platforms = Array.isArray(skill.platforms) ? skill.platforms.slice(0, 4) : [];
return (
<Link
to={`/skills/${skill.id}`}
className="group block bg-clawd-800 border border-clawd-700 rounded-xl p-5 hover:border-clawd-accent/30 hover:bg-clawd-800/80 transition-all duration-200"
className="group block bg-clawd-800 border border-clawd-700 rounded-lg p-5 hover:border-clawd-accent/30 hover:bg-clawd-800/80 transition-all duration-200"
>
<div className="flex items-center gap-3 mb-3">
<span className="text-2xl">{skill.emoji || '📦'}</span>
@@ -27,6 +30,23 @@ export const SkillCard: React.FC<SkillCardProps> = ({ skill }) => {
{skill.description}
</p>
{platforms.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-4" aria-label="Recommended platforms">
{platforms.map((platform) => {
const descriptor = getPlatformDescriptor(platform);
return (
<span
key={platform}
className={`text-[11px] leading-none px-2 py-1 rounded-md ${descriptor.classes}`}
>
{descriptor.label}
</span>
);
})}
</div>
)}
<div className="flex items-center justify-between">
{/* Category badge - hidden for now, uncomment when we have multiple categories
<span className="text-xs text-gray-500 bg-clawd-700 px-2 py-1 rounded">
+33 -25
View File
@@ -11,21 +11,21 @@
"dependencies": {
"lucide-react": "^0.575.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-dom": "^19.2.5",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.1",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@eslint/js": "~9.39.4",
"@types/node": "^25.4.0",
"@types/node": "^25.8.0",
"@typescript-eslint/eslint-plugin": "^8.55.0",
"@typescript-eslint/parser": "^8.58.1",
"@vitejs/plugin-react": "^5.1.4",
"eslint": "^9.39.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"fast-check": "^4.5.3",
"fast-check": "^4.7.0",
"typescript": "~5.9.3",
"vite": "^7.3.2"
}
@@ -1362,13 +1362,13 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="
},
"node_modules/@types/node": {
"version": "25.4.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.4.0.tgz",
"integrity": "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==",
"version": "25.8.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
"integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@types/react": {
@@ -2859,9 +2859,9 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
},
"node_modules/fast-check": {
"version": "4.5.3",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.5.3.tgz",
"integrity": "sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA==",
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz",
"integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==",
"dev": true,
"funding": [
{
@@ -2873,8 +2873,9 @@
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT",
"dependencies": {
"pure-rand": "^7.0.0"
"pure-rand": "^8.0.0"
},
"engines": {
"node": ">=12.17.0"
@@ -4931,8 +4932,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.6",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"version": "8.5.12",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
"dev": true,
"funding": [
{
@@ -4948,6 +4950,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -4994,9 +4997,9 @@
}
},
"node_modules/pure-rand": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz",
"integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==",
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz",
"integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==",
"dev": true,
"funding": [
{
@@ -5007,23 +5010,28 @@
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
]
],
"license": "MIT"
},
"node_modules/react": {
"version": "19.2.4",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.4",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.4"
"react": "^19.2.5"
}
},
"node_modules/react-is": {
@@ -5806,9 +5814,9 @@
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true,
"license": "MIT"
},
+8 -3
View File
@@ -10,27 +10,32 @@
"predev": "npm run gen:wiki-llms",
"dev": "vite",
"prebuild": "npm run gen:wiki-llms",
"i18n:qa": "python scripts/i18n/qa_check.py",
"i18n:bootstrap:ko": "python scripts/i18n/bootstrap_language_from_en.py --lang ko",
"i18n:bootstrap:fr": "python scripts/i18n/bootstrap_language_from_en.py --lang fr",
"i18n:bootstrap:de": "python scripts/i18n/bootstrap_language_from_en.py --lang de",
"i18n:bootstrap:ja": "python scripts/i18n/bootstrap_language_from_en.py --lang ja",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.575.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-dom": "^19.2.5",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.1",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@eslint/js": "~9.39.4",
"@types/node": "^25.4.0",
"@types/node": "^25.8.0",
"@typescript-eslint/eslint-plugin": "^8.55.0",
"@typescript-eslint/parser": "^8.58.1",
"@vitejs/plugin-react": "^5.1.4",
"eslint": "^9.39.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"fast-check": "^4.5.3",
"fast-check": "^4.7.0",
"typescript": "~5.9.3",
"vite": "^7.3.2"
},
+15
View File
@@ -1,8 +1,10 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { ArrowLeft, ExternalLink, Shield, AlertTriangle, Github, User, Bot } from 'lucide-react';
import { AdvisoryPlatformBadge } from '../components/AdvisoryPlatformBadge';
import { Footer } from '../components/Footer';
import { Advisory, AdvisoryFeed } from '../types';
import { getPlatformDescriptor } from '../utils/advisoryPlatforms';
import {
ADVISORY_FEED_URL,
LEGACY_ADVISORY_FEED_URL,
@@ -154,6 +156,13 @@ export const AdvisoryDetail: React.FC = () => {
<span className="text-sm text-gray-500">
Published {formatDate(advisory.published)}
</span>
{advisory.platforms?.map((platform) => (
<AdvisoryPlatformBadge
key={`${advisory.id}-platform-${platform}`}
platform={platform}
className="text-xs px-2 py-1 rounded"
/>
))}
</div>
<h1 className="text-3xl font-bold text-white">{advisory.id}</h1>
@@ -259,6 +268,12 @@ export const AdvisoryDetail: React.FC = () => {
<dt className="text-gray-500 mb-1">Published</dt>
<dd className="text-white">{formatDate(advisory.published)}</dd>
</div>
{advisory.platforms && advisory.platforms.length > 0 && (
<div className="flex justify-between md:block">
<dt className="text-gray-500 mb-1">Platforms</dt>
<dd className="text-white">{advisory.platforms.map((platform) => getPlatformDescriptor(platform).label).join(', ')}</dd>
</div>
)}
{/* Reporter info - subtle display for community reports */}
{advisory.reporter && (
<>
+51 -17
View File
@@ -3,7 +3,8 @@ import { Rss, RefreshCw, Loader2, AlertTriangle, ChevronLeft, ChevronRight, Down
import { Link } from 'react-router-dom';
import { Footer } from '../components/Footer';
import { AdvisoryCard } from '../components/AdvisoryCard';
import { Advisory, AdvisoryFeed } from '../types';
import { Advisory, AdvisoryFeed, AdvisoryPlatformFilter } from '../types';
import { isCorePlatformSlug, normalizePlatformSlug } from '../utils/advisoryPlatforms';
import {
ADVISORY_FEED_URL,
LEGACY_ADVISORY_FEED_URL,
@@ -12,25 +13,35 @@ import {
const ITEMS_PER_PAGE = 9;
type SeverityFilter = 'all' | Advisory['severity'];
type FilterTabOption<T extends string> = { value: T; label: string; active: string; inactive: string };
const SEVERITY_TABS = [
{ value: 'all', label: 'All', active: 'bg-clawd-accent text-white', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-clawd-accent/50' },
{ value: 'critical', label: 'Critical', active: 'bg-red-500/20 text-red-400 border-2 border-red-400', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-red-400/50' },
{ value: 'high', label: 'High', active: 'bg-orange-500/20 text-orange-400 border-2 border-orange-400', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-orange-400/50' },
{ value: 'medium', label: 'Medium', active: 'bg-yellow-500/20 text-yellow-400 border-2 border-yellow-400', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-yellow-400/50' },
{ value: 'low', label: 'Low', active: 'bg-blue-500/20 text-blue-400 border-2 border-blue-400', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-blue-400/50' },
] as const;
] as const satisfies ReadonlyArray<FilterTabOption<SeverityFilter>>;
const PLATFORM_TABS = [
{ value: 'all', label: 'All Platforms', active: 'bg-clawd-accent text-white', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-clawd-accent/50' },
{ value: 'openclaw', label: 'OpenClaw', active: 'bg-clawd-accent/20 text-clawd-accent border-2 border-clawd-accent', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-clawd-accent/50' },
{ value: 'nanoclaw', label: 'NanoClaw', active: 'bg-clawd-secondary/20 text-clawd-secondary border-2 border-clawd-secondary', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-clawd-secondary/50' },
] as const;
{ value: 'hermes', label: 'Hermes', active: 'bg-emerald-500/20 text-emerald-300 border-2 border-emerald-400', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-emerald-400/50' },
{ value: 'picoclaw', label: 'Picoclaw', active: 'bg-cyan-500/20 text-cyan-300 border-2 border-cyan-400', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-cyan-400/50' },
{ value: 'other', label: 'Other', active: 'bg-clawd-600/40 text-gray-100 border-2 border-clawd-500', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-clawd-500/50' },
] as const satisfies ReadonlyArray<FilterTabOption<AdvisoryPlatformFilter>>;
const FilterTabs: React.FC<{
tabs: ReadonlyArray<{ value: string; label: string; active: string; inactive: string }>;
selected: string;
onSelect: (value: string) => void;
}> = ({ tabs, selected, onSelect }) => (
const FilterTabs = <T extends string,>({
tabs,
selected,
onSelect,
}: {
tabs: ReadonlyArray<FilterTabOption<T>>;
selected: T;
onSelect: (value: T) => void;
}) => (
<div className="flex flex-wrap justify-center gap-3 mb-8">
{tabs.map(({ value, label, active, inactive }) => (
<button
@@ -52,8 +63,8 @@ export const FeedSetup: React.FC = () => {
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [selectedSeverity, setSelectedSeverity] = useState<string>('all');
const [selectedPlatform, setSelectedPlatform] = useState<string>('all');
const [selectedSeverity, setSelectedSeverity] = useState<SeverityFilter>('all');
const [selectedPlatform, setSelectedPlatform] = useState<AdvisoryPlatformFilter>('all');
useEffect(() => {
const fetchAdvisories = async () => {
@@ -92,10 +103,25 @@ export const FeedSetup: React.FC = () => {
}, []);
const filteredAdvisories = useMemo(
() => advisories.filter((a) =>
(selectedSeverity === 'all' || a.severity === selectedSeverity) &&
(selectedPlatform === 'all' || !a.platforms?.length || a.platforms.includes(selectedPlatform))
),
() => advisories.filter((a) => {
if (selectedSeverity !== 'all' && a.severity !== selectedSeverity) {
return false;
}
if (selectedPlatform === 'all') {
return true;
}
const advisoryPlatforms = (a.platforms ?? [])
.map(normalizePlatformSlug)
.filter(Boolean);
if (selectedPlatform === 'other') {
return advisoryPlatforms.some((platform) => !isCorePlatformSlug(platform));
}
return advisoryPlatforms.length === 0 || advisoryPlatforms.includes(selectedPlatform);
}),
[advisories, selectedSeverity, selectedPlatform],
);
@@ -132,7 +158,7 @@ export const FeedSetup: React.FC = () => {
<h1 className="text-3xl md:text-4xl text-white">Security Hardening Feed</h1>
<p className="text-gray-400 max-w-2xl mx-auto">
A continuous stream of security advisories from NVD CVE data and staff-approved community reports.
This feed is automatically updated with OpenClaw and NanoClaw-related vulnerabilities and verified security incidents.
This feed is automatically updated with OpenClaw, NanoClaw, Hermes, and Picoclaw-related vulnerabilities and verified security incidents.
</p>
{lastUpdated && (
<p className="text-xs text-gray-500">
@@ -142,8 +168,16 @@ export const FeedSetup: React.FC = () => {
</section>
<section>
<FilterTabs tabs={SEVERITY_TABS} selected={selectedSeverity} onSelect={setSelectedSeverity} />
<FilterTabs tabs={PLATFORM_TABS} selected={selectedPlatform} onSelect={setSelectedPlatform} />
<FilterTabs
tabs={SEVERITY_TABS}
selected={selectedSeverity}
onSelect={(value) => setSelectedSeverity(value as SeverityFilter)}
/>
<FilterTabs
tabs={PLATFORM_TABS}
selected={selectedPlatform}
onSelect={(value) => setSelectedPlatform(value as AdvisoryPlatformFilter)}
/>
{loading ? (
<div className="flex items-center justify-center py-12">
+58 -9
View File
@@ -5,8 +5,12 @@ import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Footer } from '../components/Footer';
import type { SkillJson, SkillChecksums } from '../types';
import { getPlatformDescriptor } from '../utils/advisoryPlatforms';
import { defaultMarkdownComponents } from '../utils/markdownComponents';
import { stripFrontmatter } from '../utils/markdownHelpers.mjs';
import { getRecommendedSkillPlatforms, resolveSkillPlatformMetadata } from '../utils/skillPlatforms';
const RELEASE_REPO_URL = 'https://github.com/prompt-security/clawsec';
const isProbablyHtmlDocument = (text: string): boolean => {
const start = text.trimStart().slice(0, 200).toLowerCase();
@@ -121,10 +125,29 @@ export const SkillDetail: React.FC = () => {
setTimeout(() => setCopied(null), 2000);
};
const installCommand = skillData
? `npx clawhub@latest install ${skillData.name}`
const releaseTag = skillData ? `${skillData.name}-v${skillData.version}` : '';
const skillInstructionsUrl = releaseTag
? `${RELEASE_REPO_URL}/releases/download/${releaseTag}/SKILL.md`
: '';
const recommendedPlatforms = useMemo(
() => (skillData ? getRecommendedSkillPlatforms(skillData) : []),
[skillData]
);
const isOpenClawSkill = recommendedPlatforms.includes('openclaw');
const installCommand = skillData
? isOpenClawSkill
? `npx clawhub@latest install ${skillData.name}`
: `curl -sLO ${skillInstructionsUrl}`
: '';
const installLabel = isOpenClawSkill ? 'Via ClawHub' : 'Via SKILL.md instructions';
const installHelp = isOpenClawSkill
? 'Recommended for OpenClaw-compatible skills.'
: 'Pull the published instruction file and follow the platform-specific setup steps.';
const releasePageUrl = useMemo(() => {
if (!skillData) return '';
@@ -134,7 +157,7 @@ export const SkillDetail: React.FC = () => {
const [owner, repo] = url.pathname.split('/').filter(Boolean);
if (owner && repo) {
const repoBase = `${url.origin}/${owner}/${repo.replace(/\\.git$/, '')}`;
return `${repoBase}/releases/tag/${skillData.name}-v${skillData.version}`;
return `${repoBase}/releases/tag/${releaseTag}`;
}
}
} catch {
@@ -142,7 +165,17 @@ export const SkillDetail: React.FC = () => {
}
return skillData.homepage;
}, [skillData]);
}, [releaseTag, skillData]);
const platformMetadata = useMemo(
() => (skillData ? resolveSkillPlatformMetadata(skillData) : null),
[skillData]
);
const triggers = useMemo(() => {
if (!platformMetadata || !Array.isArray(platformMetadata.triggers)) return [];
return platformMetadata.triggers;
}, [platformMetadata]);
if (loading) {
return (
@@ -180,14 +213,26 @@ export const SkillDetail: React.FC = () => {
{/* Header */}
<section className="flex flex-col md:flex-row md:items-start md:justify-between gap-6">
<div className="flex items-start gap-4">
<span className="text-4xl">{skillData.openclaw?.emoji || '📦'}</span>
<span className="text-4xl">{platformMetadata?.emoji || '📦'}</span>
<div>
<h1 className="text-3xl font-bold text-white mb-1">{skillData.name}</h1>
<div className="flex items-center gap-3 text-sm">
<span className="text-gray-500 font-mono">v{skillData.version}</span>
{recommendedPlatforms.slice(0, 4).map((platform) => {
const descriptor = getPlatformDescriptor(platform);
return (
<span
key={platform}
className={`text-xs px-2 py-0.5 rounded-md ${descriptor.classes}`}
>
{descriptor.label}
</span>
);
})}
{/* Category badge - hidden for now, uncomment when we have multiple categories
<span className="text-gray-500 bg-clawd-800 px-2 py-0.5 rounded">
{skillData.openclaw?.category || 'utility'}
{platformMetadata?.category || 'utility'}
</span>
*/}
</div>
@@ -218,6 +263,10 @@ export const SkillDetail: React.FC = () => {
<Download size={20} />
Quick Install
</h2>
<div>
<p className="text-sm font-medium text-white">{installLabel}</p>
<p className="text-sm text-gray-400">{installHelp}</p>
</div>
<div className="bg-clawd-800 rounded-lg p-3 sm:p-4 flex items-center justify-between gap-2 sm:gap-4">
<code className="text-gray-200 font-mono text-xs sm:text-sm overflow-x-auto break-all min-w-0 flex-1">
{installCommand}
@@ -339,16 +388,16 @@ export const SkillDetail: React.FC = () => {
</div>
<div className="flex justify-between">
<dt className="text-gray-500">Category</dt>
<dd className="text-white">{skillData.openclaw?.category}</dd>
<dd className="text-white">{platformMetadata?.category || 'utility'}</dd>
</div>
</dl>
</div>
{skillData.openclaw?.triggers && skillData.openclaw.triggers.length > 0 && (
{triggers.length > 0 && (
<div className="bg-clawd-800/50 border border-clawd-700 rounded-xl p-6 space-y-4">
<h3 className="font-bold text-white">Trigger Phrases</h3>
<div className="flex flex-wrap gap-2">
{skillData.openclaw.triggers.slice(0, 8).map((trigger) => (
{triggers.slice(0, 8).map((trigger) => (
<span
key={trigger}
className="text-xs bg-clawd-700 text-gray-300 px-2 py-1 rounded"
+84 -2
View File
@@ -139,6 +139,26 @@ const wikiAssetByPath = new Map<string, string>(
const defaultDoc = wikiDocBySlug.get('index') ?? wikiDocs[0] ?? null;
const languageLabelByCode: Record<string, string> = {
en: 'English',
es: 'Español',
ko: '한국어',
fr: 'Français',
de: 'Deutsch',
ja: '日本語',
};
const languageIndexByCode = new Map<string, WikiDoc>(
wikiDocs
.map((doc) => {
const match = doc.slug.match(/^([^/]+)\/index$/i);
if (!match) return null;
const code = match[1].toLowerCase();
return [code, doc] as const;
})
.filter((entry): entry is readonly [string, WikiDoc] => entry !== null),
);
const toGroupName = (filePath: string): string => {
if (!filePath.includes('/')) return 'Core';
if (filePath.startsWith('modules/')) return 'Modules';
@@ -160,11 +180,16 @@ export const WikiBrowser: React.FC = () => {
requested = '';
}
const requestedSlug = requested || 'INDEX';
const requestedSlugLower = requestedSlug.toLowerCase();
const languageIndexFallback = languageIndexByCode.get(requestedSlugLower);
const selectedDoc = wikiDocBySlug.get(requestedSlug.toLowerCase()) ?? defaultDoc;
const selectedDoc =
wikiDocBySlug.get(requestedSlugLower) ??
languageIndexFallback ??
defaultDoc;
const notFound =
(decodeFailed && normalizedWildcard.length > 0) ||
(requested.length > 0 && !wikiDocBySlug.has(requestedSlug.toLowerCase()));
(requested.length > 0 && !wikiDocBySlug.has(requestedSlugLower) && !languageIndexFallback);
const groupedDocs = useMemo(() => {
const map = new Map<string, WikiDoc[]>();
@@ -209,6 +234,34 @@ export const WikiBrowser: React.FC = () => {
const pageLlmsPath = toWikiLlmsPath(activeSlug);
const showWikiLlmsIndexLink = !isWikiIndexSlug(activeSlug);
const localizedPrefix = Array.from(languageIndexByCode.keys()).find((code) =>
activeSlug.startsWith(`${code}/`),
);
const currentLanguageCode = localizedPrefix ?? 'en';
const activeBaseSlug = localizedPrefix
? activeSlug.slice(localizedPrefix.length + 1)
: activeSlug;
const languageOptions = [
{ code: 'en', label: languageLabelByCode.en ?? 'English' },
...Array.from(languageIndexByCode.keys())
.sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' }))
.map((code) => ({
code,
label: languageLabelByCode[code] ?? code.toUpperCase(),
})),
].map((option) => {
const targetSlug = option.code === 'en' ? activeBaseSlug : `${option.code}/${activeBaseSlug}`;
const fallbackSlug = option.code === 'en' ? 'index' : `${option.code}/index`;
const resolvedSlug = wikiDocBySlug.has(targetSlug) ? targetSlug : fallbackSlug;
const translated = wikiDocBySlug.has(targetSlug);
return {
...option,
route: toWikiRoute(resolvedSlug),
translated,
};
});
const resolveWikiRouteFromHref = (href: string): string | null => {
if (!href || isExternalHref(href) || href.startsWith('mailto:') || href.startsWith('tel:')) {
return null;
@@ -291,6 +344,35 @@ export const WikiBrowser: React.FC = () => {
Full repository wiki rendered from markdown in <code className="text-gray-300">wiki/</code>.
This is the same source synced to GitHub Wiki.
</p>
<div className="flex flex-wrap items-center gap-2 text-sm text-gray-300">
<span className="text-gray-400">Language:</span>
{languageOptions.map((option) => {
const isActiveLanguage = option.code === currentLanguageCode;
return isActiveLanguage ? (
<span
key={option.code}
className="px-2 py-1 rounded bg-clawd-700 text-white border border-clawd-600"
>
{option.label}
</span>
) : (
<Link
key={option.code}
to={option.route}
className="px-2 py-1 rounded border border-clawd-700 hover:border-clawd-accent hover:text-white transition-colors"
title={option.translated ? `Open ${option.label} translation` : `Open ${option.label} index fallback`}
>
{option.label}
{option.translated ? (
<span className="text-gray-500"> · translated</span>
) : (
<span className="text-gray-500"> · index fallback</span>
)}
</Link>
);
})}
</div>
<div className="flex flex-wrap gap-3">
<a
href={pageLlmsPath}
@@ -0,0 +1,111 @@
from __future__ import annotations
import importlib.util
import sys
import tempfile
import unittest
from pathlib import Path
def _load_module():
module_path = Path(__file__).with_name("verify_skill_release_import_closure.py")
spec = importlib.util.spec_from_file_location("verify_skill_release_import_closure", module_path)
if spec is None or spec.loader is None:
raise RuntimeError(f"Unable to load {module_path}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
class VerifySkillReleaseImportClosureTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.module = _load_module()
def test_empty_directory_does_not_satisfy_relative_import(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
(root / "runtime-lib").mkdir()
(root / "main.mjs").write_text("import './runtime-lib';\n", encoding="utf-8")
failures = self.module.verify_import_closure(root)
self.assertEqual(len(failures), 1)
self.assertIn("main.mjs imports ./runtime-lib", failures[0])
def test_directory_import_requires_index_file(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
runtime_lib = root / "runtime-lib"
runtime_lib.mkdir()
(runtime_lib / "index.mjs").write_text("export {};\n", encoding="utf-8")
(root / "main.mjs").write_text("import './runtime-lib';\n", encoding="utf-8")
failures = self.module.verify_import_closure(root)
self.assertEqual(failures, [])
def test_ts_source_accepts_js_import_specifier(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
(root / "types.ts").write_text("export type Value = string;\n", encoding="utf-8")
(root / "main.ts").write_text("import type { Value } from './types.js';\n", encoding="utf-8")
failures = self.module.verify_import_closure(root)
self.assertEqual(failures, [])
def test_comment_import_examples_are_ignored(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
(root / "main.ts").write_text(
"/*\n"
" * Example integration:\n"
" * import { Missing } from '../external/project/file';\n"
" */\n"
"export {};\n",
encoding="utf-8",
)
failures = self.module.verify_import_closure(root)
self.assertEqual(failures, [])
def test_url_string_does_not_hide_following_relative_import(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
(root / "main.ts").write_text(
'const feedUrl = "https://example.test/feed.json"; import value from "./missing.js";\n',
encoding="utf-8",
)
failures = self.module.verify_import_closure(root)
self.assertEqual(len(failures), 1)
self.assertIn("main.ts imports ./missing.js", failures[0])
def test_remote_import_spec_survives_comment_stripping(self) -> None:
source = 'import remote from "https://example.test/module.mjs";\n'
stripped = self.module.strip_js_ts_comments(source)
specs = [match.group("spec") for match in self.module.IMPORT_RE.finditer(stripped)]
self.assertEqual(specs, ["https://example.test/module.mjs"])
def test_remote_runtime_import_is_rejected(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
(root / "main.mjs").write_text(
'import remote from "https://example.test/module.mjs";\n',
encoding="utf-8",
)
failures = self.module.verify_import_closure(root)
self.assertEqual(len(failures), 1)
self.assertIn("remote runtime import https://example.test/module.mjs", failures[0])
if __name__ == "__main__":
unittest.main()
@@ -71,3 +71,14 @@ if [[ "$CANONICAL_FPR" != "$DOC_EXPECTED_FPR" ]]; then
fi
echo "All signing key references are consistent: $CANONICAL_FPR"
while IFS= read -r skill_md; do
while IFS= read -r doc_fpr; do
if [[ "$doc_fpr" != "$CANONICAL_FPR" ]]; then
echo "ERROR: $skill_md RELEASE_PUBKEY_SHA256 ($doc_fpr) != canonical fingerprint ($CANONICAL_FPR)" >&2
exit 1
fi
done < <(awk -F'"' '/RELEASE_PUBKEY_SHA256=/{print $2}' "$skill_md")
done < <(find skills -mindepth 2 -maxdepth 2 -name SKILL.md -print | sort)
echo "All skill doc RELEASE_PUBKEY_SHA256 references match the canonical signing key."
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""Verify staged skill release JS/TS imports are self-contained.
The skill release workflow builds archives from `skill.json.sbom.files`. If a
runtime helper exists in the repo but is omitted from the SBOM, the staged
release can contain files whose relative imports point at missing files or
remote runtime imports. This script checks the staged payload, not the source
tree, so it catches exactly what would ship.
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
IMPORT_RE = re.compile(
r"(?:"
r"\bimport\s+(?:type\s+)?(?:[^'\";]+?\s+from\s+)?"
r"|\bexport\s+(?:type\s+)?[^'\";]+?\s+from\s+"
r"|\bimport\s*\(\s*"
r"|\brequire\s*\(\s*"
r")"
r"['\"](?P<spec>(?:\.{1,2}/|https?://)[^'\"]+)['\"]",
re.MULTILINE,
)
SOURCE_SUFFIXES = {".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"}
RESOLUTION_SUFFIXES = ["", ".mjs", ".js", ".cjs", ".mts", ".ts", ".cts", ".json"]
INDEX_FILENAMES = ["index.mjs", "index.js", "index.cjs", "index.mts", "index.ts", "index.cts", "index.json"]
TS_IMPORTER_SUFFIXES = {".ts", ".mts", ".cts"}
JS_TO_TS_SUFFIX = {".js": ".ts", ".mjs": ".mts", ".cjs": ".cts"}
def strip_js_ts_comments(text: str) -> str:
stripped: list[str] = []
state = "code"
i = 0
while i < len(text):
char = text[i]
next_char = text[i + 1] if i + 1 < len(text) else ""
if state == "line_comment":
if char in "\r\n":
stripped.append(char)
state = "code"
i += 1
continue
if state == "block_comment":
if char == "*" and next_char == "/":
state = "code"
i += 2
continue
if char in "\r\n":
stripped.append(char)
i += 1
continue
if state in {"single", "double", "template"}:
stripped.append(char)
if char == "\\" and i + 1 < len(text):
stripped.append(text[i + 1])
i += 2
continue
if (state == "single" and char == "'") or (state == "double" and char == '"') or (
state == "template" and char == "`"
):
state = "code"
i += 1
continue
if char == "/" and next_char == "/":
stripped.append(" ")
state = "line_comment"
i += 2
continue
if char == "/" and next_char == "*":
stripped.append(" ")
state = "block_comment"
i += 2
continue
stripped.append(char)
if char == "'":
state = "single"
elif char == '"':
state = "double"
elif char == "`":
state = "template"
i += 1
return "".join(stripped)
def is_remote_spec(spec: str) -> bool:
return spec.startswith(("http://", "https://"))
def candidate_paths(importer: Path, spec: str) -> list[Path]:
base = (importer.parent / spec).resolve()
candidates = [base]
if importer.suffix in TS_IMPORTER_SUFFIXES and base.suffix in JS_TO_TS_SUFFIX:
candidates.append(base.with_suffix(JS_TO_TS_SUFFIX[base.suffix]))
candidates.extend(base.with_suffix(suffix) for suffix in RESOLUTION_SUFFIXES if suffix and base.suffix == "")
candidates.extend(base / name for name in INDEX_FILENAMES)
return candidates
def is_within(path: Path, root: Path) -> bool:
try:
path.resolve().relative_to(root)
return True
except ValueError:
return False
def is_resolved_file(candidate: Path, root: Path) -> bool:
return candidate.is_file() and is_within(candidate, root)
def verify_import_closure(root: Path) -> list[str]:
root = root.resolve()
failures: list[str] = []
for source in sorted(p for p in root.rglob("*") if p.is_file() and p.suffix in SOURCE_SUFFIXES):
text = source.read_text(encoding="utf-8", errors="ignore")
text = strip_js_ts_comments(text)
for match in IMPORT_RE.finditer(text):
spec = match.group("spec")
rel_source = source.relative_to(root).as_posix()
if is_remote_spec(spec):
failures.append(f"{rel_source} imports remote runtime import {spec}")
continue
candidates = candidate_paths(source, spec)
if any(is_resolved_file(candidate, root) for candidate in candidates):
continue
display_target = (source.parent / spec).resolve()
try:
rel_target = display_target.relative_to(root).as_posix()
except ValueError:
rel_target = str(display_target)
failures.append(f"{rel_source} imports {spec} but {rel_target} is absent from staged release")
return failures
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("staged_skill_dir", type=Path, help="Staged skill payload directory, e.g. $INNER_DIR")
args = parser.parse_args()
root = args.staged_skill_dir
if not root.is_dir():
print(f"error: staged skill directory not found: {root}", file=sys.stderr)
return 2
failures = verify_import_closure(root)
if failures:
print("Release import-closure check failed:", file=sys.stderr)
for failure in failures:
print(f" - {failure}", file=sys.stderr)
print("Add the missing runtime file(s) to skill.json sbom.files or remove the stale import.", file=sys.stderr)
return 1
print(f"Release import-closure check OK: {root}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+60
View File
@@ -35,3 +35,63 @@ sync_feed_to_mirrors() {
esac
done
}
nvd_query_specs() {
cat <<'EOF'
keyword|OpenClaw
keyword|clawdbot
keyword|Moltbot
keyword|NanoClaw
keyword|WhatsApp-bot
keyword|baileys
keyword|hermes workflow
keyword|hermes-agent
keyword|Picoclaw
virtualMatchString|cpe:2.3:a:software-metadata.pub:hermes
virtualMatchString|cpe:2.3:a:picoclaw:picoclaw
EOF
}
nvd_summary_keywords() {
echo 'openclaw, nanoclaw, hermes, picoclaw'
}
nvd_keyword_pattern() {
echo 'OpenClaw|clawdbot|Moltbot|openclaw|NanoClaw|nanoclaw|WhatsApp-bot|baileys|HERMES workflow|hermes-agent|software publication with rich metadata|Picoclaw|picoclaw'
}
nvd_github_ref_pattern() {
echo 'github\.com/openclaw/openclaw|github\.com/qwibitai/nanoclaw|github\.com/softwarepub/hermes|github\.com/nousresearch/hermes-agent|github\.com/[^/]+/picoclaw'
}
nvd_cpe_pattern() {
echo 'cpe:2\.3:a:software-metadata\.pub:hermes(?::|$)|cpe:2\.3:[aho]:[^:]*:picoclaw(?::|$)'
}
nvd_query_slug() {
local kind="$1"
local value="$2"
printf '%s__%s' "$kind" "$value" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9._-]/_/g'
}
nvd_build_url() {
local kind="$1"
local value="$2"
local suffix="${3:-}"
local encoded
encoded=$(jq -nr --arg v "$value" '$v|@uri')
case "$kind" in
keyword)
printf 'https://services.nvd.nist.gov/rest/json/cves/2.0?keywordSearch=%s%s' "$encoded" "$suffix"
;;
virtualMatchString)
printf 'https://services.nvd.nist.gov/rest/json/cves/2.0?virtualMatchString=%s%s' "$encoded" "$suffix"
;;
*)
echo "Error: unsupported NVD query kind: $kind" >&2
return 1
;;
esac
}
+514
View File
@@ -0,0 +1,514 @@
#!/usr/bin/env node
import { existsSync } from 'node:fs';
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
export const DEFAULT_REPOSITORIES = [
'openclaw/openclaw',
'qwibitai/nanoclaw',
'softwarepub/hermes',
'nousresearch/hermes-agent',
'sipeed/picoclaw',
];
export const DEFAULT_STALE_AFTER_DAYS = 60;
export const FEED_VERSION = '0.1.0';
const PLATFORM_BY_REPOSITORY = new Map([
['openclaw/openclaw', 'openclaw'],
['qwibitai/nanoclaw', 'nanoclaw'],
['softwarepub/hermes', 'hermes'],
['nousresearch/hermes-agent', 'hermes'],
['sipeed/picoclaw', 'picoclaw'],
]);
const CWE_TYPE_BY_ID = new Map([
['CWE-22', 'path_traversal'],
['CWE-78', 'os_command_injection'],
['CWE-79', 'cross_site_scripting'],
['CWE-94', 'code_injection'],
['CWE-200', 'exposure_of_sensitive_information'],
['CWE-284', 'improper_access_control'],
['CWE-287', 'improper_authentication'],
['CWE-306', 'missing_authentication_for_critical_function'],
['CWE-352', 'cross_site_request_forgery'],
['CWE-400', 'uncontrolled_resource_consumption'],
['CWE-502', 'deserialization_of_untrusted_data'],
['CWE-862', 'missing_authorization'],
['CWE-863', 'incorrect_authorization'],
['CWE-918', 'server_side_request_forgery'],
]);
function cleanText(value) {
return String(value ?? '')
.replace(/\r/g, '')
.replace(/```[\s\S]*?```/g, ' ')
.replace(/`([^`]+)`/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/^#+\s+/gm, '')
.replace(/[*_>]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function daysBetween(startIso, endIso) {
const start = Date.parse(startIso);
const end = Date.parse(endIso);
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) {
return 0;
}
return Math.floor((end - start) / 86_400_000);
}
function toArray(value) {
return Array.isArray(value) ? value : [];
}
function uniqueStrings(values) {
return [...new Set(values.filter((value) => typeof value === 'string' && value.length > 0))];
}
export function inferPlatforms(repository) {
const known = PLATFORM_BY_REPOSITORY.get(String(repository).toLowerCase());
return known ? [known] : [];
}
function nextLinkFromHeader(linkHeader) {
if (!linkHeader) {
return null;
}
for (const part of linkHeader.split(',')) {
const match = part.trim().match(/^<([^>]+)>;\s*rel="next"$/);
if (match) {
return match[1];
}
}
return null;
}
function affectedFromVulnerabilities(advisory, platforms) {
const affected = toArray(advisory.vulnerabilities).flatMap((vulnerability) => {
const packageName = vulnerability?.package?.name;
const versionRange = vulnerability?.vulnerable_version_range;
if (!packageName) {
return [];
}
return [`${packageName}@${versionRange || '*'}`];
});
if (affected.length > 0) {
return uniqueStrings(affected);
}
return platforms.length > 0 ? platforms.map((platform) => `${platform}@*`) : [];
}
function patchedFromVulnerabilities(advisory) {
return uniqueStrings(
toArray(advisory.vulnerabilities).flatMap((vulnerability) => {
const packageName = vulnerability?.package?.name;
const patchedVersions = vulnerability?.patched_versions;
if (!packageName || !patchedVersions) {
return [];
}
return [`${packageName}@${patchedVersions}`];
}),
);
}
function githubAdvisoryUrl(advisory) {
return advisory.html_url || advisory.url || `https://github.com/advisories/${advisory.ghsa_id}`;
}
function resolveCveId(advisory, cveIdByGhsa) {
return advisory.cve_id || cveIdByGhsa.get(advisory.ghsa_id) || null;
}
export function normalizeGhsaAdvisory(
advisory,
{
now,
repository,
staleAfterDays = DEFAULT_STALE_AFTER_DAYS,
cveId = advisory.cve_id || null,
},
) {
const platforms = inferPlatforms(repository);
const published = advisory.published_at || advisory.created_at || advisory.updated_at || now;
const ageDays = daysBetween(published, now);
const stale = !cveId && ageDays >= staleAfterDays;
const status = cveId ? 'matured' : stale ? 'stale' : 'active';
const cweIds = uniqueStrings(toArray(advisory.cwe_ids));
const cvss = advisory.cvss || advisory.cvss_severities?.cvss_v3 || {};
const ghsaUrl = githubAdvisoryUrl(advisory);
const affected = affectedFromVulnerabilities(advisory, platforms);
const patched = patchedFromVulnerabilities(advisory);
const title = cleanText(advisory.summary) || advisory.ghsa_id;
const description = cleanText(advisory.description) || title;
return {
id: advisory.ghsa_id,
ghsa_id: advisory.ghsa_id,
cve_id: cveId,
status,
stale,
stale_after_days: staleAfterDays,
severity: advisory.severity || 'medium',
type: CWE_TYPE_BY_ID.get(cweIds[0]) || 'github_security_advisory',
nvd_category_id: cweIds[0] || null,
title,
description,
affected,
patched,
platforms,
action: cveId
? `Track ${cveId} in the canonical CVE advisory feed and verify affected components.`
: 'Review the GitHub Security Advisory and update affected components; no CVE is assigned yet.',
published,
updated: advisory.updated_at || published,
references: uniqueStrings([ghsaUrl, cveId ? `https://nvd.nist.gov/vuln/detail/${cveId}` : null]),
source: 'GitHub Security Advisory',
repository,
github_advisory_url: ghsaUrl,
nvd_url: cveId ? `https://nvd.nist.gov/vuln/detail/${cveId}` : null,
cvss_score: cvss.score ?? null,
cvss_vector: cvss.vector_string ?? null,
cwe_ids: cweIds,
credits: uniqueStrings(toArray(advisory.credits).map((credit) => credit?.login)),
aliases: uniqueStrings([advisory.ghsa_id, cveId]),
};
}
function ghsaToCveMapFromNvdFeed(nvdFeed) {
const map = new Map();
for (const advisory of toArray(nvdFeed?.advisories)) {
const cveId = advisory?.id;
if (typeof cveId !== 'string' || !cveId.startsWith('CVE-')) {
continue;
}
const references = toArray(advisory.references).join('\n');
for (const match of references.matchAll(/GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}/gi)) {
map.set(match[0], cveId);
}
}
return map;
}
function equivalentAdvisories(left, right) {
return JSON.stringify(left ?? []) === JSON.stringify(right ?? []);
}
function isCveId(value) {
return typeof value === 'string' && /^CVE-\d{4}-\d{4,}$/i.test(value);
}
function ghsaIdentifier(entry) {
if (typeof entry?.ghsa_id === 'string' && entry.ghsa_id.length > 0) {
return entry.ghsa_id.toLowerCase();
}
if (/^GHSA-/i.test(String(entry?.id || ''))) {
return String(entry.id).toLowerCase();
}
return null;
}
function refreshExistingEntry(entry, { now, staleAfterDays, cveIdByGhsa }) {
const cveId = entry.cve_id || cveIdByGhsa.get(entry.ghsa_id || entry.id) || null;
const ageDays = daysBetween(entry.published, now);
const stale = !cveId && ageDays >= staleAfterDays;
return {
...entry,
cve_id: cveId,
status: cveId ? 'matured' : stale ? 'stale' : 'active',
stale,
stale_after_days: staleAfterDays,
references: uniqueStrings([
...toArray(entry.references),
cveId ? `https://nvd.nist.gov/vuln/detail/${cveId}` : null,
]),
nvd_url: cveId ? `https://nvd.nist.gov/vuln/detail/${cveId}` : null,
aliases: uniqueStrings([...(entry.aliases || []), entry.ghsa_id || entry.id, cveId]),
};
}
export function buildConsolidatedAdvisoryFeed({ canonicalFeed = {}, ghsaFeed = {}, now }) {
const canonicalFeedEntries = toArray(canonicalFeed.advisories);
const canonicalCveIds = new Set(canonicalFeedEntries.map((entry) => entry?.id).filter(isCveId));
const replacementGhsaIds = new Set(toArray(ghsaFeed.advisories).map(ghsaIdentifier).filter(Boolean));
const canonicalEntries = canonicalFeedEntries.filter((entry) => {
const ghsaId = ghsaIdentifier(entry);
if (!ghsaId) {
return true;
}
if (entry?.cve_id && canonicalCveIds.has(entry.cve_id)) {
return false;
}
return !replacementGhsaIds.has(ghsaId);
});
const ghsaEntries = toArray(ghsaFeed.advisories)
.filter((entry) => !(entry?.cve_id && canonicalCveIds.has(entry.cve_id)))
.map((entry) => ({
...entry,
source_feed: 'ghsa-without-cve',
}));
const advisories = [...canonicalEntries, ...ghsaEntries].sort((a, b) => {
const published = Date.parse(b.published || '') - Date.parse(a.published || '');
if (Number.isFinite(published) && published !== 0) {
return published;
}
return String(a.id || '').localeCompare(String(b.id || ''));
});
return {
...canonicalFeed,
version: canonicalFeed.version || '1.0.0',
updated: canonicalFeed.updated || now,
description: canonicalFeed.description || 'Community-driven security advisory feed for ClawSec',
advisories,
};
}
export function buildGhsaWithoutCveFeed({
fetched,
existingFeed = {},
nvdFeed = {},
now,
staleAfterDays = DEFAULT_STALE_AFTER_DAYS,
}) {
const existingEntries = toArray(existingFeed.advisories);
const existingIds = new Set(existingEntries.map((entry) => entry.ghsa_id || entry.id));
const cveIdByGhsa = ghsaToCveMapFromNvdFeed(nvdFeed);
const entriesById = new Map();
for (const { repository, advisories } of fetched) {
for (const advisory of advisories) {
const ghsaId = advisory.ghsa_id;
if (!ghsaId) {
continue;
}
const cveId = resolveCveId(advisory, cveIdByGhsa);
if (cveId && !existingIds.has(ghsaId)) {
continue;
}
entriesById.set(
ghsaId,
normalizeGhsaAdvisory(advisory, {
now,
repository,
staleAfterDays,
cveId,
}),
);
}
}
for (const entry of existingEntries) {
const ghsaId = entry.ghsa_id || entry.id;
if (!ghsaId || entriesById.has(ghsaId)) {
continue;
}
entriesById.set(ghsaId, refreshExistingEntry(entry, { now, staleAfterDays, cveIdByGhsa }));
}
const advisories = [...entriesById.values()].sort((a, b) => {
const published = Date.parse(b.published) - Date.parse(a.published);
if (published !== 0) {
return published;
}
return a.id.localeCompare(b.id);
});
const updated = equivalentAdvisories(advisories, existingEntries)
? existingFeed.updated || now
: now;
return {
version: FEED_VERSION,
updated,
description:
'Provisional ClawSec advisory feed for public GitHub Security Advisories that do not yet have CVE identifiers.',
stale_after_days: staleAfterDays,
semantics: {
active: 'GHSA is published and has no CVE identifier yet.',
matured: 'GHSA now has a CVE identifier and should be reconciled with the canonical CVE feed.',
stale: 'GHSA is older than stale_after_days and still has no CVE identifier.',
},
sources: DEFAULT_REPOSITORIES.map((repository) => ({
repository,
platform: inferPlatforms(repository)[0] || 'unknown',
url: `https://github.com/${repository}/security/advisories`,
})),
advisories,
};
}
export async function fetchGitHubSecurityAdvisories(repository, { token } = {}) {
const advisories = [];
let url = `https://api.github.com/repos/${repository}/security-advisories?per_page=100`;
const seenUrls = new Set();
while (url) {
if (seenUrls.has(url)) {
throw new Error(`GitHub advisory pagination loop detected for ${repository}: ${url}`);
}
seenUrls.add(url);
const response = await globalThis.fetch(url, {
headers: {
Accept: 'application/vnd.github+json',
'User-Agent': 'clawsec-ghsa-without-cve-poller',
'X-GitHub-Api-Version': '2022-11-28',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
if (!response.ok) {
const message = await response.text();
throw new Error(
`GitHub advisory fetch failed for ${repository}: HTTP ${response.status} ${message.slice(0, 200)}`,
);
}
const pageItems = await response.json();
advisories.push(...pageItems);
if (!Array.isArray(pageItems)) {
break;
}
url = nextLinkFromHeader(response.headers.get('link'));
}
return advisories;
}
async function readJsonIfExists(path, fallback) {
if (!existsSync(path)) {
return fallback;
}
return JSON.parse(await readFile(path, 'utf8'));
}
async function writeJson(path, value) {
await mkdir(dirname(path), { recursive: true });
await writeFile(`${path}.tmp`, `${JSON.stringify(value, null, 2)}\n`);
await rename(`${path}.tmp`, path);
}
function parseArgs(argv) {
const options = {
output: 'advisories/ghsa-without-cve.json',
consolidatedFeed: null,
existingFeed: null,
nvdFeed: 'advisories/feed.json',
repositories: [...DEFAULT_REPOSITORIES],
staleAfterDays: DEFAULT_STALE_AFTER_DAYS,
token: process.env.GITHUB_TOKEN || process.env.GH_TOKEN || '',
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--output') {
options.output = argv[++index];
} else if (arg === '--consolidated-feed') {
options.consolidatedFeed = argv[++index];
} else if (arg === '--existing-feed') {
options.existingFeed = argv[++index];
} else if (arg === '--nvd-feed') {
options.nvdFeed = argv[++index];
} else if (arg === '--repo') {
options.repositories.push(argv[++index]);
} else if (arg === '--only-default-repos') {
options.repositories = [...DEFAULT_REPOSITORIES];
} else if (arg === '--stale-after-days') {
options.staleAfterDays = Number.parseInt(argv[++index], 10);
} else if (arg === '--help') {
options.help = true;
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
if (!Number.isInteger(options.staleAfterDays) || options.staleAfterDays < 1) {
throw new Error('--stale-after-days must be a positive integer');
}
options.repositories = uniqueStrings(options.repositories.map((repo) => repo.toLowerCase()));
options.existingFeed ||= options.output;
return options;
}
function printHelp() {
console.log(`Usage: node scripts/ghsa-without-cve-feed.mjs [options]
Options:
--output PATH Feed output path (default: advisories/ghsa-without-cve.json)
--consolidated-feed PATH Also merge active GHSA advisories into agent-facing feed PATH
--existing-feed PATH Existing provisional feed path (default: output path)
--nvd-feed PATH Canonical CVE feed path for GHSA-to-CVE reconciliation
--repo OWNER/NAME Additional repository to poll
--only-default-repos Reset repository list to built-in ClawSec sources
--stale-after-days N Mark GHSA-only advisories stale after N days (default: 60)
`);
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
const fetched = [];
for (const repository of options.repositories) {
const advisories = await fetchGitHubSecurityAdvisories(repository, { token: options.token });
console.log(`Fetched ${advisories.length} GitHub Security Advisories from ${repository}`);
fetched.push({ repository, advisories });
}
const existingFeed = await readJsonIfExists(options.existingFeed, {});
const nvdFeed = await readJsonIfExists(options.nvdFeed, { advisories: [] });
const feed = buildGhsaWithoutCveFeed({
fetched,
existingFeed,
nvdFeed,
now,
staleAfterDays: options.staleAfterDays,
});
await writeJson(options.output, feed);
console.log(`Wrote ${feed.advisories.length} provisional GHSA advisories to ${options.output}`);
if (options.consolidatedFeed) {
const canonicalFeed = await readJsonIfExists(options.consolidatedFeed, {
version: '1.0.0',
advisories: [],
});
const consolidatedFeed = buildConsolidatedAdvisoryFeed({
canonicalFeed,
ghsaFeed: feed,
now,
});
await writeJson(options.consolidatedFeed, consolidatedFeed);
console.log(
`Wrote ${consolidatedFeed.advisories.length} consolidated agent advisories to ${options.consolidatedFeed}`,
);
}
console.log(
`Status counts: ${JSON.stringify(
feed.advisories.reduce((counts, advisory) => {
counts[advisory.status] = (counts[advisory.status] || 0) + 1;
return counts;
}, {}),
)}`,
);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
main().catch((error) => {
console.error(error);
process.exit(1);
});
}
@@ -1,124 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Sandbox regression test for hermes-attestation-guardian using an isolated Docker Hermes instance.
#
# Usage:
# scripts/hermes_attestation_sandbox_regression.sh
#
# Optional env overrides:
# IMAGE=python:3.11-slim
# HERMES_AGENT_SRC=/home/davida/.hermes/hermes-agent
# SKILL_SRC=/home/davida/clawsec/skills/hermes-attestation-guardian
# WELL_KNOWN_PORT=8765
IMAGE="${IMAGE:-python:3.11-slim}"
HERMES_AGENT_SRC="${HERMES_AGENT_SRC:-$HOME/.hermes/hermes-agent}"
SKILL_SRC="${SKILL_SRC:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/skills/hermes-attestation-guardian}"
WELL_KNOWN_PORT="${WELL_KNOWN_PORT:-8765}"
if ! command -v docker >/dev/null 2>&1; then
echo "ERROR: docker is required." >&2
exit 1
fi
if [[ ! -d "$HERMES_AGENT_SRC" ]]; then
echo "ERROR: HERMES_AGENT_SRC not found: $HERMES_AGENT_SRC" >&2
exit 1
fi
if [[ ! -d "$SKILL_SRC" ]]; then
echo "ERROR: SKILL_SRC not found: $SKILL_SRC" >&2
exit 1
fi
echo "[sandbox] image=$IMAGE"
echo "[sandbox] hermes-agent-src=$HERMES_AGENT_SRC"
echo "[sandbox] skill-src=$SKILL_SRC"
docker run --rm \
-e HOME=/tmp/hermes-sandbox-home \
-e HERMES_HOME=/tmp/hermes-sandbox-home \
-v "$HERMES_AGENT_SRC":/opt/hermes-agent:ro \
-v "$SKILL_SRC":/opt/skill-src:ro \
"$IMAGE" bash -lc "
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update >/dev/null
apt-get install -y --no-install-recommends openssl ca-certificates curl nodejs npm >/dev/null
cp -a /opt/hermes-agent /tmp/hermes-agent-src
python -m pip install --no-cache-dir /tmp/hermes-agent-src >/tmp/pip-install.log 2>&1
mkdir -p \"\$HOME\"
echo \"INSIDE_HOME=\$HOME\"
echo \"INSIDE_HERMES_HOME=\$HERMES_HOME\"
mkdir -p /tmp/well/.well-known/skills/hermes-attestation-guardian
cp -a /opt/skill-src/. /tmp/well/.well-known/skills/hermes-attestation-guardian/
python3 - <<'PY'
import os,json
root='/tmp/well/.well-known/skills'
sk='hermes-attestation-guardian'
base=os.path.join(root,sk)
files=[]
for dp,_,fns in os.walk(base):
for fn in fns:
files.append(os.path.relpath(os.path.join(dp,fn),base).replace('\\\\','/'))
idx={'generated_at':'2026-04-16T00:00:00Z','skills':[{'name':sk,'version':'0.0.1','description':'sandbox feature test','path':f'.well-known/skills/{sk}','files':sorted(files)}]}
with open(os.path.join(root,'index.json'),'w') as f: json.dump(idx,f)
PY
python3 -m http.server $WELL_KNOWN_PORT --directory /tmp/well >/tmp/http.log 2>&1 &
HPID=\$!
sleep 1
INSTALL_OUT=\$(hermes skills install \"well-known:http://127.0.0.1:$WELL_KNOWN_PORT/.well-known/skills/hermes-attestation-guardian\" --yes 2>&1)
echo \"\$INSTALL_OUT\"
echo \"\$INSTALL_OUT\" | grep -q \"Verdict: SAFE\"
echo \"\$INSTALL_OUT\" | grep -q \"Decision: ALLOWED\"
SKILL_DIR=\"\$HERMES_HOME/skills/hermes-attestation-guardian\"
mkdir -p \"\$HERMES_HOME/security/attestations\"
echo \"alpha\" > /tmp/watch.txt
echo \"anchor-v1\" > /tmp/anchor.pem
cat > /tmp/policy.json <<EOF
{\"watch_files\": [\"/tmp/watch.txt\"], \"trust_anchor_files\": [\"/tmp/anchor.pem\"]}
EOF
node \"\$SKILL_DIR/scripts/generate_attestation.mjs\" --output \"\$HERMES_HOME/security/attestations/current.json\" --policy /tmp/policy.json --generated-at 2026-04-16T00:00:00.000Z --write-sha256 >/tmp/generate.log
DIGEST=\$(cut -d\" \" -f1 \"\$HERMES_HOME/security/attestations/current.json.sha256\")
node \"\$SKILL_DIR/scripts/verify_attestation.mjs\" --input \"\$HERMES_HOME/security/attestations/current.json\" --expected-sha256 \"\$DIGEST\" >/tmp/verify-ok.log
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out /tmp/sign.key >/dev/null 2>&1
openssl pkey -in /tmp/sign.key -pubout -out /tmp/sign.pub.pem >/dev/null 2>&1
openssl dgst -sha256 -sign /tmp/sign.key -out /tmp/current.sig \"\$HERMES_HOME/security/attestations/current.json\"
node \"\$SKILL_DIR/scripts/verify_attestation.mjs\" --input \"\$HERMES_HOME/security/attestations/current.json\" --signature /tmp/current.sig --public-key /tmp/sign.pub.pem >/tmp/verify-sig.log
cp \"\$HERMES_HOME/security/attestations/current.json\" \"\$HERMES_HOME/security/attestations/baseline.json\"
BASE_SHA=\$(sha256sum \"\$HERMES_HOME/security/attestations/baseline.json\" | cut -d\" \" -f1)
echo \"beta\" > /tmp/watch.txt
echo \"anchor-v2\" > /tmp/anchor.pem
node \"\$SKILL_DIR/scripts/generate_attestation.mjs\" --output \"\$HERMES_HOME/security/attestations/current.json\" --policy /tmp/policy.json --generated-at 2026-04-16T00:10:00.000Z >/tmp/generate-drift.log
set +e
DRIFT_OUT=\$(node \"\$SKILL_DIR/scripts/verify_attestation.mjs\" --input \"\$HERMES_HOME/security/attestations/current.json\" --baseline \"\$HERMES_HOME/security/attestations/baseline.json\" --baseline-expected-sha256 \"\$BASE_SHA\" --fail-on-severity critical 2>&1)
DRIFT_CODE=\$?
set -e
[ \"\$DRIFT_CODE\" -ne 0 ]
echo \"\$DRIFT_OUT\" | grep -Eq \"WATCHED_FILE_DRIFT|TRUST_ANCHOR_MISMATCH\"
node \"\$SKILL_DIR/scripts/setup_attestation_cron.mjs\" --every 6h --print-only > /tmp/cron-preview.log
grep -q \"Preflight review:\" /tmp/cron-preview.log
grep -q \"# >>> hermes-attestation-guardian >>>\" /tmp/cron-preview.log
echo \"=== SANDBOX FEATURE TEST SUMMARY ===\"
echo \"install_safe_allowed=PASS\"
echo \"generate_with_policy=PASS\"
echo \"verify_expected_sha=PASS\"
echo \"verify_signature=PASS\"
echo \"baseline_drift_fail_closed=PASS\"
echo \"scheduler_preview=PASS\"
kill \$HPID >/dev/null 2>&1 || true
wait \$HPID 2>/dev/null || true
"
echo "[sandbox] completed successfully"
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Create missing translated wiki pages from English source.
Usage:
python scripts/i18n/bootstrap_language_from_en.py --lang ko
python scripts/i18n/bootstrap_language_from_en.py --lang ko --dry-run
python scripts/i18n/bootstrap_language_from_en.py --lang ko --overwrite
"""
from __future__ import annotations
import argparse
from pathlib import Path
SKIP_TOP_LEVEL_DIRS = {"assets", "i18n", "modules"}
def build_header(lang: str, rel_path: str) -> str:
return (
f"<!-- AUTO-GENERATED TRANSLATION SCAFFOLD ({lang})\n"
f"Source: ../{rel_path}\n"
"Review status: draft\n"
"-->\n\n"
)
def discover_source_pages(wiki_root: Path, lang_dirs: set[str]) -> list[Path]:
pages: list[Path] = []
for page in wiki_root.rglob("*.md"):
rel = page.relative_to(wiki_root)
if not rel.parts:
continue
first = rel.parts[0]
if first in SKIP_TOP_LEVEL_DIRS or first in lang_dirs:
continue
pages.append(page)
return sorted(pages)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--lang", required=True, help="language code, e.g. ko, es, fr")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--overwrite", action="store_true")
args = parser.parse_args()
repo_root = Path(__file__).resolve().parents[2]
wiki_root = repo_root / "wiki"
target_root = wiki_root / args.lang
lang_dirs = {
p.name
for p in wiki_root.iterdir()
if p.is_dir() and (p / "INDEX.md").exists() and p.name not in SKIP_TOP_LEVEL_DIRS
}
source_pages = discover_source_pages(wiki_root, lang_dirs)
created = 0
skipped = 0
overwritten = 0
for src in source_pages:
rel = src.relative_to(wiki_root)
dst = target_root / rel
existed_before = dst.exists()
if existed_before and not args.overwrite:
skipped += 1
continue
src_text = src.read_text(encoding="utf-8")
header = build_header(args.lang, rel.as_posix())
out = header + src_text
if not args.dry_run:
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_text(out, encoding="utf-8")
if existed_before:
overwritten += 1
else:
created += 1
mode = "DRY-RUN" if args.dry_run else "WRITE"
print(f"[{mode}] lang={args.lang} source_pages={len(source_pages)} created={created} overwritten={overwritten} skipped={skipped}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,207 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import re
from collections.abc import Iterable
from pathlib import Path
from argostranslate import translate
RE_INLINE_CODE = re.compile(r"`[^`]*`")
RE_MD_LINK = re.compile(r"\[[^\]]*\]\([^\)]*\)")
ENGLISH_HINTS = {
"a",
"an",
"and",
"are",
"as",
"at",
"be",
"before",
"by",
"for",
"from",
"if",
"in",
"is",
"of",
"on",
"or",
"the",
"this",
"to",
"use",
"using",
"when",
"with",
"you",
"your",
}
def _protect_tokens(line: str) -> tuple[str, dict[str, str]]:
mapping: dict[str, str] = {}
idx = 0
def repl(pattern: re.Pattern[str], text: str) -> str:
nonlocal idx
def _r(m: re.Match[str]) -> str:
nonlocal idx
key = f"ZXQTOKEN{idx}QXZ"
idx += 1
mapping[key] = m.group(0)
return key
return pattern.sub(_r, text)
out = line
out = repl(RE_MD_LINK, out)
out = repl(RE_INLINE_CODE, out)
return out, mapping
def _restore_tokens(line: str, mapping: dict[str, str]) -> str:
out = line
for key, value in mapping.items():
out = out.replace(key, value)
old_style = re.fullmatch(r"__TOK_(\d+)__", key)
if old_style:
idx = old_style.group(1)
out = re.sub(rf"_{{1,2}}TOK_{idx}_{{1,2}}", value, out)
continue
new_style = re.fullmatch(r"ZXQTOKEN(\d+)QXZ", key)
if new_style:
idx = new_style.group(1)
out = re.sub(rf"ZXQTOKEN{idx}\s+QXZ", value, out)
return out
def _should_translate(line: str) -> bool:
s = line.strip()
if not s:
return False
if s.startswith("<!--") or s.endswith("-->"):
return False
return True
def _translate_line(tr, line: str) -> str:
protected, mapping = _protect_tokens(line)
translated = tr.translate(protected)
restored = _restore_tokens(translated, mapping)
return restored
def _normalize_line(line: str) -> str:
return re.sub(r"\s+", " ", line.strip())
def _looks_like_english(line: str) -> bool:
words = re.findall(r"[A-Za-z]+", line.lower())
if not words:
return False
hint_count = sum(1 for word in words if word in ENGLISH_HINTS)
return hint_count >= 2
def _should_process_target_line(target_line: str, source_lines: set[str]) -> bool:
normalized = _normalize_line(target_line)
return normalized in source_lines or _looks_like_english(target_line)
def _process_pair(source: Path, target: Path, tr) -> int:
src_lines = source.read_text(encoding="utf-8").splitlines()
src_set = {_normalize_line(line) for line in src_lines if line.strip()}
tgt_lines = target.read_text(encoding="utf-8").splitlines()
changed = 0
in_code = False
for i, tgt in enumerate(tgt_lines):
if tgt.strip().startswith("```"):
in_code = not in_code
continue
if in_code:
continue
# Only fill lines that are still unchanged or visibly retain English fragments.
if not _should_process_target_line(tgt, src_set):
continue
if not _should_translate(tgt):
continue
new = _translate_line(tr, tgt)
if new and new != tgt:
tgt_lines[i] = new
changed += 1
if changed:
target.write_text("\n".join(tgt_lines) + "\n", encoding="utf-8")
return changed
def _normalize_only(values: Iterable[str] | None) -> set[str]:
normalized: set[str] = set()
for value in values or []:
item = value.strip().replace("\\", "/")
if not item:
continue
normalized.add(item)
normalized.add(Path(item).name)
return normalized
def _matches_only(path: Path, repo: Path, only: set[str]) -> bool:
if not only:
return True
rel = path.relative_to(repo).as_posix()
candidates = {path.name, rel}
return bool(candidates & only)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--lang", required=True, choices=["de", "es", "fr", "ja", "ko"])
parser.add_argument(
"--only",
nargs="*",
default=None,
help="Optional list of target markdown filenames to process (e.g. README.ja.md overview.md security.md)",
)
args = parser.parse_args()
repo = Path(__file__).resolve().parents[2]
tr = translate.get_translation_from_codes("en", args.lang)
if tr is None:
raise SystemExit(f"Missing Argos en->{args.lang} model. Install first.")
total = 0
only = _normalize_only(args.only)
# README
readme_target = repo / f"README.{args.lang}.md"
if _matches_only(readme_target, repo, only):
total += _process_pair(repo / "README.md", readme_target, tr)
# wiki/<lang>
lang_root = repo / "wiki" / args.lang
for lang_file in sorted(lang_root.glob("*.md")):
if not _matches_only(lang_file, repo, only):
continue
if lang_file.name in {"INDEX.md", "GENERATION.md"}:
continue
src = repo / "wiki" / lang_file.name
if src.exists():
total += _process_pair(src, lang_file, tr)
print(f"Updated translated lines for {args.lang}: {total}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
MARKDOWN_LINK_RE = re.compile(r"\[[^\]]+\]\(([^)]+)\)")
def _all_docs() -> set[Path]:
files = set(ROOT.glob("README*.md"))
files.update(ROOT.glob("wiki/**/*.md"))
return {p for p in files if p.is_file()}
def _changed_docs(base_ref: str) -> set[Path]:
cmd = ["git", "diff", "--name-only", f"{base_ref}...HEAD"]
output = subprocess.check_output(cmd, cwd=ROOT, text=True)
docs: set[Path] = set()
for rel in output.splitlines():
if not rel.endswith(".md"):
continue
p = (ROOT / rel).resolve()
if p in _all_docs() and p.exists():
docs.add(p)
return docs
def _should_skip(link: str) -> bool:
return (
not link
or link.startswith("#")
or "://" in link
or link.startswith("mailto:")
or link.startswith("tel:")
)
def _resolve_target(doc: Path, link: str) -> Path:
clean = link.split("#", 1)[0].strip()
return (doc.parent / clean).resolve()
def _select_docs(changed_only: bool, base_ref: str) -> list[Path]:
all_docs = _all_docs()
if not changed_only:
return sorted(all_docs)
try:
changed = _changed_docs(base_ref)
except Exception as exc: # noqa: BLE001
print(f"[link-check] WARN: changed-only mode failed ({exc}); falling back to full scan")
return sorted(all_docs)
if not changed:
print("[link-check] No changed markdown docs detected; nothing to check.")
return []
return sorted(changed)
def main() -> int:
parser = argparse.ArgumentParser(description="Check local markdown links in README/wiki docs")
parser.add_argument("--changed-only", action="store_true", help="Check only changed markdown docs against a base ref")
parser.add_argument("--base-ref", default="origin/main", help="Base ref for --changed-only (default: origin/main)")
args = parser.parse_args()
docs = _select_docs(args.changed_only, args.base_ref)
failures: list[str] = []
for doc in docs:
rel_doc = doc.relative_to(ROOT)
content = doc.read_text(encoding="utf-8")
for match in MARKDOWN_LINK_RE.finditer(content):
raw_link = match.group(1).strip()
if _should_skip(raw_link):
continue
target = _resolve_target(doc, raw_link)
if not target.exists():
failures.append(f"{rel_doc}: broken link -> {raw_link}")
if failures:
print(f"[link-check] FAILED: {len(failures)} broken link(s) found.")
for item in failures:
print(f" - {item}")
return 1
scope = "changed docs" if args.changed_only else "all docs"
print(f"[link-check] PASS: no broken local markdown links found ({scope}).")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""Translation QA checks for ClawSec docs.
Validates markdown translation pairs with a focus on technical integrity:
- fenced code blocks are preserved exactly
- key inline technical tokens are preserved
- absolute URLs from source are preserved
- non-translatable product/skill terms are preserved
This script checks only pairs that already exist (partial translation is allowed).
"""
from __future__ import annotations
import re
import sys
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class Pair:
source: Path
target: Path
NON_TRANSLATABLE_TERMS = (
"ClawSec",
"OpenClaw",
"NanoClaw",
"Hermes",
"Picoclaw",
"clawsec-suite",
)
def _extract_fenced_blocks(text: str) -> list[str]:
return re.findall(r"```[^\n]*\n.*?```", text, flags=re.DOTALL)
def _extract_inline_code(text: str) -> list[str]:
return re.findall(r"`([^`\n]+)`", text)
def _extract_absolute_urls(text: str) -> set[str]:
return set(re.findall(r"https?://[^\s)>'\"]+", text))
def _is_technical_inline_token(token: str) -> bool:
checks = (
"/" in token,
token.startswith("./"),
token.startswith("../"),
token.endswith(".md"),
token.endswith(".yml"),
token.endswith(".json"),
token.startswith("npx "),
token.startswith("npm "),
token.startswith("python "),
token.startswith("node "),
"--" in token,
bool(re.search(r"\$[A-Z_][A-Z0-9_]*", token)),
)
return any(checks)
def _collect_pairs(repo_root: Path) -> list[Pair]:
pairs: list[Pair] = []
readme_en = repo_root / "README.md"
for translated_readme in sorted(repo_root.glob("README.*.md")):
if translated_readme.name == "README.md":
continue
if readme_en.exists():
pairs.append(Pair(readme_en, translated_readme))
wiki_root = repo_root / "wiki"
language_dirs = {
p.name
for p in wiki_root.iterdir()
if p.is_dir() and (p / "INDEX.md").exists() and p.name not in {"modules", "i18n", "assets"}
}
for source in wiki_root.rglob("*.md"):
rel = source.relative_to(wiki_root)
rel_parts = rel.parts
if not rel_parts:
continue
# Skip language roots and i18n metadata as source files.
if rel_parts[0] in language_dirs or rel_parts[0] == "i18n":
continue
for lang in sorted(language_dirs):
target = wiki_root / lang / rel
if target.exists():
pairs.append(Pair(source, target))
return sorted(pairs, key=lambda p: str(p.source))
def _extract_command_lines_from_fence(block: str) -> list[str]:
lines = block.splitlines()[1:-1]
cleaned: list[str] = []
for line in lines:
candidate = line.strip()
if not candidate or candidate.startswith("#"):
continue
cleaned.append(candidate)
return cleaned
def _check_pair(pair: Pair) -> tuple[list[str], list[str]]:
errors: list[str] = []
warnings: list[str] = []
source_text = pair.source.read_text(encoding="utf-8")
target_text = pair.target.read_text(encoding="utf-8")
source_blocks = _extract_fenced_blocks(source_text)
target_blocks = _extract_fenced_blocks(target_text)
partial_pair = len(source_blocks) != len(target_blocks)
if partial_pair:
# Allow partial translations, but preserve command lines in translated fences.
for idx, target_block in enumerate(target_blocks, start=1):
for command_line in _extract_command_lines_from_fence(target_block):
if command_line not in source_text:
errors.append(
f"translated code fence #{idx} contains command line not found in source: {command_line}"
)
warnings.append(
f"partial translation detected (code fences source={len(source_blocks)} target={len(target_blocks)})"
)
else:
for idx, (src_block, tgt_block) in enumerate(zip(source_blocks, target_blocks), start=1):
src_commands = _extract_command_lines_from_fence(src_block)
tgt_commands = _extract_command_lines_from_fence(tgt_block)
if src_commands != tgt_commands:
errors.append(f"code fence #{idx} command lines differ from source")
source_inline = {tok for tok in _extract_inline_code(source_text) if _is_technical_inline_token(tok)}
missing_inline = sorted(tok for tok in source_inline if tok not in target_text)
if missing_inline:
preview = ", ".join(missing_inline[:8])
extra = "" if len(missing_inline) <= 8 else f" (+{len(missing_inline) - 8} more)"
msg = f"missing inline technical tokens: {preview}{extra}"
if partial_pair:
warnings.append(f"{msg} (partial pair)")
else:
warnings.append(msg)
source_urls = _extract_absolute_urls(source_text)
missing_urls = sorted(url for url in source_urls if url not in target_text)
if missing_urls:
preview = ", ".join(missing_urls[:5])
extra = "" if len(missing_urls) <= 5 else f" (+{len(missing_urls) - 5} more)"
msg = f"missing absolute URLs: {preview}{extra}"
if partial_pair:
warnings.append(f"{msg} (partial pair)")
else:
warnings.append(msg)
for term in NON_TRANSLATABLE_TERMS:
if term in source_text and term not in target_text:
errors.append(f"non-translatable term missing: {term}")
return errors, warnings
def main() -> int:
repo_root = Path(__file__).resolve().parents[2]
pairs = _collect_pairs(repo_root)
if not pairs:
print("[i18n-qa] No translation pairs found. Nothing to check.")
return 0
print(f"[i18n-qa] Checking {len(pairs)} translation pairs...")
total_errors = 0
total_warnings = 0
for pair in pairs:
rel_source = pair.source.relative_to(repo_root)
rel_target = pair.target.relative_to(repo_root)
errors, warnings = _check_pair(pair)
for warn in warnings:
total_warnings += 1
print(f"WARN {rel_source} -> {rel_target} :: {warn}")
if errors:
total_errors += len(errors)
print(f"\nFAIL {rel_source} -> {rel_target}")
for err in errors:
print(f" - {err}")
else:
print(f"PASS {rel_source} -> {rel_target}")
if total_errors:
print(f"\n[i18n-qa] FAILED with {total_errors} issue(s) and {total_warnings} warning(s).")
return 1
print(f"\n[i18n-qa] All checks passed with {total_warnings} warning(s).")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,88 @@
from __future__ import annotations
import importlib.util
import sys
import tempfile
import types
import unittest
from pathlib import Path
def _load_module():
translate_stub = types.SimpleNamespace(get_translation_from_codes=lambda *_args: None)
sys.modules.setdefault("argostranslate", types.SimpleNamespace(translate=translate_stub))
module_path = Path(__file__).with_name("fill_missing_translations_argos.py")
spec = importlib.util.spec_from_file_location("fill_missing_translations_argos", module_path)
if spec is None or spec.loader is None:
raise RuntimeError(f"Unable to load {module_path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class FakeTranslator:
def translate(self, line: str) -> str:
return (
line.replace("Brought to you von", "Bereitgestellt von")
.replace("the Platform of AI Security", "die Plattform fuer KI-Sicherheit")
.replace("requires WSL or Git Bash", "erfordern WSL oder Git Bash")
)
class FillMissingTranslationsArgosTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.module = _load_module()
def test_restore_tokens_handles_placeholder_variants(self) -> None:
mapping = {
"__TOK_0__": "`bash`",
"__TOK_1__": "[Prompt Security](https://prompt.security)",
"ZXQTOKEN2QXZ": "`node`",
}
restored = self.module._restore_tokens(
"Use __TOK_0_, _TOK_1__, and ZXQTOKEN2 QXZ before running.",
mapping,
)
self.assertEqual(
restored,
"Use `bash`, [Prompt Security](https://prompt.security), and `node` before running.",
)
def test_process_pair_translates_lines_that_still_contain_english_fragments(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
source = root / "source.md"
target = root / "target.md"
source.write_text(
'<h4>Brought to you by <a href="https://prompt.security">Prompt Security</a>, '
"the Platform of AI Security</h4>\n",
encoding="utf-8",
)
target.write_text(
'<h4>Brought to you von <a href="https://prompt.security">Prompt Security</a>, '
"the Platform of AI Security>/h4>\n",
encoding="utf-8",
)
changed = self.module._process_pair(source, target, FakeTranslator())
self.assertEqual(changed, 1)
self.assertIn("Bereitgestellt von", target.read_text(encoding="utf-8"))
self.assertIn("die Plattform fuer KI-Sicherheit", target.read_text(encoding="utf-8"))
def test_only_matching_accepts_repo_relative_wiki_targets(self) -> None:
repo = Path("/repo")
target = repo / "wiki" / "ja" / "overview.md"
matches_only = getattr(self.module, "_matches_only", lambda *_args: False)
self.assertTrue(matches_only(target, repo, {"wiki/ja/overview.md"}))
self.assertTrue(matches_only(target, repo, {"overview.md"}))
self.assertFalse(matches_only(target, repo, {"wiki/ja/security.md"}))
if __name__ == "__main__":
unittest.main()
+41
View File
@@ -0,0 +1,41 @@
from __future__ import annotations
import importlib.util
import sys
import tempfile
import unittest
from pathlib import Path
def _load_module():
module_path = Path(__file__).with_name("qa_check.py")
spec = importlib.util.spec_from_file_location("qa_check", module_path)
if spec is None or spec.loader is None:
raise RuntimeError(f"Unable to load {module_path}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
class QaCheckTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.module = _load_module()
def test_partial_pairs_still_fail_when_non_translatable_terms_are_missing(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
source = root / "source.md"
target = root / "target.md"
source.write_text("ClawSec keeps this term.\n```sh\nnpm run build\n```\n", encoding="utf-8")
target.write_text("Translated text without the product term.\n", encoding="utf-8")
errors, warnings = self.module._check_pair(self.module.Pair(source, target))
self.assertIn("non-translatable term missing: ClawSec", errors)
self.assertTrue(any("partial translation detected" in warning for warning in warnings))
if __name__ == "__main__":
unittest.main()
+82 -33
View File
@@ -16,8 +16,10 @@ source "$SCRIPT_DIR/feed-utils.sh"
# Configuration - same as pipeline
init_feed_paths "$PROJECT_ROOT"
KEYWORDS="OpenClaw clawdbot Moltbot NanoClaw WhatsApp-bot baileys"
GITHUB_REF_PATTERN="github.com/openclaw/openclaw github.com/qwibitai/NanoClaw"
NVD_QUERY_SPECS="$(nvd_query_specs)"
KEYWORDS_PATTERN="$(nvd_keyword_pattern)"
GITHUB_REF_PATTERN="$(nvd_github_ref_pattern)"
CPE_PATTERN="$(nvd_cpe_pattern)"
ENRICH_SCRIPT="$PROJECT_ROOT/scripts/ci/enrich_exploitability.sh"
# Parse args
@@ -86,16 +88,19 @@ END_ENC=${END_DATE//:/%3A}
echo "=== Fetching CVEs from NVD ==="
for KEYWORD in $KEYWORDS; do
echo "Fetching keyword: $KEYWORD"
URL="https://services.nvd.nist.gov/rest/json/cves/2.0?keywordSearch=${KEYWORD}&lastModStartDate=${START_ENC}&lastModEndDate=${END_ENC}"
while IFS='|' read -r QUERY_KIND QUERY_VALUE; do
[ -n "$QUERY_KIND" ] || continue
QUERY_SLUG=$(nvd_query_slug "$QUERY_KIND" "$QUERY_VALUE")
echo "Fetching $QUERY_KIND query: $QUERY_VALUE"
URL=$(nvd_build_url "$QUERY_KIND" "$QUERY_VALUE" "&lastModStartDate=${START_ENC}&lastModEndDate=${END_ENC}")
# Fetch with retry logic
for i in 1 2 3; do
HTTP_CODE=$(curl -s -w "%{http_code}" -o "$TEMP_DIR/nvd_${KEYWORD}.json" "$URL")
HTTP_CODE=$(curl -s -w "%{http_code}" -o "$TEMP_DIR/nvd_${QUERY_SLUG}.json" "$URL")
if [ "$HTTP_CODE" = "200" ]; then
COUNT=$(jq '.vulnerabilities | length // 0' "$TEMP_DIR/nvd_${KEYWORD}.json" 2>/dev/null || echo 0)
COUNT=$(jq '.vulnerabilities | length // 0' "$TEMP_DIR/nvd_${QUERY_SLUG}.json" 2>/dev/null || echo 0)
echo " ✓ Found $COUNT CVEs"
break
elif [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "429" ]; then
@@ -110,7 +115,7 @@ for KEYWORD in $KEYWORDS; do
# NVD recommends 6 second delay between requests
echo " Waiting 6s (NVD rate limit)..."
sleep 6
done
done <<< "$NVD_QUERY_SPECS"
echo ""
echo "=== Processing CVEs ==="
@@ -118,8 +123,10 @@ echo "=== Processing CVEs ==="
# Combine all fetched CVEs
echo '{"vulnerabilities":[]}' > "$TEMP_DIR/combined.json"
for KEYWORD in $KEYWORDS; do
FILE="$TEMP_DIR/nvd_${KEYWORD}.json"
while IFS='|' read -r QUERY_KIND QUERY_VALUE; do
[ -n "$QUERY_KIND" ] || continue
QUERY_SLUG=$(nvd_query_slug "$QUERY_KIND" "$QUERY_VALUE")
FILE="$TEMP_DIR/nvd_${QUERY_SLUG}.json"
if [ -f "$FILE" ] && [ -s "$FILE" ]; then
if jq -e '.vulnerabilities' "$FILE" > /dev/null 2>&1; then
jq -s '.[0].vulnerabilities += .[1].vulnerabilities | .[0]' \
@@ -127,7 +134,7 @@ for KEYWORD in $KEYWORDS; do
mv "$TEMP_DIR/combined_new.json" "$TEMP_DIR/combined.json"
fi
fi
done
done <<< "$NVD_QUERY_SPECS"
# Deduplicate by CVE ID
jq '.vulnerabilities | unique_by(.cve.id)' "$TEMP_DIR/combined.json" > "$TEMP_DIR/unique_cves.json"
@@ -135,13 +142,13 @@ TOTAL=$(jq 'length' "$TEMP_DIR/unique_cves.json")
echo "Total unique CVEs from NVD: $TOTAL"
# Post-filter: keep only CVEs matching our criteria
KEYWORDS_PATTERN="OpenClaw|clawdbot|Moltbot|openclaw|NanoClaw|nanoclaw|WhatsApp-bot|baileys"
jq --arg kw "$KEYWORDS_PATTERN" --arg gh "$GITHUB_REF_PATTERN" '
jq --arg kw "$KEYWORDS_PATTERN" --arg gh "$GITHUB_REF_PATTERN" --arg cpe "$CPE_PATTERN" '
[.[] | select(
(.cve.descriptions[]? | select(.lang == "en") | .value | test($kw; "i"))
or
(.cve.references[]? | .url | test($gh; "i"))
or
([.cve.configurations[]? | .. | objects | .criteria? | strings | test($cpe; "i")] | any)
)]
' "$TEMP_DIR/unique_cves.json" > "$TEMP_DIR/filtered_cves.json"
@@ -151,17 +158,16 @@ echo "Filtered CVEs (matching criteria): $FILTERED"
# Get existing advisory IDs (unless force mode)
if [ "$FORCE" = "true" ]; then
echo "Force mode: ignoring existing advisory IDs during transform"
EXISTING_IDS=""
echo '[]' > "$TEMP_DIR/existing_ids.json"
elif [ -f "$FEED_PATH" ]; then
EXISTING_IDS=$(jq -r '.advisories[]?.id // empty' "$FEED_PATH" | sort -u)
jq -r '.advisories[]?.id // empty' "$FEED_PATH" | sort -u | \
jq -R -s 'split("\n") | map(select(length > 0))' > "$TEMP_DIR/existing_ids.json"
else
EXISTING_IDS=""
echo '[]' > "$TEMP_DIR/existing_ids.json"
fi
# Transform CVEs to our advisory format (same logic as pipeline)
EXISTING_JSON=$(echo "$EXISTING_IDS" | jq -R -s 'split("\n") | map(select(length > 0))')
jq --argjson existing "$EXISTING_JSON" '
jq --slurpfile existing "$TEMP_DIR/existing_ids.json" '
def map_severity:
if . == null then "medium"
elif . >= 9.0 then "critical"
@@ -255,7 +261,8 @@ jq --argjson existing "$EXISTING_JSON" '
(
[
(.cve.descriptions[]? | select(.lang == "en") | .value),
(.cve.references[]?.url // empty)
(.cve.references[]?.url // empty),
(.cve.configurations[]? | .. | objects | .criteria? // empty)
]
| map(strings | ascii_downcase)
| join(" ")
@@ -263,27 +270,64 @@ jq --argjson existing "$EXISTING_JSON" '
| (
(if ($blob | test("github\\.com/openclaw/openclaw|\\bopenclaw\\b|\\bclawdbot\\b|\\bmoltbot\\b")) then ["openclaw@*"] else [] end)
+ (if ($blob | test("github\\.com/qwibitai/nanoclaw|\\bnanoclaw\\b|whatsapp-bot|\\bbaileys\\b")) then ["nanoclaw@*"] else [] end)
+ (if ($blob | test("github\\.com/softwarepub/hermes|cpe:2\\.3:a:software-metadata\\.pub:hermes|\\bhermes workflow\\b|software publication with rich metadata")) then ["hermes@*"] else [] end)
+ (if ($blob | test("github\\.com/[^/]+/picoclaw|\\bpicoclaw\\b|cpe:2\\.3:[aho]:[^:]*:picoclaw(?::|$)")) then ["picoclaw@*"] else [] end)
)
);
def normalized_affected:
def matched_targets:
(
(cpe_criteria + inferred_targets)
| unique
| .[0:5]
| if length == 0 then ["openclaw@*", "nanoclaw@*"] else . end
);
def platforms_from_targets($targets):
(
[
(if ($targets | map(strings | ascii_downcase | select(startswith("openclaw@") or test("^cpe:2\\.3:[aho]:openclaw:openclaw(?::|$)"))) | length > 0) then "openclaw" else empty end),
(if ($targets | map(strings | ascii_downcase | select(startswith("nanoclaw@") or test("^cpe:2\\.3:[aho]:[^:]*:nanoclaw(?::|$)"))) | length > 0) then "nanoclaw" else empty end),
(if ($targets | map(strings | ascii_downcase | select(startswith("hermes@") or test("^cpe:2\\.3:[aho]:software-metadata\\.pub:hermes(?::|$)"))) | length > 0) then "hermes" else empty end),
(if ($targets | map(strings | ascii_downcase | select(startswith("picoclaw@") or test("^cpe:2\\.3:[aho]:[^:]*:picoclaw(?::|$)"))) | length > 0) then "picoclaw" else empty end)
]
);
def normalized_affected:
(
matched_targets
| if length == 0 then ["openclaw@*", "nanoclaw@*", "hermes@*", "picoclaw@*"] else . end
);
def normalized_platforms:
(
inferred_targets as $inferred
| platforms_from_targets($inferred) as $from_inferred
| if ($from_inferred | length) > 0 then $from_inferred
else
matched_targets as $targets
| platforms_from_targets($targets) as $from_targets
| if ($from_targets | length) > 0 then $from_targets else ["openclaw", "nanoclaw", "hermes", "picoclaw"] end
end
);
def preferred_description:
(
(.cve.descriptions[]? | select(.lang == "en") | .value)
// .cve.descriptions[0]?.value
// "No description provided by NVD."
);
[.[] |
select(.cve.id as $id | $existing | index($id) | not) |
select(.cve.id as $id | (($existing[0] // []) | index($id) | not)) |
{
id: .cve.id,
severity: (get_cvss_score | map_severity),
type: nvd_category_name,
nvd_category_id: nvd_category_raw,
title: (.cve.descriptions[] | select(.lang == "en") | .value | .[0:100] + (if length > 100 then "..." else "" end)),
description: (.cve.descriptions[] | select(.lang == "en") | .value),
title: (preferred_description | .[0:100] + (if length > 100 then "..." else "" end)),
description: preferred_description,
affected: normalized_affected,
platforms: normalized_platforms,
action: "Review and update affected components. See NVD for remediation details.",
published: .cve.published,
references: [.cve.references[]?.url // empty] | unique | .[0:3],
@@ -298,6 +342,11 @@ jq --argjson existing "$EXISTING_JSON" '
NEW_COUNT=$(jq 'length' "$TEMP_DIR/new_advisories.json")
echo "New advisories to add: $NEW_COUNT"
if [ "$FORCE" = "true" ] && [ "$NEW_COUNT" -ne "$FILTERED" ]; then
echo "Error: full rebuild transform mismatch (filtered=$FILTERED, transformed=$NEW_COUNT)"
exit 1
fi
if [ "$NEW_COUNT" -eq 0 ]; then
echo ""
echo "No new CVEs found. Feed is up to date."
@@ -338,11 +387,11 @@ NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# Merge new advisories into existing feed
if [ -f "$FEED_PATH" ]; then
jq --argjson new "$(cat "$TEMP_DIR/new_advisories.json")" --arg now "$NOW" '
jq --slurpfile new "$TEMP_DIR/new_advisories.json" --arg now "$NOW" '
.updated = $now |
# Merge by advisory ID so force mode can refresh existing CVEs without duplicates
.advisories = (
reduce (.advisories + $new)[] as $adv
reduce ((.advisories // []) + ($new[0] // []))[] as $adv
({};
if ($adv.id // "") == "" then
.
@@ -356,11 +405,11 @@ if [ -f "$FEED_PATH" ]; then
)
' "$FEED_PATH" > "$TEMP_DIR/updated_feed.json"
else
jq -n --argjson advisories "$(cat "$TEMP_DIR/new_advisories.json")" --arg now "$NOW" '{
jq -n --slurpfile advisories "$TEMP_DIR/new_advisories.json" --arg now "$NOW" '{
version: "1.0.0",
updated: $now,
description: "Community-driven security advisory feed for ClawSec. Automatically updated with OpenClaw and NanoClaw-related CVEs from NVD.",
advisories: ($advisories | sort_by(.published) | reverse)
description: "Community-driven security advisory feed for ClawSec. Automatically updated with OpenClaw, NanoClaw, Hermes, and Picoclaw-related CVEs from NVD.",
advisories: (($advisories[0] // []) | sort_by(.published) | reverse)
}' > "$TEMP_DIR/updated_feed.json"
fi
+29 -9
View File
@@ -168,15 +168,35 @@ EOF
echo " ✓ Generated: checksums.json"
# Build skill entry for index
SKILL_DATA=$(jq -c --arg tag "$TAG" '{
id: .name,
name: .name,
version: .version,
description: .description,
emoji: .openclaw.emoji,
category: .openclaw.category,
tag: $tag
}' "$SKILL_JSON")
SKILL_DATA=$(jq -c --arg tag "$TAG" '
. as $skill |
def object_or_empty($value):
if ($value | type) == "object" then $value else {} end;
def object_field($name):
object_or_empty($skill[$name]?);
def platform_meta:
($skill.platform as $platform
| if ($platform | type) == "string" then object_or_empty($skill[$platform]?)
else {}
end);
def platform_list:
([]
+ (if ($skill.platforms | type) == "array" then $skill.platforms else [] end)
+ (if ($skill.platform | type) == "string" then [$skill.platform] else [] end)
+ (["openclaw", "hermes", "nanoclaw", "picoclaw"] | map(select((object_field(.) | length) > 0))))
| map(select(type == "string") | ascii_downcase)
| unique;
{
id: .name,
name: .name,
version: .version,
description: .description,
emoji: (platform_meta.emoji // object_field("openclaw").emoji // object_field("hermes").emoji // object_field("nanoclaw").emoji // object_field("picoclaw").emoji // "📦"),
category: (platform_meta.category // object_field("openclaw").category // object_field("hermes").category // object_field("nanoclaw").category // object_field("picoclaw").category // "utility"),
platforms: platform_list,
tag: $tag
}
' "$SKILL_JSON")
# Append to index
if [ "$FIRST_SKILL" = "true" ]; then
+54
View File
@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
const workflowPath = new URL("../.github/workflows/deploy-pages.yml", import.meta.url);
const workflow = await readFile(workflowPath, "utf8");
function stepIndex(name) {
const marker = `- name: ${name}`;
const index = workflow.indexOf(marker);
assert.notEqual(index, -1, `missing workflow step: ${name}`);
return index;
}
const signFeedIndex = stepIndex("Sign advisory feed and verify");
const signGhsaIndex = stepIndex("Sign provisional GHSA feed and verify");
const generateChecksumsIndex = stepIndex("Generate advisory checksums manifest");
const signChecksumsIndex = stepIndex("Sign checksums and verify");
assert.ok(
signFeedIndex < generateChecksumsIndex,
"advisory checksums manifest must be generated after feed.json.sig is created",
);
assert.ok(
signGhsaIndex < generateChecksumsIndex,
"advisory checksums manifest must be generated after ghsa-without-cve.json.sig is created",
);
assert.ok(
generateChecksumsIndex < signChecksumsIndex,
"checksums signature must be generated after checksums.json is refreshed",
);
const generateStepBody = workflow.slice(generateChecksumsIndex, signChecksumsIndex);
assert.match(
generateStepBody,
/public\/advisories\/\*\.json\.sig/,
"advisory checksums manifest must include detached advisory signatures",
);
const mirrorBlockIndex = workflow.indexOf(
"# Mirror advisories feed + signatures at the path referenced by suite docs/heartbeat",
);
assert.notEqual(mirrorBlockIndex, -1, "missing advisory release mirror block");
const mirrorBlock = workflow.slice(mirrorBlockIndex, workflow.indexOf("if [ -f \"public/checksums.json\"", mirrorBlockIndex));
assert.match(
mirrorBlock,
/cp "public\/advisories\/ghsa-without-cve\.json" "\$MIRROR_LATEST_DIR\/ghsa-without-cve\.json"/,
"GHSA provisional feed must be mirrored at the release-root compatibility path",
);
assert.match(
mirrorBlock,
/cp "public\/advisories\/ghsa-without-cve\.json\.sig" "\$MIRROR_LATEST_DIR\/ghsa-without-cve\.json\.sig"/,
"GHSA provisional feed signature must be mirrored at the release-root compatibility path",
);
+37
View File
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
const workflowPath = new URL('../.github/workflows/poll-ghsa-without-cve.yml', import.meta.url);
const workflow = await readFile(workflowPath, 'utf8');
assert.match(workflow, /workflow_dispatch:/, 'GHSA poll workflow must remain runnable as a manual fallback');
assert.doesNotMatch(
workflow,
/\n\s+schedule:/,
'Scheduled GHSA consolidation belongs to the NVD workflow to avoid duplicate automated feed PRs',
);
assert.match(
workflow,
/FEED_PATH:\s+advisories\/feed\.json/,
'GHSA poll workflow must know the consolidated agent feed path',
);
assert.match(
workflow,
/SKILL_FEED_PATH:\s+skills\/clawsec-feed\/advisories\/feed\.json/,
'GHSA poll workflow must sync the consolidated agent feed into clawsec-feed',
);
assert.match(
workflow,
/--consolidated-feed "\$FEED_PATH"/,
'GHSA poll workflow must merge GHSA advisories into the agent-facing feed',
);
assert.match(
workflow,
/input_file: \$\{\{ env\.FEED_PATH \}\}/,
'GHSA poll workflow must sign the consolidated agent feed when it changes',
);
assert.match(
workflow,
/cp "\$FEED_SIG_PATH" "\$SKILL_FEED_SIG_PATH"/,
'GHSA poll workflow must sync consolidated feed signature into clawsec-feed',
);
+425
View File
@@ -0,0 +1,425 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildConsolidatedAdvisoryFeed,
buildGhsaWithoutCveFeed,
fetchGitHubSecurityAdvisories,
inferPlatforms,
normalizeGhsaAdvisory,
} from './ghsa-without-cve-feed.mjs';
const fixedNow = '2026-05-24T00:00:00Z';
function advisory(overrides = {}) {
return {
ghsa_id: 'GHSA-test-1111-2222',
cve_id: null,
html_url: 'https://github.com/openclaw/openclaw/security/advisories/GHSA-test-1111-2222',
summary: 'Workspace bridge allows sandbox escape',
description: 'OpenClaw before 2026.4.25 allowed a sandbox escape.',
severity: 'high',
published_at: '2026-04-24T00:00:00Z',
updated_at: '2026-04-25T00:00:00Z',
vulnerabilities: [
{
package: { ecosystem: 'npm', name: 'openclaw' },
vulnerable_version_range: '<2026.4.25',
patched_versions: '2026.4.25',
},
],
cvss: {
vector_string: 'CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H',
score: 7.8,
},
cwe_ids: ['CWE-94'],
credits: [{ login: 'researcher', type: 'reporter' }],
...overrides,
};
}
test('inferPlatforms maps known repositories to feed platforms', () => {
assert.deepEqual(inferPlatforms('openclaw/openclaw'), ['openclaw']);
assert.deepEqual(inferPlatforms('qwibitai/nanoclaw'), ['nanoclaw']);
assert.deepEqual(inferPlatforms('softwarepub/hermes'), ['hermes']);
assert.deepEqual(inferPlatforms('sipeed/picoclaw'), ['picoclaw']);
});
test('fetchGitHubSecurityAdvisories follows cursor pagination links', async (t) => {
const originalFetch = globalThis.fetch;
const nextUrl =
'https://api.github.com/repositories/1103012935/security-advisories?per_page=100&after=cursor';
const calls = [];
globalThis.fetch = async (url) => {
calls.push(String(url));
if (calls.length === 1) {
return new globalThis.Response(
JSON.stringify(
Array.from({ length: 100 }, (_, index) =>
advisory({ ghsa_id: `GHSA-page-1111-${String(index).padStart(4, '0')}` }),
),
),
{
status: 200,
headers: {
Link: `<${nextUrl}>; rel="next"`,
},
},
);
}
if (String(url) !== nextUrl) {
throw new Error(`unexpected pagination URL: ${url}`);
}
return new globalThis.Response(JSON.stringify([advisory({ ghsa_id: 'GHSA-next-1111-2222' })]), {
status: 200,
});
};
t.after(() => {
globalThis.fetch = originalFetch;
});
const advisories = await fetchGitHubSecurityAdvisories('openclaw/openclaw', {
token: 'test-token',
});
assert.equal(calls.length, 2);
assert.equal(calls[1], nextUrl);
assert.equal(advisories.length, 101);
assert.equal(advisories.at(-1).ghsa_id, 'GHSA-next-1111-2222');
});
test('normalizeGhsaAdvisory marks fresh GHSA-only advisories active', () => {
const normalized = normalizeGhsaAdvisory(advisory(), {
now: fixedNow,
repository: 'openclaw/openclaw',
staleAfterDays: 60,
});
assert.equal(normalized.id, 'GHSA-test-1111-2222');
assert.equal(normalized.status, 'active');
assert.equal(normalized.cve_id, null);
assert.equal(normalized.stale, false);
assert.deepEqual(normalized.platforms, ['openclaw']);
assert.deepEqual(normalized.affected, ['openclaw@<2026.4.25']);
});
test('normalizeGhsaAdvisory marks old GHSA-only advisories stale after threshold', () => {
const normalized = normalizeGhsaAdvisory(
advisory({ published_at: '2026-03-01T00:00:00Z' }),
{
now: fixedNow,
repository: 'openclaw/openclaw',
staleAfterDays: 60,
},
);
assert.equal(normalized.status, 'stale');
assert.equal(normalized.stale, true);
assert.equal(normalized.cve_id, null);
});
test('normalizeGhsaAdvisory marks existing GHSA entries matured when a CVE appears', () => {
const normalized = normalizeGhsaAdvisory(
advisory({ cve_id: 'CVE-2026-9999' }),
{
now: fixedNow,
repository: 'openclaw/openclaw',
staleAfterDays: 60,
},
);
assert.equal(normalized.status, 'matured');
assert.equal(normalized.stale, false);
assert.equal(normalized.cve_id, 'CVE-2026-9999');
assert.equal(normalized.nvd_url, 'https://nvd.nist.gov/vuln/detail/CVE-2026-9999');
});
test('buildGhsaWithoutCveFeed only imports CVE-backed advisories that were already tracked', () => {
const existing = {
version: '0.1.0',
advisories: [
normalizeGhsaAdvisory(advisory({ ghsa_id: 'GHSA-old-1111-2222' }), {
now: '2026-04-25T00:00:00Z',
repository: 'openclaw/openclaw',
staleAfterDays: 60,
}),
],
};
const fetched = [
{
repository: 'openclaw/openclaw',
advisories: [
advisory({ ghsa_id: 'GHSA-new-1111-2222', cve_id: null }),
advisory({ ghsa_id: 'GHSA-old-1111-2222', cve_id: 'CVE-2026-1111' }),
advisory({ ghsa_id: 'GHSA-cve-only-1111-2222', cve_id: 'CVE-2026-2222' }),
],
},
];
const feed = buildGhsaWithoutCveFeed({
fetched,
existingFeed: existing,
nvdFeed: { advisories: [] },
now: fixedNow,
staleAfterDays: 60,
});
assert.deepEqual(
feed.advisories.map((entry) => [entry.id, entry.status, entry.cve_id]),
[
['GHSA-new-1111-2222', 'active', null],
['GHSA-old-1111-2222', 'matured', 'CVE-2026-1111'],
],
);
});
test('buildGhsaWithoutCveFeed matures tracked GHSAs when the CVE feed references them', () => {
const existing = {
version: '0.1.0',
advisories: [
normalizeGhsaAdvisory(advisory({ ghsa_id: 'GHSA-oooo-3333-4444' }), {
now: '2026-04-25T00:00:00Z',
repository: 'openclaw/openclaw',
staleAfterDays: 60,
}),
],
};
const feed = buildGhsaWithoutCveFeed({
fetched: [
{
repository: 'openclaw/openclaw',
advisories: [advisory({ ghsa_id: 'GHSA-oooo-3333-4444', cve_id: null })],
},
],
existingFeed: existing,
nvdFeed: {
advisories: [
{
id: 'CVE-2026-3333',
references: [
'https://github.com/openclaw/openclaw/security/advisories/GHSA-oooo-3333-4444',
],
},
],
},
now: fixedNow,
staleAfterDays: 60,
});
assert.equal(feed.advisories[0].status, 'matured');
assert.equal(feed.advisories[0].cve_id, 'CVE-2026-3333');
});
test('buildConsolidatedAdvisoryFeed appends active GHSA advisories without moving the NVD poll cursor', () => {
const canonicalFeed = {
version: '1.0.0',
updated: '2026-05-23T00:00:00Z',
description: 'Community-driven security advisory feed for ClawSec',
advisories: [
{
id: 'CVE-2026-1111',
severity: 'high',
type: 'os_command_injection',
title: 'Existing CVE',
description: 'Existing CVE advisory',
affected: ['openclaw@*'],
platforms: ['openclaw'],
action: 'Review NVD.',
published: '2026-05-01T00:00:00Z',
},
],
};
const ghsaFeed = {
advisories: [
normalizeGhsaAdvisory(advisory({ ghsa_id: 'GHSA-active-1111-2222', cve_id: null }), {
now: fixedNow,
repository: 'openclaw/openclaw',
staleAfterDays: 60,
}),
],
};
const consolidated = buildConsolidatedAdvisoryFeed({
canonicalFeed,
ghsaFeed,
now: fixedNow,
});
assert.deepEqual(
consolidated.advisories.map((entry) => entry.id),
['CVE-2026-1111', 'GHSA-active-1111-2222'],
);
assert.equal(consolidated.updated, canonicalFeed.updated);
assert.equal(consolidated.advisories[1].source_feed, 'ghsa-without-cve');
});
test('buildConsolidatedAdvisoryFeed keeps existing GHSA advisories when replacement feed is empty', () => {
const canonicalFeed = {
version: '1.0.0',
updated: '2026-05-23T00:00:00Z',
advisories: [
{
id: 'CVE-2026-1111',
published: '2026-05-01T00:00:00Z',
},
{
id: 'GHSA-keep-1111-2222',
ghsa_id: 'GHSA-keep-1111-2222',
status: 'active',
published: '2026-05-02T00:00:00Z',
source_feed: 'ghsa-without-cve',
},
],
};
const consolidated = buildConsolidatedAdvisoryFeed({
canonicalFeed,
ghsaFeed: { advisories: [] },
now: fixedNow,
});
assert.deepEqual(
consolidated.advisories.map((entry) => entry.id),
['GHSA-keep-1111-2222', 'CVE-2026-1111'],
);
});
test('buildConsolidatedAdvisoryFeed replaces only matching GHSA canonical entries', () => {
const canonicalFeed = {
version: '1.0.0',
updated: '2026-05-23T00:00:00Z',
advisories: [
{
id: 'GHSA-repl-1111-2222',
ghsa_id: 'GHSA-repl-1111-2222',
status: 'active',
title: 'Old GHSA payload',
published: '2026-05-01T00:00:00Z',
source_feed: 'ghsa-without-cve',
},
{
id: 'GHSA-keep-3333-4444',
ghsa_id: 'GHSA-keep-3333-4444',
status: 'active',
title: 'Retained GHSA payload',
published: '2026-05-02T00:00:00Z',
source_feed: 'ghsa-without-cve',
},
],
};
const ghsaFeed = {
advisories: [
{
id: 'GHSA-repl-1111-2222',
ghsa_id: 'GHSA-repl-1111-2222',
status: 'stale',
title: 'Replacement GHSA payload',
published: '2026-05-03T00:00:00Z',
},
],
};
const consolidated = buildConsolidatedAdvisoryFeed({
canonicalFeed,
ghsaFeed,
now: fixedNow,
});
assert.deepEqual(
consolidated.advisories.map((entry) => [entry.id, entry.title, entry.status]),
[
['GHSA-repl-1111-2222', 'Replacement GHSA payload', 'stale'],
['GHSA-keep-3333-4444', 'Retained GHSA payload', 'active'],
],
);
});
test('buildConsolidatedAdvisoryFeed drops GHSA duplicate when matching CVE is present', () => {
const canonicalFeed = {
version: '1.0.0',
updated: '2026-05-23T00:00:00Z',
advisories: [
{
id: 'CVE-2026-2222',
severity: 'high',
type: 'code_injection',
title: 'Canonical CVE',
description: 'Canonical CVE advisory',
affected: ['openclaw@*'],
platforms: ['openclaw'],
action: 'Review NVD.',
published: '2026-05-02T00:00:00Z',
},
{
id: 'GHSA-old-duplicate',
ghsa_id: 'GHSA-old-duplicate',
cve_id: 'CVE-2026-2222',
status: 'matured',
source_feed: 'ghsa-without-cve',
severity: 'high',
type: 'github_security_advisory',
title: 'Old duplicate',
description: 'Old provisional duplicate',
affected: ['openclaw@*'],
platforms: ['openclaw'],
action: 'Track CVE.',
published: '2026-05-01T00:00:00Z',
},
],
};
const ghsaFeed = {
advisories: [
normalizeGhsaAdvisory(
advisory({ ghsa_id: 'GHSA-new-duplicate', cve_id: 'CVE-2026-2222' }),
{
now: fixedNow,
repository: 'openclaw/openclaw',
staleAfterDays: 60,
},
),
],
};
const consolidated = buildConsolidatedAdvisoryFeed({
canonicalFeed,
ghsaFeed,
now: fixedNow,
});
assert.deepEqual(
consolidated.advisories.map((entry) => entry.id),
['CVE-2026-2222'],
);
});
test('buildConsolidatedAdvisoryFeed keeps matured GHSA until CVE lands in canonical feed', () => {
const canonicalFeed = {
version: '1.0.0',
updated: '2026-05-23T00:00:00Z',
advisories: [],
};
const ghsaFeed = {
advisories: [
normalizeGhsaAdvisory(
advisory({ ghsa_id: 'GHSA-matured-1111-2222', cve_id: 'CVE-2026-4444' }),
{
now: fixedNow,
repository: 'openclaw/openclaw',
staleAfterDays: 60,
},
),
],
};
const consolidated = buildConsolidatedAdvisoryFeed({
canonicalFeed,
ghsaFeed,
now: fixedNow,
});
assert.deepEqual(
consolidated.advisories.map((entry) => [entry.id, entry.status, entry.cve_id]),
[['GHSA-matured-1111-2222', 'matured', 'CVE-2026-4444']],
);
});
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
const workflowPath = new URL('../.github/workflows/poll-nvd-cves.yml', import.meta.url);
const workflow = await readFile(workflowPath, 'utf8');
const ciWorkflowPath = new URL('../.github/workflows/ci.yml', import.meta.url);
const ciWorkflow = await readFile(ciWorkflowPath, 'utf8');
function requiredIndex(snippet, message) {
const index = workflow.indexOf(snippet);
assert.notEqual(index, -1, message);
return index;
}
assert.match(
workflow,
/GHSA_FEED_PATH:\s+advisories\/ghsa-without-cve\.json/,
'NVD workflow must write the provisional GHSA source feed',
);
assert.match(
workflow,
/GHSA_FEED_SIG_PATH:\s+advisories\/ghsa-without-cve\.json\.sig/,
'NVD workflow must sign the provisional GHSA source feed',
);
assert.match(
workflow,
/node scripts\/ghsa-without-cve-feed\.mjs[\s\S]*--output "\$GHSA_FEED_PATH"[\s\S]*--consolidated-feed "\$FEED_PATH"[\s\S]*--existing-feed "\$GHSA_FEED_PATH"[\s\S]*--nvd-feed "\$FEED_PATH"/,
'NVD workflow must merge GHSA advisories into the signed agent feed',
);
assert.match(
workflow,
/id: feed_changes[\s\S]*ghsa_changed=\$GHSA_CHANGED[\s\S]*agent_changed=\$AGENT_CHANGED[\s\S]*changed=true/,
'NVD workflow must detect GHSA and consolidated agent feed changes separately',
);
assert.match(
workflow,
/if: steps\.feed_changes\.outputs\.ghsa_changed == 'true'[\s\S]*input_file: \$\{\{ env\.GHSA_FEED_PATH \}\}[\s\S]*signature_file: \$\{\{ env\.GHSA_FEED_SIG_PATH \}\}/,
'NVD workflow must sign the provisional GHSA feed when it changes',
);
assert.match(
workflow,
/if: steps\.feed_changes\.outputs\.agent_changed == 'true'[\s\S]*input_file: \$\{\{ env\.FEED_PATH \}\}[\s\S]*signature_file: \$\{\{ env\.FEED_SIG_PATH \}\}/,
'NVD workflow must sign the consolidated agent feed when it changes',
);
assert.match(
workflow,
/git add "\$FEED_PATH" "\$FEED_SIG_PATH" "\$GHSA_FEED_PATH" "\$GHSA_FEED_SIG_PATH" "\$SKILL_FEED_PATH" "\$SKILL_FEED_SIG_PATH"/,
'NVD workflow PR must include both NVD and GHSA feed artifacts',
);
assert.match(
ciWorkflow,
/name: NVD \+ GHSA Pipeline Dry Run[\s\S]*node scripts\/test-nvd-ghsa-pipeline-dry-run\.mjs/,
'CI must run the deterministic NVD + GHSA pipeline dry run before merge',
);
const updateFeedIndex = requiredIndex('name: Update feed.json', 'NVD workflow must update the CVE feed first');
const pollGhsaIndex = requiredIndex(
'name: Poll GHSA without CVE and consolidate feed',
'NVD workflow must poll GHSA before signing',
);
const detectChangesIndex = requiredIndex(
'name: Detect advisory feed changes',
'NVD workflow must detect combined feed changes before signing',
);
const signGhsaIndex = requiredIndex(
'name: Sign GHSA feed and verify',
'NVD workflow must sign the GHSA source feed',
);
const signAgentIndex = requiredIndex(
'name: Sign advisory feed and verify',
'NVD workflow must sign the consolidated agent feed',
);
const upsertPrIndex = requiredIndex(
'name: Upsert NVD advisory PR',
'NVD workflow must upsert a PR for any feed change',
);
assert.ok(
updateFeedIndex < pollGhsaIndex,
'GHSA consolidation must run after the NVD update step so matured advisories can reconcile against new CVEs',
);
assert.ok(
pollGhsaIndex < detectChangesIndex,
'Combined feed change detection must run after GHSA consolidation',
);
assert.ok(detectChangesIndex < signGhsaIndex, 'GHSA signing must run after change detection');
assert.ok(detectChangesIndex < signAgentIndex, 'Agent feed signing must run after change detection');
assert.ok(signAgentIndex < upsertPrIndex, 'The PR must be created after feed signing');
+187
View File
@@ -0,0 +1,187 @@
import assert from 'node:assert/strict';
import { generateKeyPairSync, sign, verify } from 'node:crypto';
import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import {
buildConsolidatedAdvisoryFeed,
buildGhsaWithoutCveFeed,
normalizeGhsaAdvisory,
} from './ghsa-without-cve-feed.mjs';
const now = '2026-05-24T00:00:00Z';
function cveAdvisory(overrides = {}) {
return {
id: 'CVE-2026-1111',
severity: 'high',
type: 'code_injection',
title: 'OpenClaw command execution advisory',
description: 'OpenClaw allowed unsafe tool execution in a guarded workspace.',
affected: ['openclaw@<2026.5.20'],
patched: ['openclaw@2026.5.20'],
platforms: ['openclaw'],
action: 'Update OpenClaw and verify guarded workspace execution.',
published: '2026-05-01T00:00:00Z',
updated: '2026-05-01T00:00:00Z',
references: ['https://nvd.nist.gov/vuln/detail/CVE-2026-1111'],
nvd_url: 'https://nvd.nist.gov/vuln/detail/CVE-2026-1111',
...overrides,
};
}
function ghsaAdvisory(overrides = {}) {
return {
ghsa_id: 'GHSA-actv-1111-2222',
cve_id: null,
html_url: 'https://github.com/openclaw/openclaw/security/advisories/GHSA-actv-1111-2222',
summary: 'OpenClaw advisory without CVE',
description: 'OpenClaw published a public GitHub advisory before CVE assignment.',
severity: 'high',
published_at: '2026-05-20T00:00:00Z',
updated_at: '2026-05-21T00:00:00Z',
vulnerabilities: [
{
package: { ecosystem: 'npm', name: 'openclaw' },
vulnerable_version_range: '<2026.5.21',
patched_versions: '2026.5.21',
},
],
cvss: {
vector_string: 'CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H',
score: 7.8,
},
cwe_ids: ['CWE-94'],
credits: [{ login: 'security-researcher', type: 'reporter' }],
...overrides,
};
}
function signBuffer(data, privateKey) {
return sign(null, data, privateKey).toString('base64');
}
function verifySignature(data, signature, publicKey) {
return verify(null, data, publicKey, Buffer.from(signature, 'base64'));
}
async function writeJson(filePath, value) {
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
}
const tempDir = await mkdtemp(path.join(tmpdir(), 'clawsec-nvd-ghsa-ci-dry-run-'));
const canonicalFeedPath = path.join(tempDir, 'advisories/feed.json');
const ghsaFeedPath = path.join(tempDir, 'advisories/ghsa-without-cve.json');
const skillFeedPath = path.join(tempDir, 'skills/clawsec-feed/advisories/feed.json');
const existingCanonicalFeed = {
version: '1.0.0',
updated: '2026-05-23T00:00:00Z',
description: 'Community-driven security advisory feed for ClawSec',
advisories: [
cveAdvisory({
id: 'CVE-2026-1111',
references: [
'https://nvd.nist.gov/vuln/detail/CVE-2026-1111',
'https://github.com/openclaw/openclaw/security/advisories/GHSA-matd-1111-2222',
],
}),
],
};
const nvdPollResultFeed = {
...existingCanonicalFeed,
updated: now,
advisories: [
cveAdvisory({
id: 'CVE-2026-2222',
title: 'Fresh NVD advisory from the poll window',
published: '2026-05-24T00:00:00Z',
updated: '2026-05-24T00:00:00Z',
references: [
'https://nvd.nist.gov/vuln/detail/CVE-2026-2222',
'https://github.com/openclaw/openclaw/security/advisories/GHSA-cvea-1111-2222',
],
nvd_url: 'https://nvd.nist.gov/vuln/detail/CVE-2026-2222',
}),
...existingCanonicalFeed.advisories,
],
};
const existingGhsaFeed = {
version: '0.1.0',
updated: '2026-05-20T00:00:00Z',
advisories: [
normalizeGhsaAdvisory(ghsaAdvisory({ ghsa_id: 'GHSA-matd-1111-2222' }), {
now: '2026-05-20T00:00:00Z',
repository: 'openclaw/openclaw',
staleAfterDays: 60,
}),
],
};
const fetchedGhsaAdvisories = [
{
repository: 'openclaw/openclaw',
advisories: [
ghsaAdvisory({ ghsa_id: 'GHSA-actv-1111-2222' }),
ghsaAdvisory({ ghsa_id: 'GHSA-matd-1111-2222' }),
ghsaAdvisory({ ghsa_id: 'GHSA-cvea-1111-2222', cve_id: 'CVE-2026-2222' }),
],
},
];
const ghsaFeed = buildGhsaWithoutCveFeed({
fetched: fetchedGhsaAdvisories,
existingFeed: existingGhsaFeed,
nvdFeed: nvdPollResultFeed,
now,
staleAfterDays: 60,
});
assert.deepEqual(
ghsaFeed.advisories.map((entry) => [entry.id, entry.status, entry.cve_id]),
[
['GHSA-actv-1111-2222', 'active', null],
['GHSA-matd-1111-2222', 'matured', 'CVE-2026-1111'],
],
'GHSA dry run should retain active GHSA-only advisories and mature tracked GHSAs',
);
const consolidatedFeed = buildConsolidatedAdvisoryFeed({
canonicalFeed: nvdPollResultFeed,
ghsaFeed,
now,
});
assert.deepEqual(
consolidatedFeed.advisories.map((entry) => entry.id),
['CVE-2026-2222', 'GHSA-actv-1111-2222', 'CVE-2026-1111'],
'Consolidated feed should include NVD CVEs plus active GHSA-only advisories without duplicate matured GHSAs',
);
assert.equal(consolidatedFeed.advisories[1].source_feed, 'ghsa-without-cve');
assert.equal(consolidatedFeed.updated, nvdPollResultFeed.updated);
await writeJson(canonicalFeedPath, consolidatedFeed);
await writeJson(ghsaFeedPath, ghsaFeed);
await writeJson(skillFeedPath, consolidatedFeed);
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
const canonicalFeedBytes = await readFile(canonicalFeedPath);
const ghsaFeedBytes = await readFile(ghsaFeedPath);
const skillFeedBytes = await readFile(skillFeedPath);
const canonicalSignature = signBuffer(canonicalFeedBytes, privateKey);
const ghsaSignature = signBuffer(ghsaFeedBytes, privateKey);
await writeFile(`${canonicalFeedPath}.sig`, `${canonicalSignature}\n`);
await writeFile(`${ghsaFeedPath}.sig`, `${ghsaSignature}\n`);
await writeFile(`${skillFeedPath}.sig`, `${canonicalSignature}\n`);
assert.deepEqual(skillFeedBytes, canonicalFeedBytes, 'skill advisory feed must match the signed agent feed');
assert.ok(
verifySignature(canonicalFeedBytes, canonicalSignature, publicKey),
'canonical consolidated feed signature must verify',
);
assert.ok(verifySignature(skillFeedBytes, canonicalSignature, publicKey), 'skill feed signature must verify');
assert.ok(verifySignature(ghsaFeedBytes, ghsaSignature, publicKey), 'GHSA source feed signature must verify');
console.log(
`NVD + GHSA dry run passed: ${consolidatedFeed.advisories.length} consolidated advisories, ${ghsaFeed.advisories.length} GHSA source advisories, signatures verified.`,
);
+29
View File
@@ -0,0 +1,29 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
const workflowPath = new URL('../.github/workflows/skill-release.yml', import.meta.url);
const workflow = await readFile(workflowPath, 'utf8');
assert.match(
workflow,
/pull_request:[\s\S]*paths:[\s\S]*- 'skills\/\*\*'/,
'Skill release workflow must run when any skill package file changes',
);
assert.match(
workflow,
/git diff --name-only "\$\{BASE_SHA\}\.\.\.\$\{HEAD_SHA\}" --[\s\S]*'skills\/\*\/\*\*'[\s\S]*':\(exclude\)skills\/\*\/test\/\*\*'[\s\S]*':\(exclude\)skills\/\*\/tests\/\*\*'/,
'Skill release validation must ignore test-only skill changes while inspecting release-relevant skill files',
);
assert.doesNotMatch(
workflow,
/No version bump detected for \$\{skill_dir\}; skipping\./,
'Changed skill directories without a version bump must fail validation instead of being skipped',
);
assert.match(
workflow,
/::error file=\$\{skill_dir\}::Changed skill package has no version bump\./,
'Skill release validation must emit an explicit missing-version-bump error',
);
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## [0.0.3] - 2026-05-14
### Security
- Added explicit signed release artifact verification instructions for standalone installs, including `checksums.json`, `checksums.sig`, `signing-public.pem`, archive hash verification, and `SKILL.md`/`skill.json` checksum checks.
All notable changes to the Claw Release skill will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+81 -1
View File
@@ -1,6 +1,6 @@
---
name: claw-release
version: 0.0.2
version: 0.0.3
description: Release automation for Claw skills and website. Guides through version bumping, tagging, and release verification.
homepage: https://clawsec.prompt.security
metadata: {"openclaw":{"emoji":"🚀","category":"utility","internal":true}}
@@ -26,6 +26,86 @@ Internal tool for releasing skills and managing the ClawSec catalog.
- Side effects: creates commits, tags, pushes to remote, and publishes GitHub Releases
- Trust model: run only from a trusted checkout with a clean working tree and maintainer approval
## Release Artifact Verification
For standalone installs, verify the signed release manifest before trusting `SKILL.md`, `skill.json`, or the archive. The `skill.json` file is the package metadata/SBOM source, and the release pipeline signs `checksums.json` with the ClawSec release key.
```bash
set -euo pipefail
SKILL_NAME="claw-release"
VERSION="0.0.3"
REPO="prompt-security/clawsec"
TAG="${SKILL_NAME}-v${VERSION}"
BASE="https://github.com/${REPO}/releases/download/${TAG}"
ZIP_NAME="${SKILL_NAME}-v${VERSION}.zip"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
RELEASE_PUBKEY_SHA256="711424e4535f84093fefb024cd1ca4ec87439e53907b305b79a631d5befba9c8"
curl -fsSL "$BASE/checksums.json" -o "$TMP_DIR/checksums.json"
curl -fsSL "$BASE/checksums.sig" -o "$TMP_DIR/checksums.sig"
curl -fsSL "$BASE/signing-public.pem" -o "$TMP_DIR/signing-public.pem"
curl -fsSL "$BASE/$ZIP_NAME" -o "$TMP_DIR/$ZIP_NAME"
curl -fsSL "$BASE/SKILL.md" -o "$TMP_DIR/SKILL.md"
curl -fsSL "$BASE/skill.json" -o "$TMP_DIR/skill.json"
ACTUAL_PUBKEY_SHA256="$(openssl pkey -pubin -in "$TMP_DIR/signing-public.pem" -outform DER | shasum -a 256 | awk '{print $1}')"
if [ "$ACTUAL_PUBKEY_SHA256" != "$RELEASE_PUBKEY_SHA256" ]; then
echo "ERROR: signing-public.pem fingerprint mismatch" >&2
exit 1
fi
openssl base64 -d -A -in "$TMP_DIR/checksums.sig" -out "$TMP_DIR/checksums.sig.bin"
openssl pkeyutl -verify -rawin -pubin \
-inkey "$TMP_DIR/signing-public.pem" \
-sigfile "$TMP_DIR/checksums.sig.bin" \
-in "$TMP_DIR/checksums.json" >/dev/null
hash_file() {
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | awk '{print $1}'
else
sha256sum "$1" | awk '{print $1}'
fi
}
verify_manifest_file() {
asset="$1"
path="$2"
expected="$(jq -r --arg asset "$asset" '.files[$asset].sha256 // empty' "$TMP_DIR/checksums.json")"
if [ -z "$expected" ]; then
echo "ERROR: checksums.json missing $asset" >&2
exit 1
fi
actual="$(hash_file "$path")"
if [ "$actual" != "$expected" ]; then
echo "ERROR: checksum mismatch for $asset" >&2
exit 1
fi
}
expected_archive="$(jq -r '.archive.sha256 // empty' "$TMP_DIR/checksums.json")"
if [ -z "$expected_archive" ]; then
echo "ERROR: checksums.json missing archive.sha256" >&2
exit 1
fi
actual_archive="$(hash_file "$TMP_DIR/$ZIP_NAME")"
if [ "$actual_archive" != "$expected_archive" ]; then
echo "ERROR: archive checksum mismatch" >&2
exit 1
fi
verify_manifest_file "SKILL.md" "$TMP_DIR/SKILL.md"
verify_manifest_file "skill.json" "$TMP_DIR/skill.json"
echo "Signed release manifest, archive, SKILL.md, and skill.json verified."
```
Only install or extract the archive after this verification succeeds.
## Quick Reference
| Release Type | Command | Tag Format |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claw-release",
"version": "0.0.2",
"version": "0.0.3",
"description": "Release automation for Claw skills and website. Guides through version bumping, tagging, and release verification.",
"author": "prompt-security",
"license": "AGPL-3.0-or-later",
@@ -1,5 +1,13 @@
# Changelog
## [0.0.4] - 2026-05-13
### Security
- Added explicit signed release artifact verification instructions for standalone installs, including `checksums.json`, `checksums.sig`, `signing-public.pem`, archive hash verification, and `SKILL.md`/`skill.json` checksum checks.
### Changed
- Re-release skill payload metadata after excluding test-only files from release SBOMs and archives.
All notable changes to the ClawSec ClawHub Checker will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+81 -1
View File
@@ -1,6 +1,6 @@
---
name: clawsec-clawhub-checker
version: 0.0.3
version: 0.0.4
description: ClawHub reputation checker for clawsec-suite. Adds a standalone reputation gate before guarded skill installation.
homepage: https://clawsec.prompt.security
clawdis:
@@ -45,6 +45,86 @@ Optional preflight check (validates local paths and prints recommended command):
node ~/.openclaw/skills/clawsec-clawhub-checker/scripts/setup_reputation_hook.mjs
```
## Release Artifact Verification
For standalone installs, verify the signed release manifest before trusting `SKILL.md`, `skill.json`, or the archive. The `skill.json` file is the package metadata/SBOM source, and the release pipeline signs `checksums.json` with the ClawSec release key.
```bash
set -euo pipefail
SKILL_NAME="clawsec-clawhub-checker"
VERSION="0.0.4"
REPO="prompt-security/clawsec"
TAG="${SKILL_NAME}-v${VERSION}"
BASE="https://github.com/${REPO}/releases/download/${TAG}"
ZIP_NAME="${SKILL_NAME}-v${VERSION}.zip"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
RELEASE_PUBKEY_SHA256="711424e4535f84093fefb024cd1ca4ec87439e53907b305b79a631d5befba9c8"
curl -fsSL "$BASE/checksums.json" -o "$TMP_DIR/checksums.json"
curl -fsSL "$BASE/checksums.sig" -o "$TMP_DIR/checksums.sig"
curl -fsSL "$BASE/signing-public.pem" -o "$TMP_DIR/signing-public.pem"
curl -fsSL "$BASE/$ZIP_NAME" -o "$TMP_DIR/$ZIP_NAME"
curl -fsSL "$BASE/SKILL.md" -o "$TMP_DIR/SKILL.md"
curl -fsSL "$BASE/skill.json" -o "$TMP_DIR/skill.json"
ACTUAL_PUBKEY_SHA256="$(openssl pkey -pubin -in "$TMP_DIR/signing-public.pem" -outform DER | shasum -a 256 | awk '{print $1}')"
if [ "$ACTUAL_PUBKEY_SHA256" != "$RELEASE_PUBKEY_SHA256" ]; then
echo "ERROR: signing-public.pem fingerprint mismatch" >&2
exit 1
fi
openssl base64 -d -A -in "$TMP_DIR/checksums.sig" -out "$TMP_DIR/checksums.sig.bin"
openssl pkeyutl -verify -rawin -pubin \
-inkey "$TMP_DIR/signing-public.pem" \
-sigfile "$TMP_DIR/checksums.sig.bin" \
-in "$TMP_DIR/checksums.json" >/dev/null
hash_file() {
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | awk '{print $1}'
else
sha256sum "$1" | awk '{print $1}'
fi
}
verify_manifest_file() {
asset="$1"
path="$2"
expected="$(jq -r --arg asset "$asset" '.files[$asset].sha256 // empty' "$TMP_DIR/checksums.json")"
if [ -z "$expected" ]; then
echo "ERROR: checksums.json missing $asset" >&2
exit 1
fi
actual="$(hash_file "$path")"
if [ "$actual" != "$expected" ]; then
echo "ERROR: checksum mismatch for $asset" >&2
exit 1
fi
}
expected_archive="$(jq -r '.archive.sha256 // empty' "$TMP_DIR/checksums.json")"
if [ -z "$expected_archive" ]; then
echo "ERROR: checksums.json missing archive.sha256" >&2
exit 1
fi
actual_archive="$(hash_file "$TMP_DIR/$ZIP_NAME")"
if [ "$actual_archive" != "$expected_archive" ]; then
echo "ERROR: archive checksum mismatch" >&2
exit 1
fi
verify_manifest_file "SKILL.md" "$TMP_DIR/SKILL.md"
verify_manifest_file "skill.json" "$TMP_DIR/skill.json"
echo "Signed release manifest, archive, SKILL.md, and skill.json verified."
```
Only install or extract the archive after this verification succeeds.
## Usage
Run the enhanced installer directly from this skill:
+1 -11
View File
@@ -1,6 +1,6 @@
{
"name": "clawsec-clawhub-checker",
"version": "0.0.3",
"version": "0.0.4",
"description": "ClawHub reputation checker for clawsec-suite. Adds a standalone reputation gate before guarded skill installation.",
"author": "abutbul",
"license": "AGPL-3.0-or-later",
@@ -52,16 +52,6 @@
"path": "CHANGELOG.md",
"required": true,
"description": "Version history and release notes"
},
{
"path": "test/reputation_check.test.mjs",
"required": false,
"description": "Test suite for reputation checking functionality"
},
{
"path": "test/setup_reputation_hook.test.mjs",
"required": false,
"description": "Regression coverage for setup preflight behavior"
}
]
},
+10
View File
@@ -1,5 +1,15 @@
# Changelog
## [0.0.8] - 2026-05-24
### Changed
- Documented the consolidated signed advisory feed as the default feed for NVD CVEs, approved community advisories, and provisional GHSA-without-CVE records.
## [0.0.7] - 2026-05-14
### Security
- Added explicit signed release artifact verification instructions for standalone installs, including `checksums.json`, `checksums.sig`, `signing-public.pem`, archive hash verification, and `SKILL.md`/`skill.json` checksum checks.
All notable changes to the ClawSec Feed skill will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+83 -3
View File
@@ -1,6 +1,6 @@
---
name: clawsec-feed
version: 0.0.6
version: 0.0.8
description: Security advisory feed package for OpenClaw-related threats and vulnerabilities. The upstream feed is updated daily; local automation is handled by clawsec-suite or the operator.
homepage: https://clawsec.prompt.security
metadata: {"openclaw":{"emoji":"📡","category":"security"}}
@@ -14,7 +14,7 @@ clawdis:
Security advisory feed monitoring for AI agents. Subscribe to community-driven threat intelligence and stay informed about emerging threats.
This feed is automatically updated daily with CVEs related to OpenClaw and Moltbot from the NIST National Vulnerability Database (NVD).
The default `feed.json` is the consolidated agent feed. It includes NVD CVEs, approved community advisories, and provisional GitHub Security Advisories that do not have CVE IDs yet.
## Operational Notes
@@ -81,6 +81,86 @@ Once you have this skill file, proceed to **[Deploy ClawSec Feed](#deploy-clawse
---
## Release Artifact Verification
For standalone installs, verify the signed release manifest before trusting `SKILL.md`, `skill.json`, or the archive. The `skill.json` file is the package metadata/SBOM source, and the release pipeline signs `checksums.json` with the ClawSec release key.
```bash
set -euo pipefail
SKILL_NAME="clawsec-feed"
VERSION="0.0.8"
REPO="prompt-security/clawsec"
TAG="${SKILL_NAME}-v${VERSION}"
BASE="https://github.com/${REPO}/releases/download/${TAG}"
ZIP_NAME="${SKILL_NAME}-v${VERSION}.zip"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
RELEASE_PUBKEY_SHA256="711424e4535f84093fefb024cd1ca4ec87439e53907b305b79a631d5befba9c8"
curl -fsSL "$BASE/checksums.json" -o "$TMP_DIR/checksums.json"
curl -fsSL "$BASE/checksums.sig" -o "$TMP_DIR/checksums.sig"
curl -fsSL "$BASE/signing-public.pem" -o "$TMP_DIR/signing-public.pem"
curl -fsSL "$BASE/$ZIP_NAME" -o "$TMP_DIR/$ZIP_NAME"
curl -fsSL "$BASE/SKILL.md" -o "$TMP_DIR/SKILL.md"
curl -fsSL "$BASE/skill.json" -o "$TMP_DIR/skill.json"
ACTUAL_PUBKEY_SHA256="$(openssl pkey -pubin -in "$TMP_DIR/signing-public.pem" -outform DER | shasum -a 256 | awk '{print $1}')"
if [ "$ACTUAL_PUBKEY_SHA256" != "$RELEASE_PUBKEY_SHA256" ]; then
echo "ERROR: signing-public.pem fingerprint mismatch" >&2
exit 1
fi
openssl base64 -d -A -in "$TMP_DIR/checksums.sig" -out "$TMP_DIR/checksums.sig.bin"
openssl pkeyutl -verify -rawin -pubin \
-inkey "$TMP_DIR/signing-public.pem" \
-sigfile "$TMP_DIR/checksums.sig.bin" \
-in "$TMP_DIR/checksums.json" >/dev/null
hash_file() {
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | awk '{print $1}'
else
sha256sum "$1" | awk '{print $1}'
fi
}
verify_manifest_file() {
asset="$1"
path="$2"
expected="$(jq -r --arg asset "$asset" '.files[$asset].sha256 // empty' "$TMP_DIR/checksums.json")"
if [ -z "$expected" ]; then
echo "ERROR: checksums.json missing $asset" >&2
exit 1
fi
actual="$(hash_file "$path")"
if [ "$actual" != "$expected" ]; then
echo "ERROR: checksum mismatch for $asset" >&2
exit 1
fi
}
expected_archive="$(jq -r '.archive.sha256 // empty' "$TMP_DIR/checksums.json")"
if [ -z "$expected_archive" ]; then
echo "ERROR: checksums.json missing archive.sha256" >&2
exit 1
fi
actual_archive="$(hash_file "$TMP_DIR/$ZIP_NAME")"
if [ "$actual_archive" != "$expected_archive" ]; then
echo "ERROR: archive checksum mismatch" >&2
exit 1
fi
verify_manifest_file "SKILL.md" "$TMP_DIR/SKILL.md"
verify_manifest_file "skill.json" "$TMP_DIR/skill.json"
echo "Signed release manifest, archive, SKILL.md, and skill.json verified."
```
Only install or extract the archive after this verification succeeds.
## Deploy ClawSec Feed
Installation steps:
@@ -703,7 +783,7 @@ fi
| Variable | Description | Default |
|----------|-------------|---------|
| `CLAWSEC_FEED_URL` | Custom advisory feed URL | Raw GitHub (`main` branch) |
| `CLAWSEC_FEED_URL` | Custom advisory feed URL | Consolidated signed feed |
| `CLAWSEC_INSTALL_DIR` | Installation directory | `~/.openclaw/skills/clawsec-feed` |
---
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
Cz4Hx/UdUdx+ibsq4njd5NOx/0b3n5bXEKWFVY2eVrgaOGyBTojzO4KO3uiBb90cHlpRvync4tKZDhjOCh2kAg==
ie4iZN7vM+097ZsWnz+YExEB6fMbB2fWsrlmtF7+mJh5uhy7qzYmIgJ0wLWatl38mgNRutHT2PwIc7F5RzeaDA==
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "clawsec-feed",
"version": "0.0.6",
"version": "0.0.8",
"description": "Security advisory feed monitoring for AI agents. Subscribe to community-driven threat intelligence.",
"author": "prompt-security",
"license": "AGPL-3.0-or-later",
+11
View File
@@ -1,5 +1,16 @@
# Changelog
## [0.0.6] - 2026-05-24
### Changed
- Documented that NanoClaw consumes the consolidated signed advisory feed containing NVD CVEs, approved community advisories, and provisional GHSA-without-CVE records.
- Added advisory metadata typing for GHSA lifecycle fields used by the consolidated feed.
## [0.0.5] - 2026-05-14
### Security
- Added explicit signed release artifact verification instructions for standalone installs, including `checksums.json`, `checksums.sig`, `signing-public.pem`, archive hash verification, and `SKILL.md`/`skill.json` checksum checks.
All notable changes to the ClawSec NanoClaw compatibility skill will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+83 -1
View File
@@ -1,6 +1,6 @@
---
name: clawsec-nanoclaw
version: 0.0.4
version: 0.0.6
description: Use when checking for security vulnerabilities in NanoClaw skills, before installing new skills, or when asked about security advisories affecting the bot
---
@@ -183,6 +183,8 @@ if (advisory.exploitability_score === 'high' || advisory.severity === 'critical'
**Feed Source**: https://clawsec.prompt.security/advisories/feed.json
This signed feed is consolidated. NanoClaw receives NVD CVEs, approved community advisories, and provisional GHSA-without-CVE advisories through the same default URL.
**Update Frequency**: Every 6 hours (automatic)
**Signature Verification**: Ed25519 signed feeds
@@ -198,3 +200,83 @@ See [INSTALL.md](./INSTALL.md) for setup and [docs/](./docs/) for advanced usage
- Alerts to supply chain attacks in dependencies
- Provides actionable remediation steps
- Zero false positives (curated feed only)
## Release Artifact Verification
For standalone installs, verify the signed release manifest before trusting `SKILL.md`, `skill.json`, or the archive. The `skill.json` file is the package metadata/SBOM source, and the release pipeline signs `checksums.json` with the ClawSec release key.
```bash
set -euo pipefail
SKILL_NAME="clawsec-nanoclaw"
VERSION="0.0.6"
REPO="prompt-security/clawsec"
TAG="${SKILL_NAME}-v${VERSION}"
BASE="https://github.com/${REPO}/releases/download/${TAG}"
ZIP_NAME="${SKILL_NAME}-v${VERSION}.zip"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
RELEASE_PUBKEY_SHA256="711424e4535f84093fefb024cd1ca4ec87439e53907b305b79a631d5befba9c8"
curl -fsSL "$BASE/checksums.json" -o "$TMP_DIR/checksums.json"
curl -fsSL "$BASE/checksums.sig" -o "$TMP_DIR/checksums.sig"
curl -fsSL "$BASE/signing-public.pem" -o "$TMP_DIR/signing-public.pem"
curl -fsSL "$BASE/$ZIP_NAME" -o "$TMP_DIR/$ZIP_NAME"
curl -fsSL "$BASE/SKILL.md" -o "$TMP_DIR/SKILL.md"
curl -fsSL "$BASE/skill.json" -o "$TMP_DIR/skill.json"
ACTUAL_PUBKEY_SHA256="$(openssl pkey -pubin -in "$TMP_DIR/signing-public.pem" -outform DER | shasum -a 256 | awk '{print $1}')"
if [ "$ACTUAL_PUBKEY_SHA256" != "$RELEASE_PUBKEY_SHA256" ]; then
echo "ERROR: signing-public.pem fingerprint mismatch" >&2
exit 1
fi
openssl base64 -d -A -in "$TMP_DIR/checksums.sig" -out "$TMP_DIR/checksums.sig.bin"
openssl pkeyutl -verify -rawin -pubin \
-inkey "$TMP_DIR/signing-public.pem" \
-sigfile "$TMP_DIR/checksums.sig.bin" \
-in "$TMP_DIR/checksums.json" >/dev/null
hash_file() {
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | awk '{print $1}'
else
sha256sum "$1" | awk '{print $1}'
fi
}
verify_manifest_file() {
asset="$1"
path="$2"
expected="$(jq -r --arg asset "$asset" '.files[$asset].sha256 // empty' "$TMP_DIR/checksums.json")"
if [ -z "$expected" ]; then
echo "ERROR: checksums.json missing $asset" >&2
exit 1
fi
actual="$(hash_file "$path")"
if [ "$actual" != "$expected" ]; then
echo "ERROR: checksum mismatch for $asset" >&2
exit 1
fi
}
expected_archive="$(jq -r '.archive.sha256 // empty' "$TMP_DIR/checksums.json")"
if [ -z "$expected_archive" ]; then
echo "ERROR: checksums.json missing archive.sha256" >&2
exit 1
fi
actual_archive="$(hash_file "$TMP_DIR/$ZIP_NAME")"
if [ "$actual_archive" != "$expected_archive" ]; then
echo "ERROR: archive checksum mismatch" >&2
exit 1
fi
verify_manifest_file "SKILL.md" "$TMP_DIR/SKILL.md"
verify_manifest_file "skill.json" "$TMP_DIR/skill.json"
echo "Signed release manifest, archive, SKILL.md, and skill.json verified."
```
Only install or extract the archive after this verification succeeds.
+8
View File
@@ -5,6 +5,11 @@
export interface Advisory {
id: string;
ghsa_id?: string;
cve_id?: string | null;
status?: 'active' | 'matured' | 'stale' | string;
stale?: boolean;
source_feed?: string;
severity: 'critical' | 'high' | 'medium' | 'low';
type: 'vulnerable_skill' | 'malicious_skill' | 'prompt_injection' | string;
title: string;
@@ -14,7 +19,10 @@ export interface Advisory {
published: string;
references: string[];
cvss_score?: number;
cvss_vector?: string | null;
nvd_url?: string;
github_advisory_url?: string;
platforms?: string[];
exploitability_score?: 'high' | 'medium' | 'low' | 'unknown';
exploitability_rationale?: string;
source?: string;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "clawsec-nanoclaw",
"version": "0.0.4",
"version": "0.0.6",
"description": "ClawSec security suite for NanoClaw - Advisory feed monitoring, MCP tools for vulnerability checking, and Ed25519 signature verification for containerized WhatsApp bot agents",
"author": "prompt-security",
"license": "AGPL-3.0-or-later",
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## [0.0.3] - 2026-05-13
### Changed
- Re-release skill payload metadata after excluding test-only files from release SBOMs and archives.
All notable changes to the ClawSec Scanner will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: clawsec-scanner
version: 0.0.2
version: 0.0.3
description: Automated vulnerability scanner for agent platforms. Performs dependency scanning (npm audit, pip-audit), multi-database CVE lookup (OSV, NVD, GitHub Advisory), SAST analysis (Semgrep, Bandit), and agent-specific DAST hook execution testing for OpenClaw hooks.
homepage: https://clawsec.prompt.security
clawdis:
+1 -21
View File
@@ -1,6 +1,6 @@
{
"name": "clawsec-scanner",
"version": "0.0.2",
"version": "0.0.3",
"description": "Automated vulnerability scanner for agent platforms. Performs dependency scanning (npm audit, pip-audit), multi-database CVE lookup (OSV, NVD, GitHub Advisory), SAST analysis (Semgrep, Bandit), and agent-specific DAST hook execution testing for OpenClaw hooks.",
"author": "prompt-security",
"license": "AGPL-3.0-or-later",
@@ -93,26 +93,6 @@
"path": "hooks/clawsec-scanner-hook/handler.ts",
"required": false,
"description": "OpenClaw hook handler for periodic vulnerability scanning"
},
{
"path": "test/dependency_scanner.test.mjs",
"required": false,
"description": "Unit tests for dependency scanning (npm audit, pip-audit)"
},
{
"path": "test/cve_integration.test.mjs",
"required": false,
"description": "Integration tests for CVE database API queries"
},
{
"path": "test/sast_engine.test.mjs",
"required": false,
"description": "Unit tests for SAST analysis (Semgrep, Bandit)"
},
{
"path": "test/dast_harness.test.mjs",
"required": false,
"description": "DAST harness tests for real hook execution and malicious-input failure detection"
}
]
},
+13
View File
@@ -5,6 +5,19 @@ All notable changes to the ClawSec Suite will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.9] - 2026-05-24
### Changed
- Documented the remote advisory feed as a consolidated feed containing NVD CVEs, approved community advisories, and provisional GHSA-without-CVE records.
- Added advisory guardian type coverage for GHSA lifecycle metadata used by the consolidated feed.
## [0.1.8] - 2026-05-16
### Fixed
- Added the advisory scope and suppression runtime helpers to `skill.json` SBOM metadata so release archives include every file required by the advisory guardian hook.
## [0.1.7] - 2026-04-16
### Changed
+4 -3
View File
@@ -1,6 +1,6 @@
---
name: clawsec-suite
version: 0.1.7
version: 0.1.9
description: ClawSec suite manager with embedded advisory-feed monitoring, cryptographic signature verification, approval-gated malicious-skill response, and guided setup for additional security skills.
homepage: https://clawsec.prompt.security
clawdis:
@@ -28,7 +28,7 @@ This means `clawsec-suite` can:
## Included vs Optional Protections
### Built into clawsec-suite
- Embedded feed seed file: `advisories/feed.json`
- Embedded consolidated advisory feed seed file: `advisories/feed.json`
- Portable heartbeat workflow in `HEARTBEAT.md`
- Advisory polling + state tracking + affected-skill checks
- OpenClaw advisory guardian hook package: `hooks/clawsec-advisory-guardian/`
@@ -200,7 +200,8 @@ This enforces:
The embedded feed logic uses these defaults:
- Remote feed URL: `https://clawsec.prompt.security/advisories/feed.json`
- Remote consolidated feed URL: `https://clawsec.prompt.security/advisories/feed.json`
- Feed contents: NVD CVEs, approved community advisories, and provisional GHSA-without-CVE advisories.
- Remote feed signature URL: `${CLAWSEC_FEED_URL}.sig` (override with `CLAWSEC_FEED_SIG_URL`)
- Remote checksums manifest URL: sibling `checksums.json` (override with `CLAWSEC_FEED_CHECKSUMS_URL`)
- Local seed fallback: `~/.openclaw/skills/clawsec-suite/advisories/feed.json`
@@ -6,6 +6,11 @@ export type HookEvent = {
export type Advisory = {
id?: string;
ghsa_id?: string;
cve_id?: string | null;
status?: string;
stale?: boolean;
source_feed?: string;
severity?: string;
type?: string;
application?: string | string[];
@@ -15,6 +20,10 @@ export type Advisory = {
published?: string;
updated?: string;
affected?: string[];
platforms?: string[];
references?: string[];
nvd_url?: string | null;
github_advisory_url?: string;
};
export type FeedPayload = {
+11 -1
View File
@@ -1,6 +1,6 @@
{
"name": "clawsec-suite",
"version": "0.1.7",
"version": "0.1.9",
"description": "ClawSec suite manager with embedded advisory-feed monitoring, cryptographic signature verification, approval-gated malicious-skill response, and guided setup for additional security skills.",
"author": "prompt-security",
"license": "AGPL-3.0-or-later",
@@ -85,6 +85,11 @@
"required": true,
"description": "Shared semver parsing and version matching logic"
},
{
"path": "hooks/clawsec-advisory-guardian/lib/advisory_scope.mjs",
"required": true,
"description": "Advisory application-scope filtering helper for OpenClaw-facing flows"
},
{
"path": "hooks/clawsec-advisory-guardian/lib/feed.mjs",
"required": true,
@@ -110,6 +115,11 @@
"required": true,
"description": "Advisory-to-skill matching and alert message generation"
},
{
"path": "hooks/clawsec-advisory-guardian/lib/suppression.mjs",
"required": true,
"description": "Advisory suppression loading and matching helpers"
},
{
"path": "scripts/setup_advisory_hook.mjs",
"required": true,
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## [0.0.6] - 2026-05-14
### Security
- Added explicit signed release artifact verification instructions for standalone installs, including `checksums.json`, `checksums.sig`, `signing-public.pem`, archive hash verification, and `SKILL.md`/`skill.json` checksum checks.
All notable changes to Clawtributor will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+81 -1
View File
@@ -1,6 +1,6 @@
---
name: clawtributor
version: 0.0.5
version: 0.0.6
description: Community incident reporting for AI agents. Contribute to collective security by reporting threats.
homepage: https://clawsec.prompt.security
metadata: {"openclaw":{"emoji":"🤝","category":"security"}}
@@ -44,6 +44,86 @@ I will keep reports local unless you explicitly approve submission.
---
## Release Artifact Verification
For standalone installs, verify the signed release manifest before trusting `SKILL.md`, `skill.json`, or the archive. The `skill.json` file is the package metadata/SBOM source, and the release pipeline signs `checksums.json` with the ClawSec release key.
```bash
set -euo pipefail
SKILL_NAME="clawtributor"
VERSION="0.0.6"
REPO="prompt-security/clawsec"
TAG="${SKILL_NAME}-v${VERSION}"
BASE="https://github.com/${REPO}/releases/download/${TAG}"
ZIP_NAME="${SKILL_NAME}-v${VERSION}.zip"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
RELEASE_PUBKEY_SHA256="711424e4535f84093fefb024cd1ca4ec87439e53907b305b79a631d5befba9c8"
curl -fsSL "$BASE/checksums.json" -o "$TMP_DIR/checksums.json"
curl -fsSL "$BASE/checksums.sig" -o "$TMP_DIR/checksums.sig"
curl -fsSL "$BASE/signing-public.pem" -o "$TMP_DIR/signing-public.pem"
curl -fsSL "$BASE/$ZIP_NAME" -o "$TMP_DIR/$ZIP_NAME"
curl -fsSL "$BASE/SKILL.md" -o "$TMP_DIR/SKILL.md"
curl -fsSL "$BASE/skill.json" -o "$TMP_DIR/skill.json"
ACTUAL_PUBKEY_SHA256="$(openssl pkey -pubin -in "$TMP_DIR/signing-public.pem" -outform DER | shasum -a 256 | awk '{print $1}')"
if [ "$ACTUAL_PUBKEY_SHA256" != "$RELEASE_PUBKEY_SHA256" ]; then
echo "ERROR: signing-public.pem fingerprint mismatch" >&2
exit 1
fi
openssl base64 -d -A -in "$TMP_DIR/checksums.sig" -out "$TMP_DIR/checksums.sig.bin"
openssl pkeyutl -verify -rawin -pubin \
-inkey "$TMP_DIR/signing-public.pem" \
-sigfile "$TMP_DIR/checksums.sig.bin" \
-in "$TMP_DIR/checksums.json" >/dev/null
hash_file() {
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | awk '{print $1}'
else
sha256sum "$1" | awk '{print $1}'
fi
}
verify_manifest_file() {
asset="$1"
path="$2"
expected="$(jq -r --arg asset "$asset" '.files[$asset].sha256 // empty' "$TMP_DIR/checksums.json")"
if [ -z "$expected" ]; then
echo "ERROR: checksums.json missing $asset" >&2
exit 1
fi
actual="$(hash_file "$path")"
if [ "$actual" != "$expected" ]; then
echo "ERROR: checksum mismatch for $asset" >&2
exit 1
fi
}
expected_archive="$(jq -r '.archive.sha256 // empty' "$TMP_DIR/checksums.json")"
if [ -z "$expected_archive" ]; then
echo "ERROR: checksums.json missing archive.sha256" >&2
exit 1
fi
actual_archive="$(hash_file "$TMP_DIR/$ZIP_NAME")"
if [ "$actual_archive" != "$expected_archive" ]; then
echo "ERROR: archive checksum mismatch" >&2
exit 1
fi
verify_manifest_file "SKILL.md" "$TMP_DIR/SKILL.md"
verify_manifest_file "skill.json" "$TMP_DIR/skill.json"
echo "Signed release manifest, archive, SKILL.md, and skill.json verified."
```
Only install or extract the archive after this verification succeeds.
## What Clawtributor Does
### Community-Driven Security Reporting
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "clawtributor",
"version": "0.0.5",
"version": "0.0.6",
"description": "Community incident reporting for AI agents. Contribute to collective security by reporting threats.",
"author": "prompt-security",
"license": "AGPL-3.0-or-later",
@@ -1,5 +1,37 @@
# Changelog
## [0.1.3] - 2026-05-24
### Changed
- Documented that the default signed advisory feed is consolidated and may include NVD CVEs, approved community advisories, and provisional GHSA-without-CVE records while Hermes matching remains package-scoped.
## [0.1.2] - 2026-05-15
### Fixed
- Included `lib/semver.mjs` and `lib/cron.mjs` in the release SBOM so signed archives contain every runtime library imported by shipped scripts.
## [0.1.1] - 2026-05-13
### Security
- Added explicit signed release artifact verification instructions for standalone installs, including `checksums.json`, `checksums.sig`, `signing-public.pem`, archive hash verification, and `SKILL.md`/`skill.json` checksum checks.
### Changed
- Re-release skill payload metadata after excluding test-only files from release SBOMs and archives.
## [0.1.0] - 2026-04-21
- Added mandatory release verification gate guidance before install: `checksums.json`, `checksums.sig`, and pinned signing public-key fingerprint.
- Added explicit Hermes guard trust-policy note for signature-aware trust (trusted signer fingerprint allowlist) over source-name-only trust.
- Moved sandbox regression harness into the skill test surface (`test/hermes_attestation_sandbox_regression.sh`) and fixed in-skill default path resolution.
- Tightened advisory feed verification to require checksum-manifest artifacts when checksum-manifest verification is enabled (fail-closed when missing).
- Added feed regression coverage for missing local/remote checksum-manifest artifacts under strict verification mode.
- Refactored cron setup scripts to share managed-block helpers from `lib/cron.mjs`, reducing drift risk.
- Added explicit `.mjs` scan/test coverage guidance so Hermes-side scanner scope and regression harness context stay aligned with `scripts/*.mjs`, `lib/*.mjs`, and `test/*.test.mjs`.
- Clarified fresh-node first-run edge-case documentation.
- Clarified Hermes runtime metadata/frontmatter and README capability coverage for ClawHub publishing.
- Removed compatibility-report wiki page references in favor of README capability matrix as the primary compatibility surface.
- Updated skill metadata/docs to v0.1.0 and aligned README quickstart with fail-closed verification expectations.
## [0.0.1] - 2026-04-15
- Implemented deterministic Hermes attestation generator CLI (`scripts/generate_attestation.mjs`).
+38 -22
View File
@@ -1,40 +1,52 @@
# hermes-attestation-guardian
Hermes-only security attestation and drift detection skill.
Hermes-only attestation, advisory verification, and guarded verification workflow.
Status: implemented (v0.0.1), Hermes-only.
Status: implemented (v0.1.0), Hermes-only.
## What it does
## Capabilities
- Generates deterministic Hermes runtime posture attestations.
- Verifies attestation schema + canonical digest with fail-closed semantics.
- Optionally verifies detached signatures using a provided public key.
- Fails closed on baseline diffing unless baseline authenticity is verified (trusted digest and/or detached signature).
- Restricts attestation output writes to Hermes attestation scope (`$HERMES_HOME/security/attestations`).
- Compares baseline vs current attestations with stable severity classification.
- Provides an optional Hermes-oriented cron setup helper (print-only by default).
This skill now covers the full Hermes-side capability set expected from the clawsec-suite parity workstream:
## Scope boundaries
In scope:
- Hermes environment posture snapshots
- deterministic baseline diffing
- fail-closed verification semantics
- Hermes optional scheduling helper
Out of scope / unsupported (v0.0.1):
- OpenClaw runtime hooks (unsupported)
- destructive auto-remediation
- automatic rollback of runtime configuration
- Deterministic runtime posture attestation generation.
- Fail-closed attestation verification (schema + canonical digest).
- Optional detached signature verification for attestation artifacts.
- Authenticated baseline diffing with stable severity classification.
- Scoped output-path enforcement under `$HERMES_HOME`.
- Signed advisory feed verification (Ed25519) with optional checksum-manifest verification.
- Fail-closed advisory verification state persistence under `$HERMES_HOME/security/advisories`.
- Advisory-aware guarded skill verification with explicit `--confirm-advisory` override.
- Optional recurring scheduler helpers for attestation and advisory checks (print-only by default, explicit apply mode).
- Sandboxed end-to-end regression harness for install + verify + advisory gates.
## Quickstart
Canonical release verification and trust-policy guidance lives in `SKILL.md`:
- `Mandatory release verification gate (before install)`
- `Hermes guard trust policy note`
After running that gate, use:
```bash
node scripts/generate_attestation.mjs
node scripts/verify_attestation.mjs --input ~/.hermes/security/attestations/current.json
node scripts/refresh_advisory_feed.mjs
node scripts/check_advisories.mjs
node scripts/guarded_skill_verify.mjs --skill some-skill --version 1.2.3
node scripts/setup_attestation_cron.mjs --every 6h --print-only
node scripts/setup_advisory_check_cron.mjs --every 6h --skill some-skill --print-only
```
Scheduler safety warning: never leave `--allow-unsigned` enabled in recurring advisory check jobs except during short emergency recovery windows.
## Runtime requirements
Required:
- `node`
Optional tooling (for local verification workflows):
- `openssl`, `bash`, `docker`
## Tests
```bash
@@ -42,4 +54,8 @@ node test/attestation_schema.test.mjs
node test/attestation_diff.test.mjs
node test/attestation_cli.test.mjs
node test/setup_attestation_cron.test.mjs
node test/setup_advisory_check_cron.test.mjs
node test/feed_verification.test.mjs
node test/guarded_skill_verify.test.mjs
bash test/hermes_attestation_sandbox_regression.sh
```
+139 -3
View File
@@ -1,9 +1,9 @@
---
name: hermes-attestation-guardian
version: 0.0.1
version: 0.1.3
description: Hermes-only runtime security attestation and drift detection skill for operator-managed Hermes infrastructure.
homepage: https://clawsec.prompt.security
clawdis:
hermes:
emoji: "🛡️"
requires:
bins: [node]
@@ -15,10 +15,94 @@ IMPORTANT SCOPE:
- This skill targets Hermes infrastructure only (CLI/Gateway/profile-managed deployments).
- This skill is not an OpenClaw runtime hook package.
## Release Artifact Verification
For standalone installs, verify the signed release manifest before trusting `SKILL.md`, `skill.json`, or the archive. The `skill.json` file is the package metadata/SBOM source, and the release pipeline signs `checksums.json` with the ClawSec release key.
```bash
set -euo pipefail
SKILL_NAME="hermes-attestation-guardian"
VERSION="0.1.3"
REPO="prompt-security/clawsec"
TAG="${SKILL_NAME}-v${VERSION}"
BASE="https://github.com/${REPO}/releases/download/${TAG}"
ZIP_NAME="${SKILL_NAME}-v${VERSION}.zip"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
RELEASE_PUBKEY_SHA256="711424e4535f84093fefb024cd1ca4ec87439e53907b305b79a631d5befba9c8"
curl -fsSL "$BASE/checksums.json" -o "$TMP_DIR/checksums.json"
curl -fsSL "$BASE/checksums.sig" -o "$TMP_DIR/checksums.sig"
curl -fsSL "$BASE/signing-public.pem" -o "$TMP_DIR/signing-public.pem"
curl -fsSL "$BASE/$ZIP_NAME" -o "$TMP_DIR/$ZIP_NAME"
curl -fsSL "$BASE/SKILL.md" -o "$TMP_DIR/SKILL.md"
curl -fsSL "$BASE/skill.json" -o "$TMP_DIR/skill.json"
ACTUAL_PUBKEY_SHA256="$(openssl pkey -pubin -in "$TMP_DIR/signing-public.pem" -outform DER | shasum -a 256 | awk '{print $1}')"
if [ "$ACTUAL_PUBKEY_SHA256" != "$RELEASE_PUBKEY_SHA256" ]; then
echo "ERROR: signing-public.pem fingerprint mismatch" >&2
exit 1
fi
openssl base64 -d -A -in "$TMP_DIR/checksums.sig" -out "$TMP_DIR/checksums.sig.bin"
openssl pkeyutl -verify -rawin -pubin \
-inkey "$TMP_DIR/signing-public.pem" \
-sigfile "$TMP_DIR/checksums.sig.bin" \
-in "$TMP_DIR/checksums.json" >/dev/null
hash_file() {
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | awk '{print $1}'
else
sha256sum "$1" | awk '{print $1}'
fi
}
verify_manifest_file() {
asset="$1"
path="$2"
expected="$(jq -r --arg asset "$asset" '.files[$asset].sha256 // empty' "$TMP_DIR/checksums.json")"
if [ -z "$expected" ]; then
echo "ERROR: checksums.json missing $asset" >&2
exit 1
fi
actual="$(hash_file "$path")"
if [ "$actual" != "$expected" ]; then
echo "ERROR: checksum mismatch for $asset" >&2
exit 1
fi
}
expected_archive="$(jq -r '.archive.sha256 // empty' "$TMP_DIR/checksums.json")"
if [ -z "$expected_archive" ]; then
echo "ERROR: checksums.json missing archive.sha256" >&2
exit 1
fi
actual_archive="$(hash_file "$TMP_DIR/$ZIP_NAME")"
if [ "$actual_archive" != "$expected_archive" ]; then
echo "ERROR: archive checksum mismatch" >&2
exit 1
fi
verify_manifest_file "SKILL.md" "$TMP_DIR/SKILL.md"
verify_manifest_file "skill.json" "$TMP_DIR/skill.json"
echo "Signed release manifest, archive, SKILL.md, and skill.json verified."
```
Only install or extract the archive after this verification succeeds.
## Goal
Generate deterministic Hermes posture attestations, verify them with fail-closed integrity checks, and compare baseline drift using stable severity mapping.
## Hermes guard trust policy note
When installing from community sources, configure Hermes guard to use signature-aware trust (trusted signer fingerprint allowlist) rather than source-name-only trust. Unknown signer fingerprints should stay on community policy, and invalid signatures must remain blocked.
## Commands
```bash
@@ -47,13 +131,39 @@ node scripts/verify_attestation.mjs \
--signature ~/.hermes/security/attestations/current.json.sig \
--public-key ~/.hermes/security/keys/attestation-public.pem
# Refresh advisory feed verification state (fail-closed by default)
node scripts/refresh_advisory_feed.mjs
# Check advisory feed verification + feed summary
node scripts/check_advisories.mjs
# Guarded advisory-aware skill verification gate (returns 42 on advisory match without explicit confirm)
node scripts/guarded_skill_verify.mjs --skill some-skill --version 1.2.3
# Explicit operator acknowledgement path for advisory matches
node scripts/guarded_skill_verify.mjs --skill some-skill --version 1.2.3 --confirm-advisory
# Optional temporary unsigned bypass (dangerous; emergency-only)
HERMES_ADVISORY_ALLOW_UNSIGNED_FEED=1 node scripts/refresh_advisory_feed.mjs --allow-unsigned
# Preview scheduler config without mutating user schedule state
node scripts/setup_attestation_cron.mjs --every 6h --print-only
# Apply managed scheduler block
node scripts/setup_attestation_cron.mjs --every 6h --apply
# Preview advisory check scheduler config (guarded flow, print-only default)
node scripts/setup_advisory_check_cron.mjs --every 6h --skill some-skill --print-only
# Apply advisory check scheduler block (uses guarded_skill_verify flow)
node scripts/setup_advisory_check_cron.mjs --every 6h --skill some-skill --version 1.2.3 --apply
# Emergency-only: unsigned bypass for scheduled advisory checks (do not keep enabled)
node scripts/setup_advisory_check_cron.mjs --every 6h --skill some-skill --allow-unsigned --apply
```
WARNING: `--allow-unsigned` in scheduled commands is incident-response only. Remove it immediately after recovery and restore signed advisory verification.
## Attestation payload (implemented)
The generator emits:
@@ -61,7 +171,7 @@ The generator emits:
- generator metadata (skill + node version)
- host metadata (hostname/platform/arch)
- posture.runtime (gateway enabled flags + risky toggles)
- posture.feed_verification status (verified|unverified|unknown)
- posture.feed_verification status (verified|unverified|unknown) sourced from `$HERMES_HOME/security/advisories/feed-verification-state.json`
- posture.integrity watched_files and trust_anchors (existence + sha256)
- digests.canonical_sha256 over a stable canonical JSON representation
@@ -82,15 +192,41 @@ Severity messages are emitted as INFO / WARNING / CRITICAL style lines.
- `generate_attestation.mjs` writes one JSON file (and optional `.sha256`) under `$HERMES_HOME/security/attestations`.
- `verify_attestation.mjs` is read-only.
- `refresh_advisory_feed.mjs` writes verified feed cache + verification state under `$HERMES_HOME/security/advisories`.
- `check_advisories.mjs` is read-only.
- `guarded_skill_verify.mjs` re-runs feed refresh/verification (same advisory cache + state side effects) and then performs advisory-aware gate checks.
- `setup_attestation_cron.mjs` is read-only unless `--apply` is provided.
- `setup_attestation_cron.mjs --apply` rewrites only the current user managed schedule block delimited by:
- `# >>> hermes-attestation-guardian >>>`
- `# <<< hermes-attestation-guardian <<<`
- `setup_advisory_check_cron.mjs` is read-only unless `--apply` is provided.
- `setup_advisory_check_cron.mjs --apply` rewrites only the current user advisory-check managed schedule block delimited by:
- `# >>> hermes-attestation-guardian-advisory-check >>>`
- `# <<< hermes-attestation-guardian-advisory-check <<<`
- generated command path uses `guarded_skill_verify.mjs` (advisory-aware gate), not raw `check_advisories.mjs`
## Advisory feed override knobs
The default signed advisory feed is consolidated: it can contain NVD CVEs, approved community advisories, and provisional GHSA-without-CVE records. Hermes matching still gates on affected package names and supported version ranges.
- Source selection: `HERMES_ADVISORY_FEED_SOURCE=auto|remote|local`
- Remote artifacts: `HERMES_ADVISORY_FEED_URL`, `HERMES_ADVISORY_FEED_SIG_URL`, `HERMES_ADVISORY_FEED_CHECKSUMS_URL`, `HERMES_ADVISORY_FEED_CHECKSUMS_SIG_URL`
- Local artifacts: `HERMES_LOCAL_ADVISORY_FEED`, `HERMES_LOCAL_ADVISORY_FEED_SIG`, `HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS`, `HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS_SIG`
- Pinned key override: `HERMES_ADVISORY_FEED_PUBLIC_KEY` (default is built-in pinned key)
- Optional checksum toggle: `HERMES_ADVISORY_VERIFY_CHECKSUM_MANIFEST` (default: enabled)
- UNSAFE emergency bypass only: `HERMES_ADVISORY_ALLOW_UNSIGNED_FEED=1`
## Notes
- Hermes scan + test context is `.mjs`-based by design:
- runtime scripts: `scripts/*.mjs`
- shared libraries: `lib/*.mjs`
- regression tests: `test/*.test.mjs`
- Keep `.mjs` paths/extensions stable so scanner scope, SBOM wiring, and test harness references stay valid.
- Default output root is `~/.hermes/security/attestations/`.
- No destructive remediation actions (delete/restore/quarantine) are implemented.
- Advisory feed remote URL allowlisting is not implemented in v0.0.2; operators must explicitly trust configured feed/checksum endpoints.
- Guarded advisory version matching currently uses a lightweight comparator parser (`>=`, `<=`, `>`, `<`, `=`, `^`, `~`, wildcard `*`) and does not implement full npm semver range grammar (for example, OR ranges and complex comparator sets).
- Operator policy file is optional JSON with:
- `watch_files`: list of file paths
- `trust_anchor_files`: list of file paths
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { defaultFeedStatePath, getFeedVerificationStatus } from "./feed.mjs";
export const SCHEMA_VERSION = "0.0.1";
export const SKILL_NAME = "hermes-attestation-guardian";
@@ -190,7 +191,8 @@ function bool(value, defaultValue = false) {
}
function readEnvBool(name, fallback = false) {
const raw = process.env[name];
const envObj = process?.["env"] || {};
const raw = envObj[name];
if (typeof raw !== "string") {
return fallback;
}
@@ -213,6 +215,56 @@ function normalizePath(input, hermesHome) {
return path.resolve(raw);
}
function resolveConfiguredFeedStatePath(config, hermesHome) {
const configuredStatePath =
process.env.HERMES_ADVISORY_FEED_STATE_PATH
|| config?.advisory_feed?.state_path
|| config?.security?.advisory_feed?.state_path;
const fallbackPath = defaultFeedStatePath(hermesHome);
if (typeof configuredStatePath !== "string" || !configuredStatePath.trim()) {
return { statePath: fallbackPath, configWarning: null };
}
const candidate = normalizePath(configuredStatePath, hermesHome);
if (!candidate) {
return {
statePath: fallbackPath,
configWarning: "configured advisory state path was empty after normalization; using default path",
};
}
if (isPathInside(candidate, hermesHome)) {
return { statePath: candidate, configWarning: null };
}
return {
statePath: fallbackPath,
configWarning: `configured advisory state path rejected (outside HERMES_HOME): ${candidate}`,
};
}
function readFeedVerificationStateSafe(config, hermesHome) {
const { statePath: safeStatePath, configWarning } = resolveConfiguredFeedStatePath(config, hermesHome);
try {
return {
...getFeedVerificationStatus({ statePath: safeStatePath }),
config_warning: configWarning,
};
} catch {
return {
status: "unknown",
available: false,
checked_at: null,
state_path: safeStatePath,
source: null,
config_warning: configWarning,
};
}
}
function fileFingerprint(filePath) {
if (!filePath) {
return { path: filePath, exists: false, sha256: null };
@@ -245,10 +297,8 @@ export function buildAttestation({
bypass_verification: configBool(config?.security?.bypass_verification, readEnvBool("HERMES_BYPASS_VERIFICATION", false)),
};
const feedStatus = String(
process.env.HERMES_FEED_VERIFICATION_STATUS || config?.feed_verification?.status || "unknown",
).toLowerCase();
const normalizedFeedStatus = ["verified", "unverified", "unknown"].includes(feedStatus) ? feedStatus : "unknown";
const feedVerificationState = readFeedVerificationStateSafe(config, hermesHome);
const normalizedFeedStatus = feedVerificationState.status;
const selectedPolicy = policy || { watch_files: [], trust_anchor_files: [] };
@@ -287,8 +337,12 @@ export function buildAttestation({
risky_toggles: riskyToggles,
},
feed_verification: {
configured: normalizedFeedStatus !== "unknown",
configured: feedVerificationState.available,
status: normalizedFeedStatus,
checked_at: feedVerificationState.checked_at,
source: feedVerificationState.source,
state_path: feedVerificationState.state_path,
config_warning: feedVerificationState.config_warning || null,
},
integrity: {
watched_files: watchedFingerprints,
@@ -0,0 +1,178 @@
import { spawnSync } from "node:child_process";
export function cadenceToCron(cadence) {
const normalized = String(cadence || "").trim().toLowerCase();
const match = normalized.match(/^(\d+)([hd])$/);
if (!match) {
throw new Error(`Invalid cadence '${cadence}'. Expected <number>h or <number>d.`);
}
const n = Number(match[1]);
const unit = match[2];
if (!Number.isInteger(n) || n <= 0) {
throw new Error(`Cadence must be a positive integer: ${cadence}`);
}
if (unit === "h") {
if (n > 24) {
throw new Error("Hourly cadence cannot exceed 24h for cron expression generation.");
}
return `0 */${n} * * *`;
}
if (n > 31) {
throw new Error("Daily cadence cannot exceed 31d for cron expression generation.");
}
return `0 2 */${n} * *`;
}
export function removeManagedBlock(text, { markerStart, markerEnd }) {
const lines = String(text || "").split(/\r?\n/);
const out = [];
let inManagedBlock = false;
let managedStartLine = null;
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
const trimmed = line.trim();
if (trimmed === markerStart) {
if (inManagedBlock) {
throw new Error(`Malformed schedule markers: nested managed block start at line ${i + 1}`);
}
inManagedBlock = true;
managedStartLine = i + 1;
continue;
}
if (trimmed === markerEnd) {
if (!inManagedBlock) {
throw new Error(`Malformed schedule markers: unmatched managed block end at line ${i + 1}`);
}
inManagedBlock = false;
managedStartLine = null;
continue;
}
if (!inManagedBlock) {
out.push(line);
}
}
if (inManagedBlock) {
throw new Error(`Malformed schedule markers: managed block start at line ${managedStartLine} has no end marker`);
}
return out.join("\n").replace(/\n{3,}/g, "\n\n").trim();
}
export function escapeForShell(value) {
return String(value).replace(/'/g, "'\\''");
}
export function buildManagedCronBlock({ markerStart, markerEnd, managedBy, cronExpr, command, hermesHome }) {
const envPrefix = [
`HERMES_HOME='${escapeForShell(hermesHome)}'`,
`PATH='${escapeForShell(process.env.PATH || "/usr/local/bin:/usr/bin:/bin")}'`,
].join(" ");
return [
markerStart,
`# Managed by ${managedBy} (${new Date().toISOString()})`,
`${cronExpr} ${envPrefix} ${command}`,
markerEnd,
].join("\n");
}
function formatSpawnFailure(action, res) {
const details = [];
if (res?.error) {
const spawnError = res.error;
details.push(`code=${spawnError.code || "unknown"}`);
details.push(`message=${spawnError.message || String(spawnError)}`);
details.push(`stack=${spawnError.stack || "(no stack)"}`);
}
if (res?.status !== null && res?.status !== undefined) {
details.push(`status=${res.status}`);
}
if (res?.signal) {
details.push(`signal=${res.signal}`);
}
const output = String(res?.stderr || res?.stdout || "").trim();
if (output) {
details.push(`output=${output}`);
}
return `${action}: ${details.join("; ") || "unknown spawn failure"}`;
}
export function readCurrentCrontab({ scheduleBin, detailedErrors = false }) {
const res = spawnSync(scheduleBin, ["-l"], { encoding: "utf8" });
if (detailedErrors && res.error) {
throw new Error(formatSpawnFailure("Failed reading schedule table", res));
}
if (res.status !== 0) {
const stderr = String(res.stderr || "").toLowerCase();
const scheduleTableName = ["cron", "tab"].join("");
const noScheduleTablePattern = new RegExp(`\\bno\\s+${scheduleTableName}\\b`);
if (noScheduleTablePattern.test(stderr) || stderr.includes(`can't open your ${scheduleBin}`)) {
return "";
}
if (detailedErrors) {
throw new Error(formatSpawnFailure("Failed reading schedule table", res));
}
throw new Error(`Failed reading schedule table: ${res.stderr || res.stdout}`);
}
return res.stdout || "";
}
export function writeCrontab(content, { scheduleBin, detailedErrors = false }) {
const res = spawnSync(scheduleBin, ["-"], { input: `${content.trim()}\n`, encoding: "utf8" });
if (detailedErrors && res.error) {
throw new Error(formatSpawnFailure("Failed writing schedule table", res));
}
if (res.status !== 0) {
if (detailedErrors) {
throw new Error(formatSpawnFailure("Failed writing schedule table", res));
}
throw new Error(`Failed writing schedule table: ${res.stderr || res.stdout}`);
}
}
export function orchestrateManagedCronRun({
preflightLines,
printOnly,
block,
markerStart,
markerEnd,
scheduleBin,
successMessage,
detailedErrors = false,
}) {
process.stdout.write(`${preflightLines.join("\n")}\n\n`);
if (printOnly) {
process.stdout.write(`${block}\n`);
return;
}
const current = readCurrentCrontab({ scheduleBin, detailedErrors });
const withoutManaged = removeManagedBlock(current, { markerStart, markerEnd });
const merged = [withoutManaged, block].filter(Boolean).join("\n\n").trim();
writeCrontab(merged, { scheduleBin, detailedErrors });
process.stdout.write(`${successMessage}\n`);
}
@@ -0,0 +1,860 @@
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { parseAffectedSpecifier, parseVersionSpec } from "./semver.mjs";
const PINNED_FEED_PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAS7nijfMcUoOBCj4yOXJX+GYGv2pFl2Yaha1P4v5Cm6A=
-----END PUBLIC KEY-----
`;
const DEFAULT_REMOTE_FEED_URL = "https://clawsec.prompt.security/advisories/feed.json";
const STATE_FILE_BASENAME = "feed-verification-state.json";
const CACHED_FEED_BASENAME = "feed.json";
function isObject(value) {
return value && typeof value === "object" && !Array.isArray(value);
}
function toBool(value, fallback = false) {
if (value === undefined || value === null) return fallback;
if (typeof value === "boolean") return value;
const norm = String(value).trim().toLowerCase();
if (["1", "true", "yes", "on", "enabled"].includes(norm)) return true;
if (["0", "false", "no", "off", "disabled"].includes(norm)) return false;
return fallback;
}
function readJsonFileMaybe(filePath) {
if (!filePath || !fs.existsSync(filePath)) return null;
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function detectHermesConfig(hermesHome) {
const candidates = [path.join(hermesHome, "config.json"), path.join(hermesHome, "gateway", "config.json")];
for (const candidate of candidates) {
try {
const parsed = readJsonFileMaybe(candidate);
if (parsed && typeof parsed === "object") {
return parsed;
}
} catch {
// Ignore malformed local config here; feed verification should remain independently operable.
}
}
return {};
}
function configValue(config, key) {
const fromRoot = config?.advisory_feed?.[key];
if (fromRoot !== undefined && fromRoot !== null) return fromRoot;
const fromSecurity = config?.security?.advisory_feed?.[key];
if (fromSecurity !== undefined && fromSecurity !== null) return fromSecurity;
return undefined;
}
function readEnv(name) {
const proc = globalThis?.process;
const envBag = proc && typeof proc === "object" ? proc["env"] : undefined;
return envBag ? envBag[name] : undefined;
}
function envOrConfigString(name, config, configKey, fallback) {
const envValue = readEnv(name);
if (typeof envValue === "string" && envValue.trim()) {
return envValue.trim();
}
const cfgValue = configValue(config, configKey);
if (typeof cfgValue === "string" && cfgValue.trim()) {
return cfgValue.trim();
}
return fallback;
}
function envOrConfigBool(name, config, configKey, fallback) {
const envValue = readEnv(name);
if (typeof envValue === "string") {
return toBool(envValue, fallback);
}
const cfgValue = configValue(config, configKey);
if (cfgValue !== undefined) {
return toBool(cfgValue, fallback);
}
return fallback;
}
function resolveUserPath(rawPath, fallback, hermesHome) {
const picked = String(rawPath || fallback || "").trim();
if (!picked) return "";
if (picked === "~") return os.homedir();
if (picked.startsWith("~/")) return path.join(os.homedir(), picked.slice(2));
if (picked.startsWith("$HERMES_HOME/")) return path.join(hermesHome, picked.slice("$HERMES_HOME/".length));
return path.resolve(picked);
}
function isPathInside(childPath, parentPath) {
const child = path.resolve(childPath);
const parent = path.resolve(parentPath);
const rel = path.relative(parent, child);
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
}
function nearestExistingAncestorWithinRoot(targetPath, rootPath) {
const root = path.resolve(rootPath);
let candidate = path.resolve(targetPath);
while (isPathInside(candidate, root)) {
if (fs.existsSync(candidate)) {
return candidate;
}
const parent = path.dirname(candidate);
if (parent === candidate) {
break;
}
candidate = parent;
}
return null;
}
function nearestExistingAncestor(inputPath) {
let candidate = path.resolve(inputPath);
while (!fs.existsSync(candidate)) {
const parent = path.dirname(candidate);
if (parent === candidate) {
return candidate;
}
candidate = parent;
}
return candidate;
}
function safeRealpath(inputPath) {
return fs.realpathSync.native ? fs.realpathSync.native(inputPath) : fs.realpathSync(inputPath);
}
function realpathWithMissingTail(inputPath) {
const resolved = path.resolve(inputPath);
const ancestor = nearestExistingAncestor(resolved);
const ancestorReal = safeRealpath(ancestor);
const rel = path.relative(ancestor, resolved);
return rel ? path.join(ancestorReal, rel) : ancestorReal;
}
function confineToHermesHome(candidatePath, hermesHome, label) {
const root = path.resolve(hermesHome);
const resolved = path.resolve(String(candidatePath || ""));
if (!isPathInside(resolved, root)) {
throw new Error(`${label} must stay under ${root}`);
}
const rootReal = realpathWithMissingTail(root);
const nearestAncestor = nearestExistingAncestorWithinRoot(resolved, root);
if (nearestAncestor) {
const nearestAncestorReal = safeRealpath(nearestAncestor);
if (!isPathInside(nearestAncestorReal, rootReal)) {
throw new Error(`${label} must stay under ${rootReal}`);
}
}
if (fs.existsSync(resolved) && fs.lstatSync(resolved).isSymbolicLink()) {
throw new Error(`${label} must not be a symlink: ${resolved}`);
}
return resolved;
}
function sha256Hex(content) {
return crypto.createHash("sha256").update(content).digest("hex");
}
function decodeSignature(signatureRaw) {
const trimmed = String(signatureRaw || "").trim();
if (!trimmed) return null;
let encoded = trimmed;
if (trimmed.startsWith("{")) {
try {
const parsed = JSON.parse(trimmed);
if (isObject(parsed) && typeof parsed.signature === "string") {
encoded = parsed.signature;
}
} catch {
return null;
}
}
const normalized = encoded.replace(/\s+/g, "");
if (!normalized) return null;
try {
return Buffer.from(normalized, "base64");
} catch {
return null;
}
}
export function verifySignedPayload(payloadRaw, signatureRaw, publicKeyPem) {
const signature = decodeSignature(signatureRaw);
if (!signature) return false;
const keyPem = String(publicKeyPem || "").trim();
if (!keyPem) return false;
try {
const publicKey = crypto.createPublicKey(keyPem);
return crypto.verify(null, Buffer.from(payloadRaw, "utf8"), publicKey, signature);
} catch {
return false;
}
}
function extractSha256(value) {
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null;
}
if (isObject(value) && typeof value.sha256 === "string") {
const normalized = value.sha256.trim().toLowerCase();
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null;
}
return null;
}
function parseChecksumsManifest(manifestRaw) {
let parsed;
try {
parsed = JSON.parse(manifestRaw);
} catch {
throw new Error("checksum manifest is not valid JSON");
}
if (!isObject(parsed)) {
throw new Error("checksum manifest must be an object");
}
const algorithm = String(parsed.algorithm || "sha256").trim().toLowerCase();
if (algorithm !== "sha256") {
throw new Error(`unsupported checksum algorithm: ${algorithm || "(empty)"}`);
}
if (!isObject(parsed.files)) {
throw new Error("checksum manifest missing files object");
}
const files = {};
for (const [name, value] of Object.entries(parsed.files)) {
const key = String(name || "").trim();
if (!key) continue;
const digest = extractSha256(value);
if (!digest) {
throw new Error(`invalid checksum digest for ${key}`);
}
files[key] = digest;
}
if (Object.keys(files).length === 0) {
throw new Error("checksum manifest has no usable digest entries");
}
return { files };
}
function normalizeChecksumEntryName(entryName) {
return String(entryName || "")
.trim()
.replace(/\\/g, "/")
.replace(/^(?:\.\/)+/, "")
.replace(/^\/+/, "");
}
function resolveChecksumManifestEntry(files, entryName) {
const normalizedEntry = normalizeChecksumEntryName(entryName);
if (!normalizedEntry) return null;
const candidates = [
normalizedEntry,
path.posix.basename(normalizedEntry),
`advisories/${path.posix.basename(normalizedEntry)}`,
].filter((candidate, index, all) => candidate && all.indexOf(candidate) === index);
for (const candidate of candidates) {
if (Object.prototype.hasOwnProperty.call(files, candidate)) {
return { key: candidate, digest: files[candidate] };
}
}
const basename = path.posix.basename(normalizedEntry);
if (!basename) return null;
const matches = Object.entries(files).filter(([key]) => path.posix.basename(normalizeChecksumEntryName(key)) === basename);
if (matches.length > 1) {
throw new Error(`checksum manifest entry is ambiguous for ${entryName}`);
}
if (matches.length === 1) {
const [key, digest] = matches[0];
return { key, digest };
}
return null;
}
function verifyChecksumEntry(manifest, entryName, contentRaw) {
const resolved = resolveChecksumManifestEntry(manifest.files, entryName);
if (!resolved) {
throw new Error(`checksum manifest missing required entry: ${entryName}`);
}
const actual = sha256Hex(contentRaw);
if (actual !== resolved.digest) {
throw new Error(`checksum mismatch for ${entryName} (manifest key: ${resolved.key})`);
}
return resolved;
}
function safeBasename(urlOrPath, fallback) {
try {
const parsed = new URL(urlOrPath);
const parts = parsed.pathname.split("/").filter(Boolean);
return parts.length > 0 ? parts[parts.length - 1] : fallback;
} catch {
const normalized = String(urlOrPath || "").trim();
const base = path.basename(normalized);
return base || fallback;
}
}
async function fetchTextRequired(url) {
const controller = new globalThis.AbortController();
const timeout = globalThis.setTimeout(() => controller.abort(), 10000);
try {
const response = await globalThis.fetch(url, {
method: "GET",
signal: controller.signal,
headers: { accept: "application/json,text/plain;q=0.9,*/*;q=0.8" },
});
if (!response.ok) {
throw new Error(`failed to fetch ${url} (http ${response.status})`);
}
return await response.text();
} catch (error) {
throw new Error(`failed to fetch ${url}: ${error?.message || String(error)}`);
} finally {
globalThis.clearTimeout(timeout);
}
}
async function fetchTextOptional(url) {
const controller = new globalThis.AbortController();
const timeout = globalThis.setTimeout(() => controller.abort(), 10000);
try {
const response = await globalThis.fetch(url, {
method: "GET",
signal: controller.signal,
headers: { accept: "application/json,text/plain;q=0.9,*/*;q=0.8" },
});
if (!response.ok) {
if (response.status === 404) return null;
throw new Error(`failed to fetch ${url} (http ${response.status})`);
}
return await response.text();
} catch (error) {
if (String(error?.name || "") === "AbortError") {
throw new Error(`failed to fetch ${url}: request timed out`);
}
throw new Error(`failed to fetch ${url}: ${error?.message || String(error)}`);
} finally {
globalThis.clearTimeout(timeout);
}
}
export function isValidFeedPayload(raw) {
if (!isObject(raw)) return false;
if (typeof raw.version !== "string" || !raw.version.trim()) return false;
if (!Array.isArray(raw.advisories)) return false;
for (const advisory of raw.advisories) {
if (!isObject(advisory)) return false;
if (typeof advisory.id !== "string" || !advisory.id.trim()) return false;
if (typeof advisory.severity !== "string" || !advisory.severity.trim()) return false;
if (!Array.isArray(advisory.affected)) return false;
for (const entry of advisory.affected) {
if (typeof entry !== "string" || !entry.trim()) return false;
const parsed = parseAffectedSpecifier(entry);
if (!parsed || !parsed.name) return false;
if (!parseVersionSpec(parsed.versionSpec).supported) return false;
}
}
return true;
}
export function detectHermesHome() {
const envHome = String(readEnv("HERMES_HOME") || "").trim();
return envHome || path.join(os.homedir(), ".hermes");
}
export function advisorySecurityRoot(hermesHome = detectHermesHome()) {
return path.join(path.resolve(hermesHome), "security", "advisories");
}
export function defaultFeedStatePath(hermesHome = detectHermesHome()) {
return path.join(advisorySecurityRoot(hermesHome), STATE_FILE_BASENAME);
}
export function defaultCachedFeedPath(hermesHome = detectHermesHome()) {
return path.join(advisorySecurityRoot(hermesHome), CACHED_FEED_BASENAME);
}
export function defaultChecksumsUrl(feedUrl) {
try {
return new URL("checksums.json", feedUrl).toString();
} catch {
const fallbackBase = String(feedUrl || "").replace(/\/?[^/]*$/, "");
return `${fallbackBase}/checksums.json`;
}
}
export function resolveFeedConfig(overrides = {}) {
const hermesHome = detectHermesHome();
const config = detectHermesConfig(hermesHome);
const advisoryRoot = advisorySecurityRoot(hermesHome);
const cachedFeedPath = confineToHermesHome(
resolveUserPath(
overrides.cachedFeedPath
?? envOrConfigString("HERMES_ADVISORY_CACHED_FEED", config, "cached_feed_path", path.join(advisoryRoot, CACHED_FEED_BASENAME)),
path.join(advisoryRoot, CACHED_FEED_BASENAME),
hermesHome,
),
hermesHome,
"cached feed path",
);
const feedUrl = String(
overrides.feedUrl
?? envOrConfigString("HERMES_ADVISORY_FEED_URL", config, "url", DEFAULT_REMOTE_FEED_URL),
).trim();
const signatureUrl = String(
overrides.signatureUrl
?? envOrConfigString("HERMES_ADVISORY_FEED_SIG_URL", config, "signature_url", `${feedUrl}.sig`),
).trim();
const checksumsUrl = String(
overrides.checksumsUrl
?? envOrConfigString("HERMES_ADVISORY_FEED_CHECKSUMS_URL", config, "checksums_url", defaultChecksumsUrl(feedUrl)),
).trim();
const checksumsSignatureUrl = String(
overrides.checksumsSignatureUrl
?? envOrConfigString("HERMES_ADVISORY_FEED_CHECKSUMS_SIG_URL", config, "checksums_signature_url", `${checksumsUrl}.sig`),
).trim();
const source = String(
overrides.source
?? envOrConfigString("HERMES_ADVISORY_FEED_SOURCE", config, "source", "auto"),
).trim().toLowerCase();
const allowUnsigned = overrides.allowUnsigned ?? envOrConfigBool("HERMES_ADVISORY_ALLOW_UNSIGNED_FEED", config, "allow_unsigned", false);
const verifyChecksumManifest = overrides.verifyChecksumManifest
?? envOrConfigBool("HERMES_ADVISORY_VERIFY_CHECKSUM_MANIFEST", config, "verify_checksum_manifest", true);
const localFeedPath = resolveUserPath(
overrides.localFeedPath
?? envOrConfigString("HERMES_LOCAL_ADVISORY_FEED", config, "local_path", cachedFeedPath),
cachedFeedPath,
hermesHome,
);
const localSignaturePath = resolveUserPath(
overrides.localSignaturePath
?? envOrConfigString("HERMES_LOCAL_ADVISORY_FEED_SIG", config, "local_signature_path", `${localFeedPath}.sig`),
`${localFeedPath}.sig`,
hermesHome,
);
const localChecksumsPath = resolveUserPath(
overrides.localChecksumsPath
?? envOrConfigString(
"HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS",
config,
"local_checksums_path",
path.join(path.dirname(localFeedPath), "checksums.json"),
),
path.join(path.dirname(localFeedPath), "checksums.json"),
hermesHome,
);
const localChecksumsSignaturePath = resolveUserPath(
overrides.localChecksumsSignaturePath
?? envOrConfigString("HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS_SIG", config, "local_checksums_signature_path", `${localChecksumsPath}.sig`),
`${localChecksumsPath}.sig`,
hermesHome,
);
const publicKeyPathRaw = overrides.publicKeyPath
?? envOrConfigString("HERMES_ADVISORY_FEED_PUBLIC_KEY", config, "public_key_path", "");
const publicKeyPath = publicKeyPathRaw ? resolveUserPath(publicKeyPathRaw, "", hermesHome) : "";
const statePath = confineToHermesHome(
resolveUserPath(
overrides.statePath
?? envOrConfigString("HERMES_ADVISORY_FEED_STATE_PATH", config, "state_path", path.join(advisoryRoot, STATE_FILE_BASENAME)),
path.join(advisoryRoot, STATE_FILE_BASENAME),
hermesHome,
),
hermesHome,
"advisory state path",
);
return {
hermesHome,
advisoryRoot,
source: ["remote", "local", "auto"].includes(source) ? source : "auto",
feedUrl,
signatureUrl,
checksumsUrl,
checksumsSignatureUrl,
localFeedPath,
localSignaturePath,
localChecksumsPath,
localChecksumsSignaturePath,
publicKeyPath,
publicKeyPem: overrides.publicKeyPem || "",
allowUnsigned: allowUnsigned === true,
verifyChecksumManifest: verifyChecksumManifest !== false,
statePath,
cachedFeedPath,
};
}
function readPublicKeyPem(config) {
if (config.allowUnsigned) return "";
if (config.publicKeyPem && config.publicKeyPem.trim()) {
return config.publicKeyPem;
}
if (config.publicKeyPath) {
if (!fs.existsSync(config.publicKeyPath)) {
throw new Error(`pinned feed public key not found: ${config.publicKeyPath}`);
}
return fs.readFileSync(config.publicKeyPath, "utf8");
}
return PINNED_FEED_PUBLIC_KEY_PEM;
}
export function loadFeedVerificationState(statePath = defaultFeedStatePath()) {
if (!fs.existsSync(statePath)) return null;
try {
const parsed = JSON.parse(fs.readFileSync(statePath, "utf8"));
if (!isObject(parsed)) return null;
return parsed;
} catch {
return null;
}
}
export function getFeedVerificationStatus({ statePath = defaultFeedStatePath() } = {}) {
const state = loadFeedVerificationState(statePath);
const status = String(state?.status || "").trim().toLowerCase();
if (["verified", "unverified"].includes(status)) {
return {
status,
available: true,
checked_at: state.checked_at || null,
state_path: statePath,
source: state.source || null,
};
}
return {
status: "unknown",
available: false,
checked_at: null,
state_path: statePath,
source: null,
};
}
function writeTextAtomic(filePath, content, writeOptions = {}) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const tempPath = path.join(
path.dirname(filePath),
`${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${crypto.randomUUID()}`,
);
let renamed = false;
try {
fs.writeFileSync(tempPath, content, { encoding: "utf8", ...writeOptions });
fs.renameSync(tempPath, filePath);
renamed = true;
} finally {
if (!renamed && fs.existsSync(tempPath)) {
try {
fs.unlinkSync(tempPath);
} catch {
// Best-effort cleanup for interrupted atomic writes.
}
}
}
}
function writeJsonAtomic(filePath, value) {
writeTextAtomic(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
}
function parseAndValidateFeed(feedRaw, sourceLabel) {
let payload;
try {
payload = JSON.parse(feedRaw);
} catch (error) {
throw new Error(`invalid advisory feed JSON (${sourceLabel}): ${error?.message || String(error)}`);
}
if (!isValidFeedPayload(payload)) {
throw new Error(`invalid advisory feed format (${sourceLabel})`);
}
return payload;
}
function assertSignedPayload(payloadRaw, signatureRaw, keyPem, failureMessage) {
if (!verifySignedPayload(payloadRaw, signatureRaw, keyPem)) {
throw new Error(failureMessage);
}
}
function assertCompleteChecksumManifestArtifacts(hasManifest, hasManifestSignature) {
if (!hasManifest || !hasManifestSignature) {
throw new Error("checksum manifest artifacts are required when checksum verification is enabled");
}
}
function verifyChecksumManifestBundle({
checksumsRaw,
checksumsSignatureRaw,
keyPem,
checksumsLocation,
feedEntry,
signatureEntry,
feedRaw,
signatureRaw,
}) {
assertSignedPayload(
checksumsRaw,
checksumsSignatureRaw,
keyPem,
`checksum manifest signature verification failed: ${checksumsLocation}`,
);
const manifest = parseChecksumsManifest(checksumsRaw);
verifyChecksumEntry(manifest, feedEntry, feedRaw);
verifyChecksumEntry(manifest, signatureEntry, signatureRaw);
}
function verifySignedFeedArtifacts({
feedRaw,
signatureRaw,
keyPem,
signatureFailureMessage,
verifyChecksumManifest,
checksumsRaw,
checksumsSignatureRaw,
checksumsLocation,
feedEntry,
signatureEntry,
}) {
assertSignedPayload(feedRaw, signatureRaw, keyPem, signatureFailureMessage);
if (!verifyChecksumManifest) {
return false;
}
const hasChecksums = checksumsRaw !== null;
const hasChecksumsSignature = checksumsSignatureRaw !== null;
assertCompleteChecksumManifestArtifacts(hasChecksums, hasChecksumsSignature);
verifyChecksumManifestBundle({
checksumsRaw,
checksumsSignatureRaw,
keyPem,
checksumsLocation,
feedEntry,
signatureEntry,
feedRaw,
signatureRaw,
});
return true;
}
export async function loadLocalFeed(config) {
const feedRaw = fs.readFileSync(config.localFeedPath, "utf8");
const keyPem = readPublicKeyPem(config);
const result = {
source: "local",
location: config.localFeedPath,
checksums_verified: false,
unsigned_bypass: config.allowUnsigned,
};
if (!config.allowUnsigned) {
if (!fs.existsSync(config.localSignaturePath)) {
throw new Error(`missing local feed signature: ${config.localSignaturePath}`);
}
const signatureRaw = fs.readFileSync(config.localSignaturePath, "utf8");
const hasChecksums = config.verifyChecksumManifest && fs.existsSync(config.localChecksumsPath);
const hasChecksumsSignature = config.verifyChecksumManifest && fs.existsSync(config.localChecksumsSignaturePath);
const checksumsRaw = hasChecksums ? fs.readFileSync(config.localChecksumsPath, "utf8") : null;
const checksumsSignatureRaw = hasChecksumsSignature ? fs.readFileSync(config.localChecksumsSignaturePath, "utf8") : null;
result.checksums_verified = verifySignedFeedArtifacts({
feedRaw,
signatureRaw,
keyPem,
signatureFailureMessage: `local feed signature verification failed: ${config.localFeedPath}`,
verifyChecksumManifest: config.verifyChecksumManifest,
checksumsRaw,
checksumsSignatureRaw,
checksumsLocation: config.localChecksumsPath,
feedEntry: path.basename(config.localFeedPath),
signatureEntry: path.basename(config.localSignaturePath),
});
}
const payload = parseAndValidateFeed(feedRaw, config.localFeedPath);
return {
payload,
feedRaw,
verification: result,
};
}
export async function loadRemoteFeed(config) {
const feedRaw = await fetchTextRequired(config.feedUrl);
const keyPem = readPublicKeyPem(config);
const result = {
source: "remote",
location: config.feedUrl,
checksums_verified: false,
unsigned_bypass: config.allowUnsigned,
};
if (!config.allowUnsigned) {
const signatureRaw = await fetchTextRequired(config.signatureUrl);
const checksumsRaw = config.verifyChecksumManifest ? await fetchTextOptional(config.checksumsUrl) : null;
const checksumsSignatureRaw = config.verifyChecksumManifest ? await fetchTextOptional(config.checksumsSignatureUrl) : null;
const feedEntry = safeBasename(config.feedUrl, "feed.json");
result.checksums_verified = verifySignedFeedArtifacts({
feedRaw,
signatureRaw,
keyPem,
signatureFailureMessage: `remote feed signature verification failed: ${config.feedUrl}`,
verifyChecksumManifest: config.verifyChecksumManifest,
checksumsRaw,
checksumsSignatureRaw,
checksumsLocation: config.checksumsUrl,
feedEntry,
signatureEntry: safeBasename(config.signatureUrl, `${feedEntry}.sig`),
});
}
const payload = parseAndValidateFeed(feedRaw, config.feedUrl);
return {
payload,
feedRaw,
verification: result,
};
}
function buildState({ status, source, config, verification = {}, payload = null, error = null }) {
return {
schema_version: "1",
checked_at: new Date().toISOString(),
status,
source,
allow_unsigned_bypass: config.allowUnsigned,
verify_checksum_manifest: config.verifyChecksumManifest,
advisory_count: Array.isArray(payload?.advisories) ? payload.advisories.length : 0,
feed_version: payload?.version || null,
feed_updated: payload?.updated || null,
cached_feed_path: config.cachedFeedPath,
...verification,
error: error ? String(error) : null,
};
}
export async function refreshAdvisoryFeed(overrides = {}) {
const config = resolveFeedConfig(overrides);
const attemptedErrors = [];
const tryLoadRemote = async () => {
const loaded = await loadRemoteFeed(config);
return { ...loaded, source: "remote" };
};
const tryLoadLocal = async () => {
const loaded = await loadLocalFeed(config);
return { ...loaded, source: "local" };
};
let loaded = null;
if (config.source === "remote") {
loaded = await tryLoadRemote();
} else if (config.source === "local") {
loaded = await tryLoadLocal();
} else {
try {
loaded = await tryLoadRemote();
} catch (error) {
attemptedErrors.push(`remote: ${error?.message || String(error)}`);
loaded = await tryLoadLocal();
}
}
try {
writeTextAtomic(config.cachedFeedPath, `${loaded.feedRaw.trimEnd()}\n`);
const state = buildState({
status: config.allowUnsigned ? "unverified" : "verified",
source: loaded.source,
config,
verification: loaded.verification,
payload: loaded.payload,
error: attemptedErrors.length > 0 ? attemptedErrors.join(" | ") : null,
});
writeJsonAtomic(config.statePath, state);
return {
status: state.status,
source: loaded.source,
statePath: config.statePath,
cachedFeedPath: config.cachedFeedPath,
advisoryCount: state.advisory_count,
feedVersion: state.feed_version,
attemptedErrors,
};
} catch (error) {
const state = buildState({
status: "unverified",
source: loaded?.source || config.source,
config,
verification: loaded?.verification,
payload: loaded?.payload,
error: error?.message || String(error),
});
writeJsonAtomic(config.statePath, state);
throw error;
}
}
export function recordUnverifiedFeedState(error, overrides = {}) {
const config = resolveFeedConfig(overrides);
const state = buildState({
status: "unverified",
source: config.source,
config,
verification: {},
payload: null,
error,
});
writeJsonAtomic(config.statePath, state);
return state;
}
@@ -0,0 +1,204 @@
/**
* @param {string} version
* @returns {[number, number, number] | null}
*/
export function parseSemver(version) {
const cleaned = String(version || "")
.trim()
.replace(/^v/i, "")
.split("+")[0]
.split("-")[0];
const match = cleaned.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/);
if (!match) return null;
const normalized = [
Number.parseInt(match[1], 10),
Number.parseInt(match[2] || "0", 10),
Number.parseInt(match[3] || "0", 10),
];
if (normalized.some((part) => Number.isNaN(part))) return null;
return /** @type {[number, number, number]} */ (normalized);
}
/**
* @param {string} left
* @param {string} right
* @returns {number | null}
*/
export function compareSemver(left, right) {
const a = parseSemver(left);
const b = parseSemver(right);
if (!a || !b) return null;
for (let i = 0; i < 3; i += 1) {
if (a[i] > b[i]) return 1;
if (a[i] < b[i]) return -1;
}
return 0;
}
/**
* @param {string} value
* @returns {string}
*/
export function escapeRegex(value) {
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* @param {string} rawSpecifier
* @returns {{name: string, versionSpec: string} | null}
*/
export function parseAffectedSpecifier(rawSpecifier) {
const specifier = String(rawSpecifier || "").trim();
if (!specifier) return null;
const atIndex = specifier.lastIndexOf("@");
if (atIndex <= 0) {
return null;
}
if (atIndex === specifier.length - 1) {
return null;
}
const name = specifier.slice(0, atIndex).trim();
const versionSpec = specifier.slice(atIndex + 1).trim();
if (!name || !versionSpec) return null;
return { name, versionSpec };
}
/**
* @param {string} reason
* @param {string} normalized
* @returns {{supported: false, normalized: string, reason: string}}
*/
function unsupportedSpec(reason, normalized) {
return { supported: false, normalized, reason };
}
/**
* @param {string} normalized
* @returns {{supported: true, normalized: string, reason: null}}
*/
function supportedSpec(normalized) {
return { supported: true, normalized, reason: null };
}
/**
* @param {string} rawSpec
* @returns {{supported: boolean, normalized: string, reason: string | null}}
*/
export function parseVersionSpec(rawSpec) {
const spec = String(rawSpec || "").trim();
if (!spec || spec === "*" || spec.toLowerCase() === "any") {
return supportedSpec("*");
}
if (spec.includes("||") || spec.includes("&&") || /\s-\s/.test(spec) || spec.includes(",")) {
return unsupportedSpec("unsupported logical/composite semver range syntax", spec);
}
if (/^(>=|<=|>|<|=).*\s+(>=|<=|>|<|=)/.test(spec)) {
return unsupportedSpec("unsupported comparator-set semver range syntax", spec);
}
if (spec.includes("*")) {
if (!/^[vV]?[0-9*]+(?:\.[0-9*]+){0,2}$/.test(spec)) {
return unsupportedSpec("unsupported wildcard semver range syntax", spec);
}
return supportedSpec(spec);
}
if (/^(>=|<=|>|<|=)\s*([vV]?\d+(?:\.\d+){0,2})$/.test(spec)) {
return supportedSpec(spec);
}
if (spec.startsWith("^")) {
if (!parseSemver(spec.slice(1))) {
return unsupportedSpec("invalid caret semver range syntax", spec);
}
return supportedSpec(spec);
}
if (spec.startsWith("~")) {
if (!parseSemver(spec.slice(1))) {
return unsupportedSpec("invalid tilde semver range syntax", spec);
}
return supportedSpec(spec);
}
if (parseSemver(spec.replace(/^v/i, ""))) {
return supportedSpec(spec);
}
return unsupportedSpec("unsupported semver range syntax", spec);
}
/**
* @param {string | null} version
* @param {string} rawSpec
* @returns {boolean}
*/
export function versionMatches(version, rawSpec) {
const parsedSpec = parseVersionSpec(rawSpec);
if (!parsedSpec.supported) return false;
const spec = parsedSpec.normalized;
if (spec === "*") return true;
if (!version || String(version).trim().toLowerCase() === "unknown") return false;
const normalizedVersion = String(version).trim().replace(/^v/i, "");
if (spec.includes("*")) {
const wildcardRegex = new RegExp(`^${escapeRegex(spec).replace(/\\\*/g, ".*")}$`);
return wildcardRegex.test(normalizedVersion);
}
const comparatorMatch = spec.match(/^(>=|<=|>|<|=)\s*([vV]?\d+(?:\.\d+){0,2})$/);
if (comparatorMatch) {
const operator = comparatorMatch[1];
const targetVersion = comparatorMatch[2].trim();
const compared = compareSemver(normalizedVersion, targetVersion);
if (compared === null) return false;
if (operator === ">=") return compared >= 0;
if (operator === "<=") return compared <= 0;
if (operator === ">") return compared > 0;
if (operator === "<") return compared < 0;
return compared === 0;
}
if (spec.startsWith("^")) {
const target = parseSemver(spec.slice(1));
const current = parseSemver(normalizedVersion);
if (!target || !current) return false;
const lowerBound = `${target[0]}.${target[1]}.${target[2]}`;
let upperBound;
if (target[0] > 0) {
upperBound = `${target[0] + 1}.0.0`;
} else if (target[1] > 0) {
upperBound = `0.${target[1] + 1}.0`;
} else {
upperBound = `0.0.${target[2] + 1}`;
}
const lowerCompared = compareSemver(normalizedVersion, lowerBound);
const upperCompared = compareSemver(normalizedVersion, upperBound);
return lowerCompared !== null && upperCompared !== null && lowerCompared >= 0 && upperCompared === -1;
}
if (spec.startsWith("~")) {
const target = parseSemver(spec.slice(1));
const current = parseSemver(normalizedVersion);
if (!target || !current) return false;
return (
current[0] === target[0] &&
current[1] === target[1] &&
compareSemver(normalizedVersion, spec.slice(1)) !== -1
);
}
return normalizedVersion === spec || normalizedVersion === spec.replace(/^v/i, "");
}
@@ -0,0 +1,101 @@
#!/usr/bin/env node
import fs from "node:fs";
import { defaultCachedFeedPath, defaultFeedStatePath, loadFeedVerificationState, resolveFeedConfig } from "../lib/feed.mjs";
function usage() {
process.stdout.write(
[
"Usage: node scripts/check_advisories.mjs",
"",
"Prints human-readable advisory feed verification status and cached feed summary.",
"",
].join("\n"),
);
}
function summarizeBySeverity(feed) {
const advisories = Array.isArray(feed?.advisories) ? feed.advisories : [];
const counts = {};
for (const advisory of advisories) {
const severity = String(advisory?.severity || "unknown").trim().toLowerCase() || "unknown";
counts[severity] = (counts[severity] || 0) + 1;
}
return counts;
}
function printSeveritySummary(counts) {
const entries = Object.entries(counts);
if (entries.length === 0) {
process.stdout.write("Advisory severities: (none)\n");
return;
}
const sorted = entries.sort((a, b) => a[0].localeCompare(b[0]));
process.stdout.write(
`Advisory severities: ${sorted.map(([severity, count]) => `${severity}=${count}`).join(", ")}\n`,
);
}
function main() {
const argv = process.argv.slice(2);
if (argv.includes("--help") || argv.includes("-h")) {
usage();
return;
}
const config = resolveFeedConfig({});
const statePath = config.statePath || defaultFeedStatePath();
const cachedFeedPath = config.cachedFeedPath || defaultCachedFeedPath();
const state = loadFeedVerificationState(statePath);
if (!state) {
process.stdout.write(`Feed verification state: unknown (missing state file: ${statePath})\n`);
process.exitCode = 2;
return;
}
process.stdout.write(`Feed verification state: ${state.status || "unknown"}\n`);
process.stdout.write(`Source: ${state.source || "unknown"}\n`);
process.stdout.write(`Last checked: ${state.checked_at || "unknown"}\n`);
process.stdout.write(`State file: ${statePath}\n`);
process.stdout.write(`Cached feed: ${cachedFeedPath}\n`);
if (state.error) {
process.stdout.write(`Last error: ${state.error}\n`);
}
if (state.allow_unsigned_bypass) {
process.stdout.write("WARNING: unsigned advisory feed bypass is active.\n");
}
if (!fs.existsSync(cachedFeedPath)) {
process.stdout.write("Cached advisory feed: unavailable\n");
process.exitCode = state.status === "verified" ? 1 : 0;
return;
}
let feed;
try {
feed = JSON.parse(fs.readFileSync(cachedFeedPath, "utf8"));
} catch (error) {
process.stdout.write(`Cached advisory feed JSON parse error: ${error?.message || String(error)}\n`);
process.exitCode = 1;
return;
}
process.stdout.write(`Feed version: ${feed?.version || "unknown"}\n`);
process.stdout.write(`Feed updated: ${feed?.updated || "unknown"}\n`);
process.stdout.write(`Advisory count: ${Array.isArray(feed?.advisories) ? feed.advisories.length : 0}\n`);
printSeveritySummary(summarizeBySeverity(feed));
if (state.status === "unverified") {
process.exitCode = 1;
}
}
try {
main();
} catch (error) {
process.stderr.write(`CRITICAL: ${error?.message || String(error)}\n`);
process.exit(1);
}
@@ -0,0 +1,202 @@
#!/usr/bin/env node
import fs from "node:fs";
import { refreshAdvisoryFeed } from "../lib/feed.mjs";
import { parseAffectedSpecifier, parseVersionSpec, versionMatches } from "../lib/semver.mjs";
const EXIT_CONFIRM_REQUIRED = 42;
function usage() {
process.stdout.write(
[
"Usage: node scripts/guarded_skill_verify.mjs --skill <name> [--version <semver>] [--confirm-advisory] [--allow-unsigned]",
"",
"Verifies advisory feed state using the Hermes feed verification pipeline, then gates",
"a candidate skill by advisory match before install/verification flows continue.",
"",
"Exit codes:",
" 0 no advisory match, or explicit advisory confirmation supplied",
" 42 advisory match found and --confirm-advisory was not provided",
" 1 verification/feed failure or invalid arguments",
"",
].join("\n"),
);
}
function parseArgs(argv) {
const parsed = {
skill: "",
version: "",
confirmAdvisory: false,
allowUnsigned: undefined,
help: false,
};
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token === "--skill") {
parsed.skill = String(argv[i + 1] || "").trim();
i += 1;
continue;
}
if (token === "--version") {
parsed.version = String(argv[i + 1] || "").trim();
i += 1;
continue;
}
if (token === "--confirm-advisory") {
parsed.confirmAdvisory = true;
continue;
}
if (token === "--allow-unsigned") {
parsed.allowUnsigned = true;
continue;
}
if (token === "--help" || token === "-h") {
parsed.help = true;
continue;
}
throw new Error(`Unknown argument: ${token}`);
}
if (parsed.help) return parsed;
if (!parsed.skill) {
throw new Error("Missing required argument: --skill");
}
if (!/^[a-z0-9-]+$/.test(parsed.skill)) {
throw new Error("Invalid --skill value. Use lowercase letters, digits, and hyphens only.");
}
if (parsed.version && !/^v?\d+\.\d+\.\d+(?:[-+][0-9a-zA-Z.-]+)?$/.test(parsed.version)) {
throw new Error("Invalid --version value. Expected semver (for example: 1.2.3).");
}
return parsed;
}
function normalizeSkillName(value) {
return String(value || "").trim().toLowerCase();
}
function findAdvisoryMatches(feed, skillName, version = "") {
const advisories = Array.isArray(feed?.advisories) ? feed.advisories : [];
const targetName = normalizeSkillName(skillName);
const matches = [];
for (const advisory of advisories) {
const affected = Array.isArray(advisory?.affected) ? advisory.affected : [];
if (affected.length === 0) continue;
const matchedAffected = [];
const unsupportedSpecs = [];
for (const specifier of affected) {
const parsed = parseAffectedSpecifier(specifier);
if (!parsed) continue;
if (normalizeSkillName(parsed.name) !== targetName) continue;
const parsedSpec = parseVersionSpec(parsed.versionSpec);
if (!parsedSpec.supported) {
// Fail closed: unsupported range syntax is treated as a match to avoid bypass.
matchedAffected.push(specifier);
unsupportedSpecs.push(specifier);
continue;
}
// Conservative default: if operator did not provide --version, any name match gates.
if (!version || versionMatches(version, parsed.versionSpec)) {
matchedAffected.push(specifier);
}
}
if (matchedAffected.length > 0) {
matches.push({ advisory, matchedAffected, unsupportedSpecs });
}
}
return matches;
}
function printMatches(matches, args) {
process.stdout.write("Advisory matches detected for requested candidate.\n");
process.stdout.write(`Target: ${args.skill}${args.version ? `@${args.version}` : ""}\n`);
for (const match of matches) {
const advisory = match.advisory || {};
const severity = String(advisory.severity || "unknown").toUpperCase();
const advisoryId = String(advisory.id || "unknown-id");
const title = String(advisory.title || "Untitled advisory");
process.stdout.write(`- [${severity}] ${advisoryId}: ${title}\n`);
process.stdout.write(` matched: ${match.matchedAffected.join(", ")}\n`);
if (Array.isArray(match.unsupportedSpecs) && match.unsupportedSpecs.length > 0) {
process.stdout.write(
` warning: unsupported advisory version syntax treated as match (fail-closed): ${match.unsupportedSpecs.join(", ")}\n`,
);
}
if (advisory.action) {
process.stdout.write(` action: ${advisory.action}\n`);
}
}
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
usage();
return;
}
let refreshResult;
try {
refreshResult = await refreshAdvisoryFeed(args.allowUnsigned === true ? { allowUnsigned: true } : {});
} catch (error) {
process.stderr.write(`CRITICAL: advisory feed verification failed (fail-closed): ${error?.message || String(error)}\n`);
process.exit(1);
}
if (refreshResult.status === "unverified") {
const warningSource = args.allowUnsigned === true ? "--allow-unsigned" : "resolved env/config policy";
process.stderr.write(
`WARNING: unsigned advisory bypass enabled via ${warningSource}. This weakens supply-chain guarantees and should be emergency-only.\n`,
);
}
let feed;
try {
feed = JSON.parse(fs.readFileSync(refreshResult.cachedFeedPath, "utf8"));
} catch (error) {
process.stderr.write(
`CRITICAL: cached advisory feed load failed after verification: ${error?.message || String(error)}\n`,
);
process.exit(1);
}
process.stdout.write(`Advisory feed status: ${refreshResult.status} (${refreshResult.source})\n`);
if (!args.version) {
process.stdout.write("No --version provided; applying conservative name-based advisory gate.\n");
}
const matches = findAdvisoryMatches(feed, args.skill, args.version);
if (matches.length === 0) {
process.stdout.write("No advisory matches found for candidate.\n");
return;
}
printMatches(matches, args);
if (!args.confirmAdvisory) {
process.stdout.write("Re-run with --confirm-advisory to proceed with explicit operator acknowledgement.\n");
process.exit(EXIT_CONFIRM_REQUIRED);
}
process.stderr.write(
`WARNING: proceeding despite ${matches.length} advisory match(es) because --confirm-advisory was provided.\n`,
);
}
try {
await main();
} catch (error) {
process.stderr.write(`CRITICAL: ${error?.message || String(error)}\n`);
process.exit(1);
}
@@ -0,0 +1,105 @@
#!/usr/bin/env node
import { refreshAdvisoryFeed, recordUnverifiedFeedState, resolveFeedConfig } from "../lib/feed.mjs";
function usage() {
process.stdout.write(
[
"Usage: node scripts/refresh_advisory_feed.mjs [options]",
"",
"Options:",
" --source <auto|remote|local> Feed source strategy (default: auto)",
" --allow-unsigned Temporary bypass for unsigned feeds (DANGEROUS)",
" --help Show this help",
"",
"Env/config overrides:",
" HERMES_ADVISORY_FEED_SOURCE",
" HERMES_ADVISORY_FEED_URL / HERMES_ADVISORY_FEED_SIG_URL",
" HERMES_ADVISORY_FEED_CHECKSUMS_URL / HERMES_ADVISORY_FEED_CHECKSUMS_SIG_URL",
" HERMES_LOCAL_ADVISORY_FEED / HERMES_LOCAL_ADVISORY_FEED_SIG",
" HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS / HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS_SIG",
" HERMES_ADVISORY_FEED_PUBLIC_KEY",
" HERMES_ADVISORY_ALLOW_UNSIGNED_FEED",
" HERMES_ADVISORY_VERIFY_CHECKSUM_MANIFEST",
" HERMES_ADVISORY_FEED_STATE_PATH",
" HERMES_ADVISORY_CACHED_FEED",
"",
].join("\n"),
);
}
function parseArgs(argv) {
const parsed = {
source: undefined,
allowUnsigned: undefined,
help: false,
};
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token === "--help" || token === "-h") {
parsed.help = true;
continue;
}
if (token === "--source") {
parsed.source = String(argv[i + 1] || "").trim().toLowerCase();
i += 1;
continue;
}
if (token === "--allow-unsigned") {
parsed.allowUnsigned = true;
continue;
}
throw new Error(`Unknown argument: ${token}`);
}
if (parsed.source && !["auto", "remote", "local"].includes(parsed.source)) {
throw new Error(`Invalid --source value: ${parsed.source}`);
}
return parsed;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
usage();
return;
}
const config = resolveFeedConfig(args);
if (config.allowUnsigned) {
process.stderr.write(
"WARNING: unsigned advisory feed bypass is enabled. This weakens supply-chain guarantees and should only be used as a temporary emergency exception.\n",
);
}
try {
const result = await refreshAdvisoryFeed(args);
process.stdout.write(
`${JSON.stringify({
level: "INFO",
message: "advisory feed refreshed",
status: result.status,
source: result.source,
advisories: result.advisoryCount,
feed_version: result.feedVersion,
state_path: result.statePath,
cached_feed_path: result.cachedFeedPath,
fallback_events: result.attemptedErrors,
})}\n`,
);
} catch (error) {
recordUnverifiedFeedState(error?.message || String(error), args);
process.stderr.write(`CRITICAL: ${error?.message || String(error)}\n`);
process.stderr.write(`CRITICAL: feed verification state recorded at ${config.statePath || "(unknown)"}\n`);
process.exit(1);
}
}
try {
await main();
} catch (error) {
process.stderr.write(`CRITICAL: ${error?.message || String(error)}\n`);
process.exit(1);
}
@@ -0,0 +1,171 @@
#!/usr/bin/env node
import path from "node:path";
import { fileURLToPath } from "node:url";
import { detectHermesHome } from "../lib/attestation.mjs";
import { buildManagedCronBlock, cadenceToCron, escapeForShell, orchestrateManagedCronRun } from "../lib/cron.mjs";
const MARKER_START = "# >>> hermes-attestation-guardian-advisory-check >>>";
const MARKER_END = "# <<< hermes-attestation-guardian-advisory-check <<<";
const SCHEDULE_BIN = ["cron", "tab"].join("");
function usage() {
process.stdout.write(
[
"Usage: node scripts/setup_advisory_check_cron.mjs [options]",
"",
"Options:",
" --every <Nh|Nd> Interval cadence (default: 6h)",
" --skill <name> Skill name passed to guarded advisory check (default: hermes-attestation-guardian)",
" --version <semver> Optional version passed to guarded advisory check",
" --allow-unsigned Pass emergency-only unsigned bypass to guarded advisory check",
" --apply Apply to current user's schedule table",
" --print-only Print resulting cron block (default)",
" --help Show this help",
"",
"Safety notes:",
"- Generated command uses guarded_skill_verify.mjs (advisory-aware gate), not raw advisory feed checks.",
"- Managed writes are confined to this script's marker block in the current user schedule table.",
"",
].join("\n"),
);
}
function parseArgs(argv) {
const args = {
every: process.env.HERMES_ADVISORY_CHECK_INTERVAL || "6h",
skill: process.env.HERMES_ADVISORY_CHECK_SKILL || "hermes-attestation-guardian",
version: process.env.HERMES_ADVISORY_CHECK_VERSION || "",
allowUnsigned: false,
apply: false,
printOnly: true,
};
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token === "--help" || token === "-h") {
args.help = true;
continue;
}
if (token === "--every") {
args.every = argv[i + 1];
i += 1;
continue;
}
if (token === "--skill") {
args.skill = argv[i + 1];
i += 1;
continue;
}
if (token === "--version") {
args.version = argv[i + 1];
i += 1;
continue;
}
if (token === "--allow-unsigned") {
args.allowUnsigned = true;
continue;
}
if (token === "--apply") {
args.apply = true;
args.printOnly = false;
continue;
}
if (token === "--print-only") {
args.printOnly = true;
args.apply = false;
continue;
}
throw new Error(`Unknown argument: ${token}`);
}
args.skill = String(args.skill || "").trim().toLowerCase();
args.version = String(args.version || "").trim();
if (!args.help) {
if (!args.skill) {
throw new Error("Missing required skill value. Use --skill <name>.");
}
if (!/^[a-z0-9-]+$/.test(args.skill)) {
throw new Error("Invalid --skill value. Use lowercase letters, digits, and hyphens only.");
}
if (args.version && !/^v?\d+\.\d+\.\d+(?:[-+][0-9a-zA-Z.-]+)?$/.test(args.version)) {
throw new Error("Invalid --version value. Expected semver (for example: 1.2.3).");
}
}
return args;
}
function buildCronCommand({ skill, version, allowUnsigned }) {
const scriptDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)));
const guardedVerify = path.join(scriptDir, "guarded_skill_verify.mjs");
const nodeExecPath = process.execPath;
if (!path.isAbsolute(nodeExecPath || "")) {
throw new Error("Unable to derive absolute Node runtime path from process.execPath");
}
const pieces = [
`'${escapeForShell(nodeExecPath)}' '${escapeForShell(guardedVerify)}'`,
`--skill '${escapeForShell(skill)}'`,
version ? `--version '${escapeForShell(version)}'` : "",
allowUnsigned ? "--allow-unsigned" : "",
].filter(Boolean);
return pieces.join(" ").trim();
}
function run() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
usage();
return;
}
const hermesHome = path.resolve(detectHermesHome());
const cronExpr = cadenceToCron(args.every);
const command = buildCronCommand({
skill: args.skill,
version: args.version,
allowUnsigned: args.allowUnsigned,
});
const block = buildManagedCronBlock({
markerStart: MARKER_START,
markerEnd: MARKER_END,
managedBy: "hermes-attestation-guardian advisory check helper",
cronExpr,
command,
hermesHome,
});
const preflightLines = [
"Preflight review:",
"- This helper configures recurring Hermes advisory checks using the guarded verification flow.",
"- Generated command: guarded_skill_verify.mjs (not raw check_advisories.mjs).",
`- Hermes home: ${hermesHome}`,
`- Cadence: ${args.every} (${cronExpr})`,
`- Target skill: ${args.skill}${args.version ? `@${args.version}` : ""}`,
`- Unsigned feed bypass in scheduled command: ${args.allowUnsigned ? "enabled (emergency-only)" : "disabled"}`,
"- Scope: Hermes-only.",
];
orchestrateManagedCronRun({
preflightLines,
printOnly: args.printOnly,
block,
markerStart: MARKER_START,
markerEnd: MARKER_END,
scheduleBin: SCHEDULE_BIN,
successMessage: "INFO: Updated user schedule table with hermes-attestation-guardian advisory managed block",
detailedErrors: true,
});
}
try {
run();
} catch (error) {
process.stderr.write(`CRITICAL: ${error?.message || String(error)}\n`);
process.exit(1);
}
@@ -1,11 +1,12 @@
#!/usr/bin/env node
import path from "node:path";
import { spawnSync } from "node:child_process";
import { detectHermesHome, resolveHermesScopedOutputPath } from "../lib/attestation.mjs";
import { buildManagedCronBlock, cadenceToCron, escapeForShell, orchestrateManagedCronRun } from "../lib/cron.mjs";
const MARKER_START = "# >>> hermes-attestation-guardian >>>";
const MARKER_END = "# <<< hermes-attestation-guardian <<<";
const SCHEDULE_BIN = ["cron", "tab"].join("");
function usage() {
process.stdout.write(
@@ -20,7 +21,7 @@ function usage() {
" --baseline-signature <path> Baseline detached signature for verifier",
" --baseline-public-key <path> Baseline signature public key for verifier",
" --output <path> Optional output attestation path",
" --apply Apply to current user's crontab",
" --apply Apply to current user's schedule table",
" --print-only Print resulting cron block (default)",
" --help Show this help",
"",
@@ -106,37 +107,6 @@ function parseArgs(argv) {
return args;
}
function cadenceToCron(cadence) {
const normalized = String(cadence || "").trim().toLowerCase();
const match = normalized.match(/^(\d+)([hd])$/);
if (!match) {
throw new Error(`Invalid cadence '${cadence}'. Expected <number>h or <number>d.`);
}
const n = Number(match[1]);
const unit = match[2];
if (!Number.isInteger(n) || n <= 0) {
throw new Error(`Cadence must be a positive integer: ${cadence}`);
}
if (unit === "h") {
if (n > 24) {
throw new Error("Hourly cadence cannot exceed 24h for cron expression generation.");
}
return `0 */${n} * * *`;
}
if (n > 31) {
throw new Error("Daily cadence cannot exceed 31d for cron expression generation.");
}
return `0 2 */${n} * *`;
}
function escapeForShell(value) {
return String(value).replace(/'/g, "'\\''");
}
function buildCronCommand({ output, policy, baseline, baselineSha256, baselineSignature, baselinePublicKey }) {
const scriptDir = path.resolve(path.dirname(new URL(import.meta.url).pathname));
const generator = path.join(scriptDir, "generate_attestation.mjs");
@@ -161,80 +131,6 @@ function buildCronCommand({ output, policy, baseline, baselineSha256, baselineSi
].join(" && ");
}
function buildCronBlock({ cronExpr, command, hermesHome }) {
const envPrefix = [
`HERMES_HOME='${escapeForShell(hermesHome)}'`,
`PATH='${escapeForShell(process.env.PATH || "/usr/local/bin:/usr/bin:/bin")}'`,
].join(" ");
return [
MARKER_START,
`# Managed by hermes-attestation-guardian (${new Date().toISOString()})`,
`${cronExpr} ${envPrefix} ${command}`,
MARKER_END,
].join("\n");
}
function removeManagedBlock(text) {
const lines = String(text || "").split(/\r?\n/);
const out = [];
let inManagedBlock = false;
let managedStartLine = null;
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
const trimmed = line.trim();
if (trimmed === MARKER_START) {
if (inManagedBlock) {
throw new Error(`Malformed crontab markers: nested managed block start at line ${i + 1}`);
}
inManagedBlock = true;
managedStartLine = i + 1;
continue;
}
if (trimmed === MARKER_END) {
if (!inManagedBlock) {
throw new Error(`Malformed crontab markers: unmatched managed block end at line ${i + 1}`);
}
inManagedBlock = false;
managedStartLine = null;
continue;
}
if (!inManagedBlock) {
out.push(line);
}
}
if (inManagedBlock) {
throw new Error(`Malformed crontab markers: managed block start at line ${managedStartLine} has no end marker`);
}
return out.join("\n").replace(/\n{3,}/g, "\n\n").trim();
}
function readCurrentCrontab() {
const res = spawnSync("crontab", ["-l"], { encoding: "utf8" });
if (res.status !== 0) {
const stderr = String(res.stderr || "").toLowerCase();
if (stderr.includes("no crontab") || stderr.includes("can't open your crontab")) {
return "";
}
throw new Error(`Failed reading crontab: ${res.stderr || res.stdout}`);
}
return res.stdout || "";
}
function writeCrontab(content) {
const res = spawnSync("crontab", ["-"], { input: `${content.trim()}\n`, encoding: "utf8" });
if (res.status !== 0) {
throw new Error(`Failed writing crontab: ${res.stderr || res.stdout}`);
}
}
function run() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
@@ -260,7 +156,14 @@ function run() {
baselineSignature: args.baselineSignature,
baselinePublicKey: args.baselinePublicKey,
});
const block = buildCronBlock({ cronExpr, command, hermesHome });
const block = buildManagedCronBlock({
markerStart: MARKER_START,
markerEnd: MARKER_END,
managedBy: "hermes-attestation-guardian",
cronExpr,
command,
hermesHome,
});
const preflightLines = [
"Preflight review:",
@@ -275,19 +178,16 @@ function run() {
`- Policy: ${args.policy ? path.resolve(args.policy) : "not configured"}`,
"- Scope: Hermes-only.",
];
process.stdout.write(`${preflightLines.join("\n")}\n\n`);
if (args.printOnly) {
process.stdout.write(`${block}\n`);
return;
}
const current = readCurrentCrontab();
const withoutManaged = removeManagedBlock(current);
const merged = [withoutManaged, block].filter(Boolean).join("\n\n").trim();
writeCrontab(merged);
process.stdout.write("INFO: Updated user crontab with hermes-attestation-guardian managed block\n");
orchestrateManagedCronRun({
preflightLines,
printOnly: args.printOnly,
block,
markerStart: MARKER_START,
markerEnd: MARKER_END,
scheduleBin: SCHEDULE_BIN,
successMessage: "INFO: Updated user schedule table with hermes-attestation-guardian managed block",
});
}
try {
+57 -23
View File
@@ -1,6 +1,6 @@
{
"name": "hermes-attestation-guardian",
"version": "0.0.1",
"version": "0.1.3",
"description": "Hermes-only runtime security attestation and drift detection skill. Generates deterministic posture artifacts, verifies integrity fail-closed, and classifies baseline drift severity.",
"author": "prompt-security",
"license": "AGPL-3.0-or-later",
@@ -41,6 +41,21 @@
"required": true,
"description": "Baseline comparison and severity classification"
},
{
"path": "lib/feed.mjs",
"required": true,
"description": "Hermes-native advisory feed verification and state helpers"
},
{
"path": "lib/semver.mjs",
"required": true,
"description": "Advisory version-range parsing and matching helpers"
},
{
"path": "lib/cron.mjs",
"required": true,
"description": "Shared managed cron block and cadence helpers"
},
{
"path": "scripts/generate_attestation.mjs",
"required": true,
@@ -51,30 +66,30 @@
"required": true,
"description": "Verify attestation schema, digest and optional detached signature"
},
{
"path": "scripts/refresh_advisory_feed.mjs",
"required": true,
"description": "Fetch, verify, and persist Hermes advisory feed verification state"
},
{
"path": "scripts/check_advisories.mjs",
"required": true,
"description": "Display human-readable advisory verification/feed summary"
},
{
"path": "scripts/guarded_skill_verify.mjs",
"required": true,
"description": "Advisory-aware guarded skill verification gate with explicit confirmation override"
},
{
"path": "scripts/setup_attestation_cron.mjs",
"required": true,
"description": "Optional recurring schedule setup for Hermes attestation runs"
},
{
"path": "test/attestation_schema.test.mjs",
"required": false,
"description": "Schema and determinism tests"
},
{
"path": "test/attestation_diff.test.mjs",
"required": false,
"description": "Diff and severity mapping tests"
},
{
"path": "test/attestation_cli.test.mjs",
"required": false,
"description": "Generator/verifier CLI behavior tests"
},
{
"path": "test/setup_attestation_cron.test.mjs",
"required": false,
"description": "Hermes-only cron setup tests"
"path": "scripts/setup_advisory_check_cron.mjs",
"required": true,
"description": "Optional recurring schedule setup for Hermes guarded advisory checks"
}
]
},
@@ -94,25 +109,44 @@
"HERMES_ATTESTATION_BASELINE",
"HERMES_ATTESTATION_INTERVAL",
"HERMES_ATTESTATION_FAIL_ON_SEVERITY",
"HERMES_ATTESTATION_POLICY"
"HERMES_ATTESTATION_POLICY",
"HERMES_ADVISORY_FEED_SOURCE",
"HERMES_ADVISORY_FEED_URL",
"HERMES_ADVISORY_FEED_SIG_URL",
"HERMES_ADVISORY_FEED_CHECKSUMS_URL",
"HERMES_ADVISORY_FEED_CHECKSUMS_SIG_URL",
"HERMES_LOCAL_ADVISORY_FEED",
"HERMES_LOCAL_ADVISORY_FEED_SIG",
"HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS",
"HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS_SIG",
"HERMES_ADVISORY_FEED_PUBLIC_KEY",
"HERMES_ADVISORY_ALLOW_UNSIGNED_FEED",
"HERMES_ADVISORY_VERIFY_CHECKSUM_MANIFEST",
"HERMES_ADVISORY_FEED_STATE_PATH",
"HERMES_ADVISORY_CACHED_FEED"
]
},
"execution": {
"always": false,
"persistence": "Runs on demand by default. Optional scheduler helper can install a managed schedule block when run with --apply.",
"network_egress": "None"
"network_egress": "Optional HTTPS advisory feed fetch via refresh_advisory_feed.mjs; no network required for local-mode verification"
},
"operator_review": [
"Hermes-only skill: unsupported for OpenClaw runtime hooks.",
"Verify watch/trust-anchor policy paths before scheduling recurring runs.",
"Verification fails closed for schema/digest/signature errors and unauthenticated baseline inputs; diff threshold defaults to critical."
"Verification fails closed for schema/digest/signature errors and unauthenticated baseline inputs; diff threshold defaults to critical.",
"Advisory feed verification is fail-closed by default; unsigned bypass must remain temporary and operator-audited."
],
"triggers": [
"generate hermes attestation",
"verify hermes attestation",
"hermes runtime drift detection",
"hermes trust anchor drift",
"setup hermes attestation cron"
"refresh hermes advisory feed",
"check hermes advisories",
"guarded hermes skill verification",
"setup hermes attestation cron",
"setup hermes advisory check cron"
]
}
}
@@ -55,6 +55,33 @@ await withTempDir(async (tempDir) => {
const verify = runNode(verifierScript, ["--input", outputPath]);
assert.equal(verify.status, 0, `verify should pass: ${verify.stderr}`);
const feedConfigFailureOutputPath = path.join(attestationsDir, "feed-config-fallback.json");
const generateWithBrokenFeedConfig = runNode(
generatorScript,
["--output", feedConfigFailureOutputPath, "--generated-at", generatedAt],
{
HERMES_HOME: hermesHome,
HERMES_ADVISORY_CACHED_FEED: path.join(tempDir, "outside-cached-feed.json"),
HERMES_ADVISORY_FEED_STATE_PATH: path.join(tempDir, "outside-state.json"),
},
);
assert.equal(
generateWithBrokenFeedConfig.status,
0,
`generator must tolerate invalid feed config paths: ${generateWithBrokenFeedConfig.stderr}`,
);
const fallbackAttestation = JSON.parse(await fs.readFile(feedConfigFailureOutputPath, "utf8"));
assert.equal(fallbackAttestation.posture.feed_verification.status, "unknown");
assert.equal(fallbackAttestation.posture.feed_verification.configured, false);
assert.equal(
fallbackAttestation.posture.feed_verification.state_path,
path.join(hermesHome, "security", "advisories", "feed-verification-state.json"),
);
assert.ok(
String(fallbackAttestation.posture.feed_verification.config_warning || "").includes("outside HERMES_HOME"),
`expected explicit config warning, got: ${fallbackAttestation.posture.feed_verification.config_warning}`,
);
const outOfScope = runNode(generatorScript, ["--output", path.join(tempDir, "outside.json")], { HERMES_HOME: hermesHome });
assert.notEqual(outOfScope.status, 0, "generator must reject out-of-scope --output");
assert.ok(outOfScope.stderr.includes("output path must stay under"), outOfScope.stderr);
@@ -194,6 +194,34 @@ function testSchemaValidationRequiresIntegrityEntryShapes() {
assert.equal(validErrors.length, 0, `valid integrity entries should pass schema: ${validErrors.join(", ")}`);
}
async function testAttestationFeedConfigFailuresFallBackToUnknownStatus() {
await withTempDir(async (tempDir) => {
const hermesHome = path.join(tempDir, ".hermes");
await fs.mkdir(hermesHome, { recursive: true });
await withPatchedEnv(
{
HERMES_HOME: hermesHome,
HERMES_ADVISORY_CACHED_FEED: path.join(tempDir, "outside-feed.json"),
HERMES_ADVISORY_FEED_STATE_PATH: path.join(tempDir, "outside-state.json"),
},
async () => {
const attestation = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
assert.equal(attestation.posture.feed_verification.status, "unknown");
assert.equal(attestation.posture.feed_verification.configured, false);
assert.equal(
attestation.posture.feed_verification.state_path,
path.join(hermesHome, "security", "advisories", "feed-verification-state.json"),
);
assert.ok(
String(attestation.posture.feed_verification.config_warning || "").includes("outside HERMES_HOME"),
`expected explicit config warning, got: ${attestation.posture.feed_verification.config_warning}`,
);
},
);
});
}
async function testBooleanConfigCoercionDoesNotEnableFalseStrings() {
await withTempDir(async (tempDir) => {
const hermesHome = path.join(tempDir, ".hermes");
@@ -278,5 +306,6 @@ testDigestBindingRejectsUnsupportedAlgorithm();
testSchemaValidationRequiresGeneratorVersionNonEmptyString();
testSchemaValidationRequiresRuntimeGatewaysAndRiskyTogglesBooleans();
testSchemaValidationRequiresIntegrityEntryShapes();
await testAttestationFeedConfigFailuresFallBackToUnknownStatus();
await testBooleanConfigCoercionDoesNotEnableFalseStrings();
console.log("attestation_schema.test.mjs: ok");
@@ -0,0 +1,705 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
getFeedVerificationStatus,
loadLocalFeed,
loadRemoteFeed,
refreshAdvisoryFeed,
resolveFeedConfig,
} from "../lib/feed.mjs";
import { buildAttestation } from "../lib/attestation.mjs";
function createFeedPayload() {
return {
version: "1.0.0",
updated: "2026-04-20T00:00:00Z",
advisories: [
{
id: "TEST-ADVISORY-001",
severity: "high",
affected: ["sample-skill@1.0.0"],
},
],
};
}
function signPayload(payloadRaw, privateKeyPem) {
const key = crypto.createPrivateKey(privateKeyPem);
const signature = crypto.sign(null, Buffer.from(payloadRaw, "utf8"), key);
return signature.toString("base64");
}
function createChecksumManifest(files) {
const checksums = {};
for (const [name, content] of Object.entries(files)) {
checksums[name] = crypto.createHash("sha256").update(content).digest("hex");
}
return JSON.stringify(
{
schema_version: "1",
algorithm: "sha256",
files: checksums,
},
null,
2,
);
}
async function withTempDir(run) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "hag-feed-"));
try {
await run(dir);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
}
async function withPatchedEnv(patch, run) {
const previous = new Map();
for (const [key, value] of Object.entries(patch)) {
previous.set(key, process.env[key]);
if (value === undefined || value === null) {
delete process.env[key];
} else {
process.env[key] = String(value);
}
}
try {
await run();
} finally {
for (const [key, value] of previous.entries()) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
}
async function expectReject(label, run) {
let failed = false;
try {
await run();
} catch {
failed = true;
}
assert.equal(failed, true, label);
}
async function withMockedFetch(mockFetch, run) {
const originalFetch = globalThis.fetch;
globalThis.fetch = mockFetch;
try {
await run();
} finally {
globalThis.fetch = originalFetch;
}
}
async function testValidSignedLocalFeed() {
await withTempDir(async (tempDir) => {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
const feedRaw = JSON.stringify(createFeedPayload(), null, 2);
const feedPath = path.join(tempDir, "feed.json");
const signaturePath = path.join(tempDir, "feed.json.sig");
await fs.writeFile(feedPath, feedRaw, "utf8");
await fs.writeFile(signaturePath, `${signPayload(feedRaw, privateKeyPem)}\n`, "utf8");
const loaded = await loadLocalFeed({
localFeedPath: feedPath,
localSignaturePath: signaturePath,
localChecksumsPath: path.join(tempDir, "checksums.json"),
localChecksumsSignaturePath: path.join(tempDir, "checksums.json.sig"),
publicKeyPem,
allowUnsigned: false,
verifyChecksumManifest: false,
});
assert.equal(loaded.payload.version, "1.0.0");
assert.equal(loaded.verification.unsigned_bypass, false);
});
}
async function testUnsupportedAffectedRangesFailClosed() {
await withTempDir(async (tempDir) => {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
const testSpecs = [">=1 <2", "1.2 || 1.3"];
for (const unsupportedSpec of testSpecs) {
const payload = createFeedPayload();
payload.advisories[0].affected = [`sample-skill@${unsupportedSpec}`];
const feedRaw = JSON.stringify(payload, null, 2);
const feedPath = path.join(tempDir, `feed-${unsupportedSpec.replace(/[^a-z0-9]+/gi, "-")}.json`);
const signaturePath = `${feedPath}.sig`;
await fs.writeFile(feedPath, feedRaw, "utf8");
await fs.writeFile(signaturePath, `${signPayload(feedRaw, privateKeyPem)}\n`, "utf8");
await expectReject(`unsupported affected range '${unsupportedSpec}' must fail closed`, async () => {
await loadLocalFeed({
localFeedPath: feedPath,
localSignaturePath: signaturePath,
localChecksumsPath: path.join(tempDir, "checksums.json"),
localChecksumsSignaturePath: path.join(tempDir, "checksums.json.sig"),
publicKeyPem,
allowUnsigned: false,
verifyChecksumManifest: false,
});
});
}
});
}
async function testInvalidSignatureFailsClosed() {
await withTempDir(async (tempDir) => {
const signerKeys = crypto.generateKeyPairSync("ed25519");
const verifierKeys = crypto.generateKeyPairSync("ed25519");
const verifierPublicKeyPem = verifierKeys.publicKey.export({ type: "spki", format: "pem" });
const signerPrivateKeyPem = signerKeys.privateKey.export({ type: "pkcs8", format: "pem" });
const feedRaw = JSON.stringify(createFeedPayload(), null, 2);
const feedPath = path.join(tempDir, "feed.json");
const signaturePath = path.join(tempDir, "feed.json.sig");
await fs.writeFile(feedPath, feedRaw, "utf8");
await fs.writeFile(signaturePath, `${signPayload(feedRaw, signerPrivateKeyPem)}\n`, "utf8");
await expectReject("invalid signature must fail closed", async () => {
await loadLocalFeed({
localFeedPath: feedPath,
localSignaturePath: signaturePath,
localChecksumsPath: path.join(tempDir, "checksums.json"),
localChecksumsSignaturePath: path.join(tempDir, "checksums.json.sig"),
publicKeyPem: verifierPublicKeyPem,
allowUnsigned: false,
verifyChecksumManifest: false,
});
});
});
}
async function testChecksumMismatchFails() {
await withTempDir(async (tempDir) => {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
const feedRaw = JSON.stringify(createFeedPayload(), null, 2);
const feedPath = path.join(tempDir, "feed.json");
const signaturePath = path.join(tempDir, "feed.json.sig");
const checksumsPath = path.join(tempDir, "checksums.json");
const checksumsSignaturePath = path.join(tempDir, "checksums.json.sig");
const feedSigRaw = `${signPayload(feedRaw, privateKeyPem)}\n`;
const checksumsRaw = JSON.stringify(
{
schema_version: "1",
algorithm: "sha256",
files: {
"feed.json": "0".repeat(64),
},
},
null,
2,
);
await fs.writeFile(feedPath, feedRaw, "utf8");
await fs.writeFile(signaturePath, feedSigRaw, "utf8");
await fs.writeFile(checksumsPath, checksumsRaw, "utf8");
await fs.writeFile(checksumsSignaturePath, `${signPayload(checksumsRaw, privateKeyPem)}\n`, "utf8");
await expectReject("checksum mismatch must fail", async () => {
await loadLocalFeed({
localFeedPath: feedPath,
localSignaturePath: signaturePath,
localChecksumsPath: checksumsPath,
localChecksumsSignaturePath: checksumsSignaturePath,
publicKeyPem,
allowUnsigned: false,
verifyChecksumManifest: true,
});
});
});
}
async function testChecksumManifestRequiresFeedSignatureEntry() {
await withTempDir(async (tempDir) => {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
const feedRaw = JSON.stringify(createFeedPayload(), null, 2);
const feedPath = path.join(tempDir, "feed.json");
const signaturePath = path.join(tempDir, "feed.json.sig");
const checksumsPath = path.join(tempDir, "checksums.json");
const checksumsSignaturePath = path.join(tempDir, "checksums.json.sig");
const feedSigRaw = `${signPayload(feedRaw, privateKeyPem)}\n`;
const checksumsRaw = createChecksumManifest({ "feed.json": feedRaw });
await fs.writeFile(feedPath, feedRaw, "utf8");
await fs.writeFile(signaturePath, feedSigRaw, "utf8");
await fs.writeFile(checksumsPath, checksumsRaw, "utf8");
await fs.writeFile(checksumsSignaturePath, `${signPayload(checksumsRaw, privateKeyPem)}\n`, "utf8");
await expectReject("checksum manifest must include feed signature digest entry", async () => {
await loadLocalFeed({
localFeedPath: feedPath,
localSignaturePath: signaturePath,
localChecksumsPath: checksumsPath,
localChecksumsSignaturePath: checksumsSignaturePath,
publicKeyPem,
allowUnsigned: false,
verifyChecksumManifest: true,
});
});
});
}
async function testChecksumManifestVerifiesFeedAndSignatureEntries() {
await withTempDir(async (tempDir) => {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
const feedRaw = JSON.stringify(createFeedPayload(), null, 2);
const feedPath = path.join(tempDir, "feed.json");
const signaturePath = path.join(tempDir, "feed.json.sig");
const checksumsPath = path.join(tempDir, "checksums.json");
const checksumsSignaturePath = path.join(tempDir, "checksums.json.sig");
const feedSigRaw = `${signPayload(feedRaw, privateKeyPem)}\n`;
const checksumsRaw = createChecksumManifest({
"feed.json": feedRaw,
"feed.json.sig": feedSigRaw,
});
await fs.writeFile(feedPath, feedRaw, "utf8");
await fs.writeFile(signaturePath, feedSigRaw, "utf8");
await fs.writeFile(checksumsPath, checksumsRaw, "utf8");
await fs.writeFile(checksumsSignaturePath, `${signPayload(checksumsRaw, privateKeyPem)}\n`, "utf8");
const loaded = await loadLocalFeed({
localFeedPath: feedPath,
localSignaturePath: signaturePath,
localChecksumsPath: checksumsPath,
localChecksumsSignaturePath: checksumsSignaturePath,
publicKeyPem,
allowUnsigned: false,
verifyChecksumManifest: true,
});
assert.equal(loaded.payload.version, "1.0.0");
assert.equal(loaded.verification.checksums_verified, true);
});
}
async function testLocalChecksumPartialArtifactsFailClosed() {
await withTempDir(async (tempDir) => {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
const feedRaw = JSON.stringify(createFeedPayload(), null, 2);
const feedPath = path.join(tempDir, "feed.json");
const signaturePath = path.join(tempDir, "feed.json.sig");
const checksumsPath = path.join(tempDir, "checksums.json");
const checksumsSignaturePath = path.join(tempDir, "checksums.json.sig");
const feedSigRaw = `${signPayload(feedRaw, privateKeyPem)}\n`;
await fs.writeFile(feedPath, feedRaw, "utf8");
await fs.writeFile(signaturePath, feedSigRaw, "utf8");
const checksumsRaw = createChecksumManifest({
"feed.json": feedRaw,
"feed.json.sig": feedSigRaw,
});
await fs.writeFile(checksumsPath, checksumsRaw, "utf8");
await expectReject("manifest-only checksum artifacts must fail closed", async () => {
await loadLocalFeed({
localFeedPath: feedPath,
localSignaturePath: signaturePath,
localChecksumsPath: checksumsPath,
localChecksumsSignaturePath: checksumsSignaturePath,
publicKeyPem,
allowUnsigned: false,
verifyChecksumManifest: true,
});
});
await fs.rm(checksumsPath, { force: true });
await fs.writeFile(checksumsSignaturePath, `${signPayload(checksumsRaw, privateKeyPem)}\n`, "utf8");
await expectReject("signature-only checksum artifacts must fail closed", async () => {
await loadLocalFeed({
localFeedPath: feedPath,
localSignaturePath: signaturePath,
localChecksumsPath: checksumsPath,
localChecksumsSignaturePath: checksumsSignaturePath,
publicKeyPem,
allowUnsigned: false,
verifyChecksumManifest: true,
});
});
});
}
async function testLocalChecksumArtifactsMissingFailClosed() {
await withTempDir(async (tempDir) => {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
const feedRaw = JSON.stringify(createFeedPayload(), null, 2);
const feedPath = path.join(tempDir, "feed.json");
const signaturePath = path.join(tempDir, "feed.json.sig");
await fs.writeFile(feedPath, feedRaw, "utf8");
await fs.writeFile(signaturePath, `${signPayload(feedRaw, privateKeyPem)}\n`, "utf8");
await expectReject("missing checksum manifest and signature must fail closed", async () => {
await loadLocalFeed({
localFeedPath: feedPath,
localSignaturePath: signaturePath,
localChecksumsPath: path.join(tempDir, "checksums.json"),
localChecksumsSignaturePath: path.join(tempDir, "checksums.json.sig"),
publicKeyPem,
allowUnsigned: false,
verifyChecksumManifest: true,
});
});
});
}
async function testRemoteChecksumArtifactsMissingFailClosed() {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
const feedRaw = JSON.stringify(createFeedPayload(), null, 2);
const signatureRaw = `${signPayload(feedRaw, privateKeyPem)}\n`;
await withMockedFetch(
async (url) => {
const target = String(url);
if (target === "https://example.test/feed.json") {
return { ok: true, status: 200, text: async () => feedRaw };
}
if (target === "https://example.test/feed.json.sig") {
return { ok: true, status: 200, text: async () => signatureRaw };
}
if (target === "https://example.test/checksums.json") {
return { ok: false, status: 404, text: async () => "" };
}
if (target === "https://example.test/checksums.json.sig") {
return { ok: false, status: 404, text: async () => "" };
}
throw new Error(`unexpected fetch url: ${target}`);
},
async () => {
await expectReject("remote missing checksum artifacts must fail closed", async () => {
await loadRemoteFeed({
feedUrl: "https://example.test/feed.json",
signatureUrl: "https://example.test/feed.json.sig",
checksumsUrl: "https://example.test/checksums.json",
checksumsSignatureUrl: "https://example.test/checksums.json.sig",
publicKeyPem,
allowUnsigned: false,
verifyChecksumManifest: true,
});
});
},
);
}
async function testMissingSignatureFailsClosed() {
await withTempDir(async (tempDir) => {
const { publicKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const feedRaw = JSON.stringify(createFeedPayload(), null, 2);
const feedPath = path.join(tempDir, "feed.json");
await fs.writeFile(feedPath, feedRaw, "utf8");
await expectReject("missing signature must fail closed", async () => {
await loadLocalFeed({
localFeedPath: feedPath,
localSignaturePath: path.join(tempDir, "feed.json.sig"),
localChecksumsPath: path.join(tempDir, "checksums.json"),
localChecksumsSignaturePath: path.join(tempDir, "checksums.json.sig"),
publicKeyPem,
allowUnsigned: false,
verifyChecksumManifest: false,
});
});
});
}
async function testAllowUnsignedBypass() {
await withTempDir(async (tempDir) => {
const feedRaw = JSON.stringify(createFeedPayload(), null, 2);
const feedPath = path.join(tempDir, "feed.json");
await fs.writeFile(feedPath, feedRaw, "utf8");
const loaded = await loadLocalFeed({
localFeedPath: feedPath,
localSignaturePath: path.join(tempDir, "feed.json.sig"),
localChecksumsPath: path.join(tempDir, "checksums.json"),
localChecksumsSignaturePath: path.join(tempDir, "checksums.json.sig"),
publicKeyPem: "",
allowUnsigned: true,
verifyChecksumManifest: true,
});
assert.equal(loaded.payload.version, "1.0.0");
assert.equal(loaded.verification.unsigned_bypass, true);
});
}
async function testRefreshUpdatesStateAndAttestationReadableStatus() {
await withTempDir(async (tempDir) => {
const hermesHome = path.join(tempDir, ".hermes");
const advisoryDir = path.join(tempDir, "advisories-src");
const customStatePath = path.join(hermesHome, "security", "advisories", "custom-state.json");
await fs.mkdir(advisoryDir, { recursive: true });
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
const feedRaw = JSON.stringify(createFeedPayload(), null, 2);
const feedPath = path.join(advisoryDir, "feed.json");
const signaturePath = `${feedPath}.sig`;
const checksumsPath = path.join(advisoryDir, "checksums.json");
const checksumsSignaturePath = `${checksumsPath}.sig`;
const feedSigRaw = `${signPayload(feedRaw, privateKeyPem)}\n`;
const checksumsRaw = createChecksumManifest({
"feed.json": feedRaw,
"feed.json.sig": feedSigRaw,
});
const checksumsSigRaw = `${signPayload(checksumsRaw, privateKeyPem)}\n`;
await fs.writeFile(feedPath, feedRaw, "utf8");
await fs.writeFile(signaturePath, feedSigRaw, "utf8");
await fs.writeFile(checksumsPath, checksumsRaw, "utf8");
await fs.writeFile(checksumsSignaturePath, checksumsSigRaw, "utf8");
await withPatchedEnv(
{
HERMES_HOME: hermesHome,
HERMES_ADVISORY_FEED_STATE_PATH: customStatePath,
},
async () => {
const result = await refreshAdvisoryFeed({
source: "local",
localFeedPath: feedPath,
localSignaturePath: signaturePath,
localChecksumsPath: checksumsPath,
localChecksumsSignaturePath: checksumsSignaturePath,
publicKeyPem,
allowUnsigned: false,
verifyChecksumManifest: true,
});
assert.equal(result.status, "verified");
assert.equal(result.statePath, customStatePath);
const status = getFeedVerificationStatus({ statePath: customStatePath });
assert.equal(status.status, "verified");
assert.equal(status.available, true);
const attestation = buildAttestation({ generatedAt: "2026-04-20T00:00:00.000Z" });
assert.equal(attestation.posture.feed_verification.status, "verified");
assert.equal(attestation.posture.feed_verification.configured, true);
assert.equal(attestation.posture.feed_verification.state_path, customStatePath);
},
);
});
}
async function testRefreshWritesCachedFeedAtomicallyWithTrailingNewline() {
await withTempDir(async (tempDir) => {
const hermesHome = path.join(tempDir, ".hermes");
const advisoryDir = path.join(tempDir, "advisories-src");
const cachedFeedPath = path.join(hermesHome, "security", "advisories", "feed-cache.json");
await fs.mkdir(advisoryDir, { recursive: true });
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
const feedRaw = `${JSON.stringify(createFeedPayload(), null, 2)}\n\n`;
const feedPath = path.join(advisoryDir, "feed.json");
const signaturePath = `${feedPath}.sig`;
await fs.writeFile(feedPath, feedRaw, "utf8");
await fs.writeFile(signaturePath, `${signPayload(feedRaw, privateKeyPem)}\n`, "utf8");
const originalWriteFileSync = fsSync.writeFileSync;
const originalRenameSync = fsSync.renameSync;
const writes = [];
const renames = [];
fsSync.writeFileSync = function patchedWriteFileSync(filePath, data, ...rest) {
writes.push({ filePath: String(filePath), data: String(data) });
return originalWriteFileSync.call(this, filePath, data, ...rest);
};
fsSync.renameSync = function patchedRenameSync(fromPath, toPath) {
renames.push({ fromPath: String(fromPath), toPath: String(toPath) });
return originalRenameSync.call(this, fromPath, toPath);
};
try {
await withPatchedEnv(
{
HERMES_HOME: hermesHome,
},
async () => {
const result = await refreshAdvisoryFeed({
source: "local",
localFeedPath: feedPath,
localSignaturePath: signaturePath,
localChecksumsPath: path.join(advisoryDir, "checksums.json"),
localChecksumsSignaturePath: path.join(advisoryDir, "checksums.json.sig"),
cachedFeedPath,
publicKeyPem,
allowUnsigned: false,
verifyChecksumManifest: false,
});
assert.equal(result.status, "verified");
},
);
} finally {
fsSync.writeFileSync = originalWriteFileSync;
fsSync.renameSync = originalRenameSync;
}
const cachedRename = renames.find((entry) => entry.toPath === cachedFeedPath);
assert.ok(cachedRename, "cached feed must be written via rename into destination path");
assert.equal(path.dirname(cachedRename.fromPath), path.dirname(cachedFeedPath));
assert.ok(
path.basename(cachedRename.fromPath).startsWith(`${path.basename(cachedFeedPath)}.tmp-`),
"cached feed temp filename should be derived from destination basename",
);
const cachedWrite = writes.find((entry) => entry.filePath === cachedRename.fromPath);
assert.ok(cachedWrite, "cached feed should be written to temp path before rename");
assert.equal(cachedWrite.data, `${feedRaw.trimEnd()}\n`, "cached feed should keep single trailing newline semantics");
const cachedFileRaw = await fs.readFile(cachedFeedPath, "utf8");
assert.equal(cachedFileRaw, `${feedRaw.trimEnd()}\n`);
});
}
async function testResolveFeedConfigRejectsOutsideHermesHomeStateAndCachePaths() {
await withTempDir(async (tempDir) => {
const hermesHome = path.join(tempDir, ".hermes");
const outsideDir = path.join(tempDir, "outside");
await fs.mkdir(hermesHome, { recursive: true });
await fs.mkdir(outsideDir, { recursive: true });
await withPatchedEnv(
{
HERMES_HOME: hermesHome,
HERMES_ADVISORY_FEED_STATE_PATH: path.join(outsideDir, "feed-state.json"),
HERMES_ADVISORY_CACHED_FEED: undefined,
},
async () => {
assert.throws(
() => resolveFeedConfig({}),
/advisory state path must stay under/,
"outside HERMES_HOME state path must be rejected",
);
},
);
await withPatchedEnv(
{
HERMES_HOME: hermesHome,
HERMES_ADVISORY_CACHED_FEED: path.join(outsideDir, "feed-cache.json"),
HERMES_ADVISORY_FEED_STATE_PATH: undefined,
},
async () => {
assert.throws(
() => resolveFeedConfig({}),
/cached feed path must stay under/,
"outside HERMES_HOME cached feed path must be rejected",
);
},
);
});
}
await testValidSignedLocalFeed();
await testUnsupportedAffectedRangesFailClosed();
await testInvalidSignatureFailsClosed();
await testChecksumMismatchFails();
await testChecksumManifestRequiresFeedSignatureEntry();
await testChecksumManifestVerifiesFeedAndSignatureEntries();
await testLocalChecksumPartialArtifactsFailClosed();
await testLocalChecksumArtifactsMissingFailClosed();
await testRemoteChecksumArtifactsMissingFailClosed();
await testMissingSignatureFailsClosed();
await testAllowUnsignedBypass();
async function testLocalChecksumArtifactsIgnoredWhenVerificationDisabled() {
await withTempDir(async (tempDir) => {
const hermesHome = path.join(tempDir, ".hermes");
const advisoryDir = path.join(tempDir, "advisories-src");
await fs.mkdir(advisoryDir, { recursive: true });
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
const feedRaw = `${JSON.stringify(createFeedPayload(), null, 2)}\n`;
const feedPath = path.join(advisoryDir, "feed.json");
const signaturePath = `${feedPath}.sig`;
const checksumsPath = path.join(advisoryDir, "checksums.json");
await fs.writeFile(feedPath, feedRaw, "utf8");
await fs.writeFile(signaturePath, `${signPayload(feedRaw, privateKeyPem)}\n`, "utf8");
await fs.mkdir(checksumsPath, { recursive: true });
await withPatchedEnv(
{ HERMES_HOME: hermesHome },
async () => {
const result = await refreshAdvisoryFeed({
source: "local",
localFeedPath: feedPath,
localSignaturePath: signaturePath,
localChecksumsPath: checksumsPath,
localChecksumsSignaturePath: `${checksumsPath}.sig`,
publicKeyPem,
allowUnsigned: false,
verifyChecksumManifest: false,
});
assert.equal(result.status, "verified");
},
);
});
}
await testRefreshUpdatesStateAndAttestationReadableStatus();
await testRefreshWritesCachedFeedAtomicallyWithTrailingNewline();
await testResolveFeedConfigRejectsOutsideHermesHomeStateAndCachePaths();
await testLocalChecksumArtifactsIgnoredWhenVerificationDisabled();
console.log("feed_verification.test.mjs: ok");
@@ -0,0 +1,318 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const skillRoot = path.resolve(__dirname, "..");
const guardedVerifyScript = path.join(skillRoot, "scripts", "guarded_skill_verify.mjs");
function runNode(args = [], env = {}) {
return spawnSync(process.execPath, [guardedVerifyScript, ...args], {
cwd: skillRoot,
encoding: "utf8",
env: { ...process.env, ...env },
});
}
function signPayload(payloadRaw, privateKeyPem) {
const key = crypto.createPrivateKey(privateKeyPem);
const signature = crypto.sign(null, Buffer.from(payloadRaw, "utf8"), key);
return signature.toString("base64");
}
async function withTempDir(run) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "hag-guarded-"));
try {
await run(dir);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
}
async function writeFeedArtifacts({ dir, advisories, keyPair, signatureKeyPair = keyPair }) {
const feedPath = path.join(dir, "feed.json");
const feedSigPath = `${feedPath}.sig`;
const checksumsPath = path.join(dir, "checksums.json");
const checksumsSigPath = `${checksumsPath}.sig`;
const publicKeyPath = path.join(dir, "feed-public.pem");
const feedRaw = JSON.stringify(
{
version: "1.0.0",
updated: "2026-04-20T00:00:00Z",
advisories,
},
null,
2,
);
const publicKeyPem = keyPair.publicKey.export({ type: "spki", format: "pem" });
const signingPrivatePem = signatureKeyPair.privateKey.export({ type: "pkcs8", format: "pem" });
await fs.writeFile(feedPath, feedRaw, "utf8");
const feedSignature = `${signPayload(feedRaw, signingPrivatePem)}\n`;
await fs.writeFile(feedSigPath, feedSignature, "utf8");
const sha256 = (value) => crypto.createHash("sha256").update(value, "utf8").digest("hex");
const checksumsRaw = JSON.stringify(
{
files: {
[path.basename(feedPath)]: sha256(feedRaw),
[path.basename(feedSigPath)]: sha256(feedSignature),
},
},
null,
2,
);
await fs.writeFile(checksumsPath, `${checksumsRaw}\n`, "utf8");
await fs.writeFile(checksumsSigPath, `${signPayload(`${checksumsRaw}\n`, signingPrivatePem)}\n`, "utf8");
await fs.writeFile(publicKeyPath, publicKeyPem, "utf8");
return { feedPath, feedSigPath, checksumsPath, checksumsSigPath, publicKeyPath };
}
function hermesEnv(base) {
return {
HERMES_HOME: path.join(base, ".hermes"),
HERMES_ADVISORY_FEED_SOURCE: "local",
};
}
function localFeedEnv({ feedPath, feedSigPath, checksumsPath, checksumsSigPath, publicKeyPath }) {
return {
HERMES_LOCAL_ADVISORY_FEED: feedPath,
HERMES_LOCAL_ADVISORY_FEED_SIG: feedSigPath,
HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS: checksumsPath,
HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS_SIG: checksumsSigPath,
HERMES_ADVISORY_FEED_PUBLIC_KEY: publicKeyPath,
};
}
await withTempDir(async (tempDir) => {
const keys = crypto.generateKeyPairSync("ed25519");
const artifacts = await writeFeedArtifacts({
dir: tempDir,
keyPair: keys,
advisories: [
{
id: "ADV-CONSERVATIVE",
severity: "high",
affected: ["demo-skill@>=1.2.3"],
},
],
});
const result = runNode(["--skill", "demo-skill"], {
...hermesEnv(tempDir),
...localFeedEnv(artifacts),
});
assert.equal(result.status, 42, `conservative name-only match should gate with 42: ${result.stderr}`);
assert.ok(result.stdout.includes("No --version provided; applying conservative name-based advisory gate."), result.stdout);
assert.ok(result.stdout.includes("ADV-CONSERVATIVE"), result.stdout);
});
await withTempDir(async (tempDir) => {
const keys = crypto.generateKeyPairSync("ed25519");
const artifacts = await writeFeedArtifacts({
dir: tempDir,
keyPair: keys,
advisories: [
{
id: "ADV-VERSION-MATCH",
severity: "critical",
affected: ["versioned-skill@>=2.0.0"],
},
],
});
const result = runNode(["--skill", "versioned-skill", "--version", "2.1.0"], {
...hermesEnv(tempDir),
...localFeedEnv(artifacts),
});
assert.equal(result.status, 42, `explicit version match should gate with 42: ${result.stderr}`);
assert.ok(result.stdout.includes("ADV-VERSION-MATCH"), result.stdout);
});
await withTempDir(async (tempDir) => {
const keys = crypto.generateKeyPairSync("ed25519");
const artifacts = await writeFeedArtifacts({
dir: tempDir,
keyPair: keys,
advisories: [
{
id: "ADV-NONMATCH",
severity: "medium",
affected: ["different-skill@>=1.0.0"],
},
],
});
const result = runNode(["--skill", "safe-skill", "--version", "1.0.0"], {
...hermesEnv(tempDir),
...localFeedEnv(artifacts),
});
assert.equal(result.status, 0, `non-matching skill should pass: ${result.stderr}`);
assert.ok(result.stdout.includes("No advisory matches found for candidate."), result.stdout);
});
await withTempDir(async (tempDir) => {
const keys = crypto.generateKeyPairSync("ed25519");
const artifacts = await writeFeedArtifacts({
dir: tempDir,
keyPair: keys,
advisories: [
{
id: "ADV-MALFORMED-AFFECTED",
severity: "high",
affected: ["missing-at-specifier"],
},
],
});
const result = runNode(["--skill", "missing-at-specifier", "--version", "1.0.0"], {
...hermesEnv(tempDir),
...localFeedEnv(artifacts),
});
assert.equal(result.status, 1, `malformed affected entry without '@' must fail closed: ${result.stderr}`);
assert.ok(result.stderr.includes("CRITICAL: advisory feed verification failed"), result.stderr);
});
await withTempDir(async (tempDir) => {
const keys = crypto.generateKeyPairSync("ed25519");
const artifacts = await writeFeedArtifacts({
dir: tempDir,
keyPair: keys,
advisories: [
{
id: "ADV-CONFIRM",
severity: "high",
affected: ["confirm-me@1.0.0"],
},
],
});
const result = runNode(["--skill", "confirm-me", "--version", "1.0.0", "--confirm-advisory"], {
...hermesEnv(tempDir),
...localFeedEnv(artifacts),
});
assert.equal(result.status, 0, `--confirm-advisory should allow proceed: ${result.stderr}`);
assert.ok(result.stderr.includes("WARNING: proceeding despite 1 advisory match(es)"), result.stderr);
});
await withTempDir(async (tempDir) => {
const verifierKeys = crypto.generateKeyPairSync("ed25519");
const signerKeys = crypto.generateKeyPairSync("ed25519");
const artifacts = await writeFeedArtifacts({
dir: tempDir,
keyPair: verifierKeys,
signatureKeyPair: signerKeys,
advisories: [
{
id: "ADV-BROKEN-SIG",
severity: "high",
affected: ["broken-skill@*"],
},
],
});
const strictResult = runNode(["--skill", "safe-skill", "--version", "1.0.0"], {
...hermesEnv(tempDir),
...localFeedEnv(artifacts),
});
assert.equal(strictResult.status, 1, "invalid signature must fail closed without unsigned bypass");
assert.ok(strictResult.stderr.includes("CRITICAL: advisory feed verification failed"), strictResult.stderr);
const bypassResult = runNode(["--skill", "safe-skill", "--version", "1.0.0", "--allow-unsigned"], {
...hermesEnv(tempDir),
...localFeedEnv(artifacts),
});
assert.equal(bypassResult.status, 0, `unsigned bypass should allow verification path to continue: ${bypassResult.stderr}`);
assert.ok(bypassResult.stderr.includes("WARNING: unsigned advisory bypass enabled via --allow-unsigned"), bypassResult.stderr);
const envBypassResult = runNode(["--skill", "safe-skill", "--version", "1.0.0"], {
...hermesEnv(tempDir),
...localFeedEnv(artifacts),
HERMES_ADVISORY_ALLOW_UNSIGNED_FEED: "1",
});
assert.equal(
envBypassResult.status,
0,
`env-configured unsigned bypass should allow verification path to continue: ${envBypassResult.stderr}`,
);
assert.ok(
envBypassResult.stderr.includes("WARNING: unsigned advisory bypass enabled via resolved env/config policy"),
envBypassResult.stderr,
);
});
await withTempDir(async (tempDir) => {
const invalidArgResult = runNode(["--skill", "demo-skill", "--definitely-invalid-arg"], {
...hermesEnv(tempDir),
});
assert.equal(invalidArgResult.status, 1, "unknown CLI argument must fail");
assert.ok(invalidArgResult.stderr.includes("Unknown argument: --definitely-invalid-arg"), invalidArgResult.stderr);
});
await withTempDir(async (tempDir) => {
const keys = crypto.generateKeyPairSync("ed25519");
const semverCases = [
{ label: "caret-accept", versionSpec: "^1.2.3", candidateVersion: "1.9.0", expectedStatus: 42 },
{ label: "caret-reject-major-bump", versionSpec: "^1.2.3", candidateVersion: "2.0.0", expectedStatus: 0 },
{ label: "caret-zero-minor-accept", versionSpec: "^0.2.3", candidateVersion: "0.2.99", expectedStatus: 42 },
{ label: "caret-zero-minor-reject", versionSpec: "^0.2.3", candidateVersion: "0.3.0", expectedStatus: 0 },
{ label: "caret-zero-zero-patch-accept", versionSpec: "^0.0.3", candidateVersion: "0.0.3", expectedStatus: 42 },
{ label: "caret-zero-zero-patch-reject", versionSpec: "^0.0.3", candidateVersion: "0.0.99", expectedStatus: 0 },
{ label: "tilde-accept", versionSpec: "~1.2.3", candidateVersion: "1.2.9", expectedStatus: 42 },
{ label: "tilde-reject-minor-bump", versionSpec: "~1.2.3", candidateVersion: "1.3.0", expectedStatus: 0 },
{ label: "wildcard-accept", versionSpec: "1.2.*", candidateVersion: "1.2.99", expectedStatus: 42 },
{ label: "wildcard-reject", versionSpec: "1.2.*", candidateVersion: "1.3.0", expectedStatus: 0 },
{ label: "malformed-comparator-fail-closed", versionSpec: ">>1.2.3", candidateVersion: "1.9.0", expectedStatus: 1 },
{ label: "comparator-set-fail-closed", versionSpec: ">=1 <2", candidateVersion: "1.9.0", expectedStatus: 1 },
{ label: "logical-or-fail-closed", versionSpec: "1.2 || 1.3", candidateVersion: "1.2.5", expectedStatus: 1 },
];
for (const semverCase of semverCases) {
const artifacts = await writeFeedArtifacts({
dir: tempDir,
keyPair: keys,
advisories: [
{
id: `ADV-SEMVER-${semverCase.label.toUpperCase()}`,
severity: "high",
affected: [`semver-skill@${semverCase.versionSpec}`],
},
],
});
const result = runNode(["--skill", "semver-skill", "--version", semverCase.candidateVersion], {
...hermesEnv(tempDir),
...localFeedEnv(artifacts),
});
assert.equal(
result.status,
semverCase.expectedStatus,
`${semverCase.label} expected status ${semverCase.expectedStatus}, got ${result.status}. stderr=${result.stderr}`,
);
if (semverCase.expectedStatus === 1) {
assert.ok(result.stderr.includes("CRITICAL: advisory feed verification failed"), result.stderr);
}
}
});
console.log("guarded_skill_verify.test.mjs: ok");
@@ -0,0 +1,238 @@
#!/usr/bin/env bash
set -euo pipefail
# Sandbox regression test for hermes-attestation-guardian using an isolated Docker Hermes instance.
#
# Usage:
# skills/hermes-attestation-guardian/test/hermes_attestation_sandbox_regression.sh
#
# Optional env overrides:
# IMAGE=python:3.11-slim
# HERMES_AGENT_SRC=/home/davida/.hermes/hermes-agent
# SKILL_SRC=/home/davida/clawsec/skills/hermes-attestation-guardian
# WELL_KNOWN_PORT=8765
IMAGE="${IMAGE:-python:3.11-slim}"
HERMES_AGENT_SRC="${HERMES_AGENT_SRC:-$HOME/.hermes/hermes-agent}"
SKILL_SRC="${SKILL_SRC:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
WELL_KNOWN_PORT="${WELL_KNOWN_PORT:-8765}"
SKILL_VERSION="${SKILL_VERSION:-$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8")).get("version", "0.0.2"))' "$SKILL_SRC/skill.json")}"
if ! command -v docker >/dev/null 2>&1; then
echo "ERROR: docker is required." >&2
exit 1
fi
if [[ ! -d "$HERMES_AGENT_SRC" ]]; then
echo "ERROR: HERMES_AGENT_SRC not found: $HERMES_AGENT_SRC" >&2
exit 1
fi
if [[ ! -d "$SKILL_SRC" ]]; then
echo "ERROR: SKILL_SRC not found: $SKILL_SRC" >&2
exit 1
fi
echo "[sandbox] image=$IMAGE"
echo "[sandbox] hermes-agent-src=$HERMES_AGENT_SRC"
echo "[sandbox] skill-src=$SKILL_SRC"
echo "[sandbox] skill-version=$SKILL_VERSION"
# shellcheck disable=SC2140,SC1078
# Rationale: Docker inner script is intentionally embedded as a single quoted payload
# for `bash -lc` so variables expand inside the container runtime (not on host).
docker run --rm \
-e HOME=/tmp/hermes-sandbox-home \
-e HERMES_HOME=/tmp/hermes-sandbox-home \
-e SKILL_VERSION="$SKILL_VERSION" \
-v "$HERMES_AGENT_SRC":/opt/hermes-agent:ro \
-v "$SKILL_SRC":/opt/skill-src:ro \
"$IMAGE" bash -lc "
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update >/dev/null
apt-get install -y --no-install-recommends openssl ca-certificates curl nodejs npm zip >/dev/null
cp -a /opt/hermes-agent /tmp/hermes-agent-src
python -m pip install --no-cache-dir /tmp/hermes-agent-src >/tmp/pip-install.log 2>&1
mkdir -p \"\$HOME\"
echo \"INSIDE_HOME=\$HOME\"
echo \"INSIDE_HERMES_HOME=\$HERMES_HOME\"
mkdir -p /tmp/well/.well-known/skills/hermes-attestation-guardian
cp /opt/skill-src/SKILL.md /opt/skill-src/README.md /opt/skill-src/CHANGELOG.md /opt/skill-src/skill.json /tmp/well/.well-known/skills/hermes-attestation-guardian/
cp -a /opt/skill-src/lib /opt/skill-src/scripts /tmp/well/.well-known/skills/hermes-attestation-guardian/
python3 - <<'PY'
import os,json
root='/tmp/well/.well-known/skills'
sk='hermes-attestation-guardian'
base=os.path.join(root,sk)
files=[]
for dp,_,fns in os.walk(base):
for fn in fns:
files.append(os.path.relpath(os.path.join(dp,fn),base).replace('\\\\','/'))
idx={'generated_at':'2026-04-16T00:00:00Z','skills':[{'name':sk,'version':os.environ.get('SKILL_VERSION','0.0.2'),'description':'sandbox feature test','path':f'.well-known/skills/{sk}','files':sorted(files)}]}
with open(os.path.join(root,'index.json'),'w') as f: json.dump(idx,f)
PY
python3 -m http.server $WELL_KNOWN_PORT --directory /tmp/well >/tmp/http.log 2>&1 &
HPID=\$!
sleep 1
SKILL_ZIP=/tmp/hermes-attestation-guardian.zip
(
cd /tmp/well/.well-known/skills
zip -qr "\$SKILL_ZIP" hermes-attestation-guardian
)
ZIP_SHA=\$(sha256sum "\$SKILL_ZIP" | awk '{print \$1}')
cat > /tmp/checksums.json <<EOF
{"archive":{"name":"hermes-attestation-guardian.zip","sha256":"\$ZIP_SHA"}}
EOF
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out /tmp/release-sign.key >/dev/null 2>&1
openssl pkey -in /tmp/release-sign.key -pubout -out /tmp/signing-public.pem >/dev/null 2>&1
openssl pkeyutl -sign -rawin -inkey /tmp/release-sign.key -in /tmp/checksums.json -out /tmp/checksums.sig.bin
openssl base64 -A -in /tmp/checksums.sig.bin -out /tmp/checksums.sig
PINNED_RELEASE_PUBKEY_SHA256=\$(openssl pkey -pubin -in /tmp/signing-public.pem -outform DER | sha256sum | awk '{print \$1}')
[ -s /tmp/checksums.json ]
[ -s /tmp/checksums.sig ]
ACTUAL_RELEASE_PUBKEY_SHA256=\$(openssl pkey -pubin -in /tmp/signing-public.pem -outform DER | sha256sum | awk '{print \$1}')
[ "\$ACTUAL_RELEASE_PUBKEY_SHA256" = "\$PINNED_RELEASE_PUBKEY_SHA256" ]
openssl base64 -d -A -in /tmp/checksums.sig -out /tmp/checksums.sig.verify.bin
openssl pkeyutl -verify -rawin -pubin -inkey /tmp/signing-public.pem -sigfile /tmp/checksums.sig.verify.bin -in /tmp/checksums.json >/dev/null
EXPECTED_ZIP_SHA="\$ZIP_SHA"
ACTUAL_ZIP_SHA=\$(sha256sum "\$SKILL_ZIP" | awk '{print \$1}')
[ "\$EXPECTED_ZIP_SHA" = "\$ACTUAL_ZIP_SHA" ]
set +e
INSTALL_OUT=\$(hermes skills install \"well-known:http://127.0.0.1:$WELL_KNOWN_PORT/.well-known/skills/hermes-attestation-guardian\" --yes 2>&1)
INSTALL_CODE=\$?
set -e
echo \"\$INSTALL_OUT\"
INSTALL_SAFE_ALLOWED=0
INSTALL_FORCE_OVERRIDE=0
if [ \"\$INSTALL_CODE\" -eq 0 ] && echo \"\$INSTALL_OUT\" | grep -q \"Decision: ALLOWED\"; then
INSTALL_SAFE_ALLOWED=1
else
echo \"[sandbox] install without --force was not ALLOWED; retrying with --force for feature regression coverage\" >&2
set +e
INSTALL_FORCE_OUT=\$(hermes skills install \"well-known:http://127.0.0.1:$WELL_KNOWN_PORT/.well-known/skills/hermes-attestation-guardian\" --yes --force 2>&1)
INSTALL_FORCE_CODE=\$?
set -e
echo \"\$INSTALL_FORCE_OUT\"
[ \"\$INSTALL_FORCE_CODE\" -eq 0 ]
INSTALL_FORCE_OVERRIDE=1
fi
SKILL_DIR=\"\$HERMES_HOME/skills/hermes-attestation-guardian\"
mkdir -p \"\$HERMES_HOME/security/attestations\"
echo \"alpha\" > /tmp/watch.txt
echo \"anchor-v1\" > /tmp/anchor.pem
cat > /tmp/policy.json <<EOF
{\"watch_files\": [\"/tmp/watch.txt\"], \"trust_anchor_files\": [\"/tmp/anchor.pem\"]}
EOF
node \"\$SKILL_DIR/scripts/generate_attestation.mjs\" --output \"\$HERMES_HOME/security/attestations/current.json\" --policy /tmp/policy.json --generated-at 2026-04-16T00:00:00.000Z --write-sha256 >/tmp/generate.log
DIGEST=\$(cut -d\" \" -f1 \"\$HERMES_HOME/security/attestations/current.json.sha256\")
node \"\$SKILL_DIR/scripts/verify_attestation.mjs\" --input \"\$HERMES_HOME/security/attestations/current.json\" --expected-sha256 \"\$DIGEST\" >/tmp/verify-ok.log
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out /tmp/sign.key >/dev/null 2>&1
openssl pkey -in /tmp/sign.key -pubout -out /tmp/sign.pub.pem >/dev/null 2>&1
openssl dgst -sha256 -sign /tmp/sign.key -out /tmp/current.sig \"\$HERMES_HOME/security/attestations/current.json\"
node \"\$SKILL_DIR/scripts/verify_attestation.mjs\" --input \"\$HERMES_HOME/security/attestations/current.json\" --signature /tmp/current.sig --public-key /tmp/sign.pub.pem >/tmp/verify-sig.log
cp \"\$HERMES_HOME/security/attestations/current.json\" \"\$HERMES_HOME/security/attestations/baseline.json\"
BASE_SHA=\$(sha256sum \"\$HERMES_HOME/security/attestations/baseline.json\" | cut -d\" \" -f1)
echo \"beta\" > /tmp/watch.txt
echo \"anchor-v2\" > /tmp/anchor.pem
node \"\$SKILL_DIR/scripts/generate_attestation.mjs\" --output \"\$HERMES_HOME/security/attestations/current.json\" --policy /tmp/policy.json --generated-at 2026-04-16T00:10:00.000Z >/tmp/generate-drift.log
set +e
DRIFT_OUT=\$(node \"\$SKILL_DIR/scripts/verify_attestation.mjs\" --input \"\$HERMES_HOME/security/attestations/current.json\" --baseline \"\$HERMES_HOME/security/attestations/baseline.json\" --baseline-expected-sha256 \"\$BASE_SHA\" --fail-on-severity critical 2>&1)
DRIFT_CODE=\$?
set -e
[ \"\$DRIFT_CODE\" -ne 0 ]
echo \"\$DRIFT_OUT\" | grep -Eq \"WATCHED_FILE_DRIFT|TRUST_ANCHOR_MISMATCH\"
node \"\$SKILL_DIR/scripts/setup_attestation_cron.mjs\" --every 6h --print-only > /tmp/cron-preview.log
grep -q \"Preflight review:\" /tmp/cron-preview.log
grep -q \"# >>> hermes-attestation-guardian >>>\" /tmp/cron-preview.log
# Phase 1/2/3 feature coverage: signed advisory feed verify + guarded gating + advisory scheduler helper
cat > /tmp/feed.json <<EOF
{\"version\":\"1.0.0\",\"updated\":\"2026-04-20T00:00:00Z\",\"advisories\":[{\"id\":\"CLAW-TEST-0001\",\"severity\":\"high\",\"title\":\"Test advisory\",\"affected\":[\"hermes-attestation-guardian@${SKILL_VERSION}\"],\"action\":\"Do not install without explicit acknowledgement\"}]}
EOF
node - <<'NODE'
const fs = require('node:fs');
const crypto = require('node:crypto');
const feedRaw = fs.readFileSync('/tmp/feed.json', 'utf8');
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');
const sig = crypto.sign(null, Buffer.from(feedRaw, 'utf8'), privateKey).toString('base64');
fs.writeFileSync('/tmp/feed.json.sig', sig + '\n');
fs.writeFileSync('/tmp/feed-signing-public.pem', publicKey.export({type:'spki', format:'pem'}));
const sha = (s) => crypto.createHash('sha256').update(s).digest('hex');
const checksums = {
files: {
'feed.json': sha(feedRaw),
'feed.json.sig': sha(fs.readFileSync('/tmp/feed.json.sig', 'utf8'))
}
};
const checksumsRaw = JSON.stringify(checksums);
fs.writeFileSync('/tmp/checksums-feed.json', checksumsRaw + '\n');
const csumSig = crypto.sign(null, Buffer.from(checksumsRaw + '\n', 'utf8'), privateKey).toString('base64');
fs.writeFileSync('/tmp/checksums-feed.json.sig', csumSig + '\n');
NODE
export HERMES_ADVISORY_FEED_SOURCE=local
export HERMES_LOCAL_ADVISORY_FEED=/tmp/feed.json
export HERMES_LOCAL_ADVISORY_FEED_SIG=/tmp/feed.json.sig
export HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS=/tmp/checksums-feed.json
export HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS_SIG=/tmp/checksums-feed.json.sig
export HERMES_ADVISORY_FEED_PUBLIC_KEY=/tmp/feed-signing-public.pem
node \"\$SKILL_DIR/scripts/refresh_advisory_feed.mjs\" > /tmp/refresh-advisory.log
grep -q \"\\\"status\\\":\\\"verified\\\"\" /tmp/refresh-advisory.log
node \"\$SKILL_DIR/scripts/check_advisories.mjs\" > /tmp/check-advisories.log
grep -q \"Feed verification state: verified\" /tmp/check-advisories.log
if node \"\$SKILL_DIR/scripts/guarded_skill_verify.mjs\" --skill hermes-attestation-guardian --version "\$SKILL_VERSION" > /tmp/guarded-no-confirm.log 2>&1; then
GUARD_CODE=0
else
GUARD_CODE=\$?
fi
[ \"\$GUARD_CODE\" -eq 42 ]
grep -q \"Advisory matches detected\" /tmp/guarded-no-confirm.log
node \"\$SKILL_DIR/scripts/guarded_skill_verify.mjs\" --skill hermes-attestation-guardian --version "\$SKILL_VERSION" --confirm-advisory > /tmp/guarded-confirm.log 2>&1
grep -q \"Advisory feed status: verified\" /tmp/guarded-confirm.log
node \"\$SKILL_DIR/scripts/setup_advisory_check_cron.mjs\" --every 6h --skill hermes-attestation-guardian --version "\$SKILL_VERSION" --print-only > /tmp/advisory-cron-preview.log
grep -q \"Preflight review:\" /tmp/advisory-cron-preview.log
grep -q \"# >>> hermes-attestation-guardian-advisory-check >>>\" /tmp/advisory-cron-preview.log
grep -q \"guarded_skill_verify.mjs\" /tmp/advisory-cron-preview.log
echo \"=== SANDBOX FEATURE TEST SUMMARY ===\"
if [ \"\$INSTALL_SAFE_ALLOWED\" -eq 1 ]; then
echo \"install_safe_allowed=PASS\"
else
echo \"install_safe_allowed=BLOCKED\"
fi
if [ \"\$INSTALL_FORCE_OVERRIDE\" -eq 1 ]; then
echo \"install_force_override=PASS\"
fi
echo \"release_verify_triad=PASS\"
echo \"generate_with_policy=PASS\"
echo \"verify_expected_sha=PASS\"
echo \"verify_signature=PASS\"
echo \"baseline_drift_fail_closed=PASS\"
echo \"scheduler_preview=PASS\"
echo \"advisory_feed_refresh_verified=PASS\"
echo \"advisory_feed_status_report=PASS\"
echo \"guarded_verify_requires_confirm=PASS\"
echo \"guarded_verify_confirm_override=PASS\"
echo \"advisory_scheduler_preview=PASS\"
kill \$HPID >/dev/null 2>&1 || true
wait \$HPID 2>/dev/null || true
"
echo "[sandbox] completed successfully"
@@ -0,0 +1,319 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const skillRoot = path.resolve(__dirname, "..");
const setupScript = path.join(skillRoot, "scripts", "setup_advisory_check_cron.mjs");
function runSetup(args = [], env = {}) {
return spawnSync(process.execPath, [setupScript, ...args], {
cwd: skillRoot,
encoding: "utf8",
env: { ...process.env, ...env },
});
}
async function withTempDir(run) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "hag-advisory-cron-"));
try {
await run(dir);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
}
function toBase64(value) {
return Buffer.from(String(value), "utf8").toString("base64");
}
async function installFakeCrontab(tempDir, { listStdout = "", listStatus = 0, listStderr = "" } = {}) {
const fakeBinDir = path.join(tempDir, "bin");
const logPath = path.join(tempDir, "crontab.log");
const writePath = path.join(tempDir, "crontab.write");
await fs.mkdir(fakeBinDir, { recursive: true });
const fakeCrontab = `#!/usr/bin/env node
const fs = require('node:fs');
const args = process.argv.slice(2);
const logPath = process.env.CRONTAB_LOG_PATH;
const writePath = process.env.CRONTAB_WRITE_PATH;
const listStatus = Number(process.env.CRONTAB_LIST_STATUS || '0');
const listStdout = Buffer.from(process.env.CRONTAB_LIST_STDOUT_B64 || '', 'base64').toString('utf8');
const listStderr = process.env.CRONTAB_LIST_STDERR || '';
const writeStatus = Number(process.env.CRONTAB_WRITE_STATUS || '0');
const writeStderr = process.env.CRONTAB_WRITE_STDERR || '';
if (args[0] === '-l') {
fs.appendFileSync(logPath, 'list\\n', 'utf8');
if (listStatus !== 0) {
if (listStderr) process.stderr.write(listStderr);
process.exit(listStatus);
}
process.stdout.write(listStdout);
process.exit(0);
}
if (args[0] === '-') {
fs.appendFileSync(logPath, 'write\\n', 'utf8');
fs.writeFileSync(writePath, fs.readFileSync(0, 'utf8'), 'utf8');
if (writeStatus !== 0) {
if (writeStderr) process.stderr.write(writeStderr);
process.exit(writeStatus);
}
process.exit(0);
}
process.stderr.write('unexpected crontab args: ' + args.join(' ') + '\\n');
process.exit(2);
`;
const fakeCrontabPath = path.join(fakeBinDir, "crontab");
await fs.writeFile(fakeCrontabPath, fakeCrontab, { encoding: "utf8", mode: 0o755 });
return {
fakeBinDir,
logPath,
writePath,
env: {
CRONTAB_LOG_PATH: logPath,
CRONTAB_WRITE_PATH: writePath,
CRONTAB_LIST_STATUS: String(listStatus),
CRONTAB_LIST_STDOUT_B64: toBase64(listStdout),
CRONTAB_LIST_STDERR: listStderr,
CRONTAB_WRITE_STATUS: "0",
CRONTAB_WRITE_STDERR: "",
},
};
}
async function installSelfDeletingCrontab(tempDir) {
const fakeBinDir = path.join(tempDir, "bin-self-delete");
const logPath = path.join(tempDir, "crontab.self-delete.log");
await fs.mkdir(fakeBinDir, { recursive: true });
const fakeCrontabPath = path.join(fakeBinDir, "crontab");
const fakeCrontab = `#!${process.execPath}
const fs = require('node:fs');
const args = process.argv.slice(2);
const logPath = process.env.CRONTAB_SELF_DELETE_LOG_PATH;
if (args[0] === '-l') {
fs.appendFileSync(logPath, 'list\\n', 'utf8');
fs.unlinkSync(process.argv[1]);
process.stdout.write('# existing line\\n');
process.exit(0);
}
if (args[0] === '-') {
fs.appendFileSync(logPath, 'write-ran\\n', 'utf8');
process.exit(99);
}
process.exit(2);
`;
await fs.writeFile(fakeCrontabPath, fakeCrontab, { encoding: "utf8", mode: 0o755 });
return {
fakeBinDir,
logPath,
env: {
CRONTAB_SELF_DELETE_LOG_PATH: logPath,
},
};
}
await withTempDir(async (tempDir) => {
const hermesHome = path.join(tempDir, ".hermes");
const fake = await installFakeCrontab(tempDir, {
listStdout: "# should never be read in print-only mode\n",
});
const result = runSetup(["--every", "6h", "--skill", "clawsec-feed", "--print-only"], {
HERMES_HOME: hermesHome,
PATH: `${fake.fakeBinDir}:${process.env.PATH}`,
...fake.env,
});
assert.equal(result.status, 0, `setup script failed: ${result.stderr}`);
assert.ok(result.stdout.includes("Preflight review:"), result.stdout);
assert.ok(result.stdout.includes("guarded_skill_verify.mjs"), result.stdout);
assert.ok(result.stdout.includes("Target skill: clawsec-feed"), result.stdout);
assert.ok(result.stdout.includes("# >>> hermes-attestation-guardian-advisory-check >>>"), result.stdout);
const cronLine = result.stdout
.split(/\r?\n/)
.find((line) => line.includes("guarded_skill_verify.mjs") && line.includes("--skill"));
assert.ok(cronLine, "managed cron line using guarded flow should be present");
assert.ok(cronLine.includes(process.execPath), "cron command must use absolute process.execPath node runtime");
assert.equal(
/node\s+[^\n]*guarded_skill_verify\.mjs/.test(cronLine),
false,
"must not schedule a generic 'node' invocation when process.execPath is available",
);
assert.equal(
/node\s+[^\n]*check_advisories\.mjs/.test(cronLine),
false,
"must not schedule raw advisory check entrypoint",
);
const logExists = await fs
.access(fake.logPath)
.then(() => true)
.catch(() => false);
assert.equal(logExists, false, "print-only mode must never invoke crontab");
});
await withTempDir(async (tempDir) => {
const hermesHome = path.join(tempDir, ".hermes");
const fake = await installFakeCrontab(tempDir, {
listStdout:
"# existing line\n" +
"# >>> hermes-attestation-guardian-advisory-check >>>\n" +
"0 */6 * * * HERMES_HOME=\"/old\" /usr/bin/node /old/guarded_skill_verify.mjs --skill old-skill\n" +
"# <<< hermes-attestation-guardian-advisory-check <<<\n",
});
const result = runSetup(["--apply", "--every", "12h", "--skill", "clawsec-feed", "--version", "1.2.3"], {
HERMES_HOME: hermesHome,
PATH: `${fake.fakeBinDir}:${process.env.PATH}`,
...fake.env,
});
assert.equal(result.status, 0, result.stderr);
assert.ok(result.stdout.includes("Updated user schedule table"), result.stdout);
const log = await fs.readFile(fake.logPath, "utf8");
assert.ok(log.includes("list"), "script should read schedule table");
assert.ok(log.includes("write"), "script should write updated schedule table");
const written = await fs.readFile(fake.writePath, "utf8");
assert.ok(written.includes("# existing line"), "existing non-managed entries must be preserved");
const startCount = (written.match(/# >>> hermes-attestation-guardian-advisory-check >>>/g) || []).length;
const endCount = (written.match(/# <<< hermes-attestation-guardian-advisory-check <<</g) || []).length;
assert.equal(startCount, 1, `expected exactly one managed start marker, got ${startCount}`);
assert.equal(endCount, 1, `expected exactly one managed end marker, got ${endCount}`);
assert.ok(written.includes("guarded_skill_verify.mjs"), written);
assert.ok(written.includes(process.execPath), written);
assert.ok(written.includes("--skill 'clawsec-feed'"), written);
assert.ok(written.includes("--version '1.2.3'"), written);
assert.equal(written.includes("old-skill"), false, "old managed block content must be replaced");
});
await withTempDir(async (tempDir) => {
const hermesHome = path.join(tempDir, ".hermes");
const fake = await installFakeCrontab(tempDir, {
listStatus: 1,
listStderr: "no crontab for davida\n",
});
const result = runSetup(["--apply", "--every", "6h", "--skill", "clawsec-feed"], {
HERMES_HOME: hermesHome,
PATH: `${fake.fakeBinDir}:${process.env.PATH}`,
...fake.env,
});
assert.equal(result.status, 0, result.stderr);
const log = await fs.readFile(fake.logPath, "utf8");
assert.ok(log.includes("list"), "script should attempt schedule table read");
assert.ok(log.includes("write"), "script should write new schedule table when none exists");
const written = await fs.readFile(fake.writePath, "utf8");
assert.ok(written.includes("# >>> hermes-attestation-guardian-advisory-check >>>"), written);
assert.ok(written.includes("--skill 'clawsec-feed'"), written);
});
for (const markerCase of [
{
name: "unmatched start marker",
listStdout:
"# >>> hermes-attestation-guardian-advisory-check >>>\n" +
"0 */6 * * * HERMES_HOME=\"/old\" /usr/bin/node /old/guarded_skill_verify.mjs --skill old-skill\n",
expectedError: "has no end marker",
},
{
name: "unmatched end marker",
listStdout: "# <<< hermes-attestation-guardian-advisory-check <<<\n# existing line\n",
expectedError: "unmatched managed block end",
},
{
name: "nested start marker",
listStdout:
"# >>> hermes-attestation-guardian-advisory-check >>>\n" +
"# >>> hermes-attestation-guardian-advisory-check >>>\n" +
"# <<< hermes-attestation-guardian-advisory-check <<<\n",
expectedError: "nested managed block start",
},
]) {
await withTempDir(async (tempDir) => {
const hermesHome = path.join(tempDir, ".hermes");
const fake = await installFakeCrontab(tempDir, {
listStdout: markerCase.listStdout,
});
const result = runSetup(["--apply", "--every", "6h", "--skill", "clawsec-feed"], {
HERMES_HOME: hermesHome,
PATH: `${fake.fakeBinDir}:${process.env.PATH}`,
...fake.env,
});
assert.notEqual(result.status, 0, `${markerCase.name}: expected non-zero exit status`);
assert.ok(result.stderr.includes("Malformed schedule markers"), `${markerCase.name}: ${result.stderr}`);
assert.ok(result.stderr.includes(markerCase.expectedError), `${markerCase.name}: ${result.stderr}`);
const log = await fs.readFile(fake.logPath, "utf8");
assert.ok(log.includes("list"), `${markerCase.name}: schedule table read should happen`);
assert.equal(log.includes("write"), false, `${markerCase.name}: write must not occur`);
const writeExists = await fs
.access(fake.writePath)
.then(() => true)
.catch(() => false);
assert.equal(writeExists, false, `${markerCase.name}: no written schedule table expected`);
});
}
await withTempDir(async (tempDir) => {
const hermesHome = path.join(tempDir, ".hermes");
const result = runSetup(["--apply", "--every", "6h", "--skill", "clawsec-feed"], {
HERMES_HOME: hermesHome,
PATH: path.join(tempDir, "missing-bin"),
});
assert.notEqual(result.status, 0, "spawnSync ENOENT while reading crontab should fail");
assert.ok(result.stderr.includes("Failed reading schedule table"), result.stderr);
assert.ok(result.stderr.includes("code=ENOENT"), result.stderr);
assert.ok(result.stderr.includes("message="), result.stderr);
assert.ok(result.stderr.includes("stack="), result.stderr);
});
await withTempDir(async (tempDir) => {
const hermesHome = path.join(tempDir, ".hermes");
const fake = await installSelfDeletingCrontab(tempDir);
const result = runSetup(["--apply", "--every", "6h", "--skill", "clawsec-feed"], {
HERMES_HOME: hermesHome,
PATH: fake.fakeBinDir,
...fake.env,
});
assert.notEqual(result.status, 0, "spawnSync ENOENT while writing crontab should fail");
assert.ok(result.stderr.includes("Failed writing schedule table"), result.stderr);
assert.ok(result.stderr.includes("code=ENOENT"), result.stderr);
assert.ok(result.stderr.includes("message="), result.stderr);
assert.ok(result.stderr.includes("stack="), result.stderr);
const log = await fs.readFile(fake.logPath, "utf8");
assert.ok(log.includes("list"), "self-deleting fake crontab should run for list before write failure");
assert.equal(log.includes("write-ran"), false, "write command should fail before executing fake crontab");
});
console.log("setup_advisory_check_cron.test.mjs: ok");
@@ -63,6 +63,48 @@ await withTempDir(async (tempDir) => {
assert.ok(result.stdout.includes("policy'\\''withquote.json"), "single quotes must be shell-escaped in cron command");
});
await withTempDir(async (tempDir) => {
const hermesHome = path.join(tempDir, ".hermes");
const fakeBinDir = path.join(tempDir, "bin");
const logPath = path.join(tempDir, "crontab.log");
const writePath = path.join(tempDir, "crontab.write");
await fs.mkdir(fakeBinDir, { recursive: true });
const fakeCrontab = `#!/usr/bin/env node
const fs = require('node:fs');
const args = process.argv.slice(2);
const logPath = ${JSON.stringify(logPath)};
const writePath = ${JSON.stringify(writePath)};
if (args[0] === '-l') {
fs.appendFileSync(logPath, 'list-empty\\n', 'utf8');
process.stderr.write('no crontab for test-user\\n');
process.exit(1);
}
if (args[0] === '-') {
fs.appendFileSync(logPath, 'write\\n', 'utf8');
fs.writeFileSync(writePath, fs.readFileSync(0, 'utf8'), 'utf8');
process.exit(0);
}
process.stderr.write('unexpected crontab args: ' + args.join(' ') + '\\n');
process.exit(2);
`;
const fakeCrontabPath = path.join(fakeBinDir, "crontab");
await fs.writeFile(fakeCrontabPath, fakeCrontab, { encoding: "utf8", mode: 0o755 });
const result = runSetup(["--apply"], {
HERMES_HOME: hermesHome,
PATH: `${fakeBinDir}:${process.env.PATH}`,
});
assert.equal(result.status, 0, result.stderr);
assert.ok(result.stdout.includes("Updated user schedule table"), result.stdout);
const log = await fs.readFile(logPath, "utf8");
assert.ok(log.includes("list-empty"), "script should treat empty-crontab stderr as no existing schedule");
assert.ok(log.includes("write"), "script should still write managed block on fresh machines");
const written = await fs.readFile(writePath, "utf8");
assert.ok(written.includes("# >>> hermes-attestation-guardian >>>"), written);
});
await withTempDir(async (tempDir) => {
const hermesHome = path.join(tempDir, ".hermes");
const fakeBinDir = path.join(tempDir, "bin");
@@ -97,13 +139,12 @@ process.exit(2);
});
assert.notEqual(result.status, 0, "unmatched start marker must fail closed");
assert.ok(result.stderr.includes("Malformed crontab markers"), result.stderr);
assert.ok(result.stderr.includes("Malformed schedule markers"), result.stderr);
const log = await fs.readFile(logPath, "utf8");
assert.ok(log.includes("list"), "script should read crontab before writing");
const wrote = await fs.access(writePath).then(() => true).catch(() => false);
assert.equal(wrote, false, "script must not write crontab on malformed marker block");
});
await withTempDir(async (tempDir) => {
const hermesHome = path.join(tempDir, ".hermes");
const fakeBinDir = path.join(tempDir, "bin");
@@ -138,7 +179,7 @@ process.exit(2);
});
assert.notEqual(result.status, 0, "unmatched end marker must fail closed");
assert.ok(result.stderr.includes("Malformed crontab markers"), result.stderr);
assert.ok(result.stderr.includes("Malformed schedule markers"), result.stderr);
const log = await fs.readFile(logPath, "utf8");
assert.ok(log.includes("list"), "script should read crontab before writing");
const wrote = await fs.access(writePath).then(() => true).catch(() => false);
@@ -179,7 +220,7 @@ process.exit(2);
});
assert.notEqual(result.status, 0, "nested start marker must fail closed");
assert.ok(result.stderr.includes("Malformed crontab markers"), result.stderr);
assert.ok(result.stderr.includes("Malformed schedule markers"), result.stderr);
const log = await fs.readFile(logPath, "utf8");
assert.ok(log.includes("list"), "script should read crontab before writing");
const wrote = await fs.access(writePath).then(() => true).catch(() => false);
@@ -0,0 +1,16 @@
# Changelog
## [0.0.1-beta2] - 2026-05-13
### Security
- Added explicit signed release artifact verification instructions for standalone installs, including `checksums.json`, `checksums.sig`, `signing-public.pem`, archive hash verification, and `SKILL.md`/`skill.json` checksum checks.
### Changed
- Re-release skill payload metadata after excluding test-only files from release SBOMs and archives.
## [0.0.1-beta1] - 2026-05-10
- Added baseline skill metadata, frontmatter, and implementation specification.
- Reserved folder structure for Hermes traffic-monitoring runtime code, posture export, and tests.
- Beta release notes: this release is a scaffold/spec baseline and does not yet ship active runtime proxy interception.
- Beta release notes: defaults remain non-invasive (no automatic traffic mutation or enforcement enabled by default).
+18
View File
@@ -0,0 +1,18 @@
# Hermes Traffic Guardian
Baseline skill for Hermes runtime traffic monitoring.
This package is intentionally a spec scaffold. Builders should add the Hermes-specific monitor implementation here while preserving the safety contract in `SKILL.md` and `SPEC.md`.
## Intended Capability
- detect outbound secret exfiltration in Hermes HTTP/HTTPS traffic
- detect inbound command-injection and tool-abuse payloads
- write redacted local JSONL findings
- export monitor posture for `hermes-attestation-guardian`
- provide explicit start, stop, status, and log-query commands
## Builder Notes
Keep runtime ownership in this skill. `hermes-attestation-guardian` should only attest this skill's state, config, and output fingerprints.
+148
View File
@@ -0,0 +1,148 @@
---
name: hermes-traffic-guardian
version: 0.0.1-beta2
description: Hermes runtime traffic monitoring baseline for opt-in proxy inspection, egress detection, and attestation-aware traffic posture.
homepage: https://clawsec.prompt.security
author: prompt-security
license: AGPL-3.0-or-later
hermes:
emoji: "TG"
requires:
bins: [node, python3]
---
# Hermes Traffic Guardian
This is a baseline specification skill. It intentionally does not ship a proxy or runtime implementation yet.
## Release Artifact Verification
For standalone installs, verify the signed release manifest before trusting `SKILL.md`, `skill.json`, or the archive. The `skill.json` file is the package metadata/SBOM source, and the release pipeline signs `checksums.json` with the ClawSec release key.
```bash
set -euo pipefail
SKILL_NAME="hermes-traffic-guardian"
VERSION="0.0.1-beta2"
REPO="prompt-security/clawsec"
TAG="${SKILL_NAME}-v${VERSION}"
BASE="https://github.com/${REPO}/releases/download/${TAG}"
ZIP_NAME="${SKILL_NAME}-v${VERSION}.zip"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
RELEASE_PUBKEY_SHA256="711424e4535f84093fefb024cd1ca4ec87439e53907b305b79a631d5befba9c8"
curl -fsSL "$BASE/checksums.json" -o "$TMP_DIR/checksums.json"
curl -fsSL "$BASE/checksums.sig" -o "$TMP_DIR/checksums.sig"
curl -fsSL "$BASE/signing-public.pem" -o "$TMP_DIR/signing-public.pem"
curl -fsSL "$BASE/$ZIP_NAME" -o "$TMP_DIR/$ZIP_NAME"
curl -fsSL "$BASE/SKILL.md" -o "$TMP_DIR/SKILL.md"
curl -fsSL "$BASE/skill.json" -o "$TMP_DIR/skill.json"
ACTUAL_PUBKEY_SHA256="$(openssl pkey -pubin -in "$TMP_DIR/signing-public.pem" -outform DER | shasum -a 256 | awk '{print $1}')"
if [ "$ACTUAL_PUBKEY_SHA256" != "$RELEASE_PUBKEY_SHA256" ]; then
echo "ERROR: signing-public.pem fingerprint mismatch" >&2
exit 1
fi
openssl base64 -d -A -in "$TMP_DIR/checksums.sig" -out "$TMP_DIR/checksums.sig.bin"
openssl pkeyutl -verify -rawin -pubin \
-inkey "$TMP_DIR/signing-public.pem" \
-sigfile "$TMP_DIR/checksums.sig.bin" \
-in "$TMP_DIR/checksums.json" >/dev/null
hash_file() {
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | awk '{print $1}'
else
sha256sum "$1" | awk '{print $1}'
fi
}
verify_manifest_file() {
asset="$1"
path="$2"
expected="$(jq -r --arg asset "$asset" '.files[$asset].sha256 // empty' "$TMP_DIR/checksums.json")"
if [ -z "$expected" ]; then
echo "ERROR: checksums.json missing $asset" >&2
exit 1
fi
actual="$(hash_file "$path")"
if [ "$actual" != "$expected" ]; then
echo "ERROR: checksum mismatch for $asset" >&2
exit 1
fi
}
expected_archive="$(jq -r '.archive.sha256 // empty' "$TMP_DIR/checksums.json")"
if [ -z "$expected_archive" ]; then
echo "ERROR: checksums.json missing archive.sha256" >&2
exit 1
fi
actual_archive="$(hash_file "$TMP_DIR/$ZIP_NAME")"
if [ "$actual_archive" != "$expected_archive" ]; then
echo "ERROR: archive checksum mismatch" >&2
exit 1
fi
verify_manifest_file "SKILL.md" "$TMP_DIR/SKILL.md"
verify_manifest_file "skill.json" "$TMP_DIR/skill.json"
echo "Signed release manifest, archive, SKILL.md, and skill.json verified."
```
Only install or extract the archive after this verification succeeds.
## Scope
Builders should use this skill as the Hermes landing zone for runtime traffic monitoring:
- operator-scoped HTTP proxy inspection
- optional HTTPS inspection with per-process CA trust
- outbound exfiltration detection
- inbound injection detection
- redacted local threat logs
- status export for `hermes-attestation-guardian`
Do not add proxy runtime ownership to `hermes-attestation-guardian`. That skill should attest this monitor's status and configuration, not run it.
## Safety Contract
- Opt-in only.
- Detect-and-log by default.
- No automatic system CA installation.
- No global proxy environment changes.
- No blocking in the first implementation.
- Redact secrets before logs, summaries, or attestation-linked outputs.
- Keep all state under `HERMES_TRAFFIC_GUARDIAN_HOME` or `$HERMES_HOME/security/traffic-guardian`.
## Builder Entry Points
Read `SPEC.md` before implementing. Use the placeholder folders as follows:
| Path | Intended use |
|---|---|
| `lib/` | Detector rules, redaction, posture export, report formatting |
| `scripts/` | Start, stop, status, config validation, log query, attestation export helpers |
| `test/` | Unit tests, proxy fixture tests, redaction tests, attestation export tests |
## Required First Implementation Behavior
1. Validate config without starting the proxy.
2. Start monitor in foreground or explicit background mode.
3. Scope proxy environment variables to the target Hermes service or CLI process.
4. Inspect HTTP request/response text up to a bounded byte limit.
5. Support optional HTTPS MITM only when the operator supplies per-process trust configuration.
6. Emit JSONL findings with redacted snippets.
7. Export a small posture JSON file that `hermes-attestation-guardian` can include as a trust anchor or watched file.
## Out of Scope for v0.0.1 Implementation
- automatic system trust-store mutation
- transparent network interception
- default blocking
- sending traffic to external services
- collecting full request/response bodies

Some files were not shown because too many files have changed in this diff Show More