Skip to content

feat: add Oathe behavioral security scan integration#562

Open
liamkauffman wants to merge 7 commits intoopenclaw:mainfrom
liamkauffman:feat/oathe-behavioral-security
Open

feat: add Oathe behavioral security scan integration#562
liamkauffman wants to merge 7 commits intoopenclaw:mainfrom
liamkauffman:feat/oathe-behavioral-security

Conversation

@liamkauffman
Copy link

@liamkauffman liamkauffman commented Feb 28, 2026

Summary

Adds Oathe behavioral security scanning as a third scanner alongside VirusTotal and OpenClaw's LLM analysis. Oathe runs skills in a sandboxed VM and monitors actual runtime behavior (filesystem, network, process activity), catching attack vectors that static analysis misses.

  • Publish trigger: fires a scan request to Oathe when a new version is published
  • Cron poller: checks pending results every 10 minutes, resolves completed audits, re-submits stale ones
  • UI: renders Oathe results in the existing Security Scan panel (panel + badge variants)
  • Opt-in: gated behind OATHE_API_URL env var — zero impact when unset

How it works

Skill published
  → skillPublish.ts schedules notifyOathe action
    → POST /api/submit to Oathe API
      → oatheAnalysis.status = "pending" on skillVersion

Every 10 min (cron):
  → fetchPendingOatheResults checks GET /api/skill/{slug}/latest
    → If complete: store score, verdict, dimensions, reportUrl
    → If stale >1h: re-submit with force_rescan
    → If stale >24h: mark as error

What the UI looks like

All three scanners together (VT + OpenClaw + Oathe)

panel-all

Oathe "Safe" result with expanded dimensions

panel-safe

Oathe "Caution" result with behavioral findings

panel-caution

Pending state

panel-pending

Files changed

File What
convex/oathe.ts Oathe actions: submit on publish, cron poll for results
convex/schema.ts oatheAnalysis field on skillVersions
convex/crons.ts 10-minute cron for pending result polling
convex/skills.ts Internal mutations/queries for Oathe data
convex/lib/skillPublish.ts Trigger notifyOathe on publish
src/components/SkillSecurityScanResults.tsx Oathe icon, status mapping, panel + badge rendering
src/components/SkillHeader.tsx Pass oatheAnalysis to SecurityScanResults
src/components/SkillVersionsPanel.tsx Pass oatheAnalysis to badge variant
src/styles.css .scan-result-icon-oathe, .version-scan-icon-oathe, .scan-status-unknown
.github/workflows/secret-scan.yml Pin TruffleHog to SHA (security hygiene)
convex/lib/globalStats.ts JSDoc + concurrency comments
convex/skills.countPublicSkills.test.ts Updated tests for perf fix

Opt-in design

Set OATHE_API_URL in your Convex environment to enable. When unset:

  • notifyOathe exits immediately with a log line
  • Cron poller exits immediately
  • UI shows nothing (no Oathe row in Security Scan)

No new dependencies. No impact to existing VT or OpenClaw scanning. Fully env-var-gated — can be enabled per-environment when ready.

Strategic alignment

Roadmap alignment

OpenClaw's Trust & Security page identifies supply chain security as critical: "Skills are code that runs in your agent's context — supply chain security is critical." The published security program's Phase 3 (code review + dynamic testing) covers behavioral analysis — an area where existing scanners have a known gap. VirusTotal catches known malware signatures, and OpenClaw's LLM scanner evaluates skill bundles statically, but neither observes actual runtime behavior: exfiltration over external URLs, social-engineered payloads, or adversarial SKILL.md instructions that don't surface in source code.

The Security Enhancements RFC discusses sandboxing as a defensive goal. This PR contributes a community-built integration for exactly that — a behavioral sandbox that executes skills in an isolated VM and monitors filesystem, network, and process activity.

Complementary PRs

The Oathe team has confirmed support (see issue comment).

Test plan

  • bun run lint — 0 warnings, 0 errors
  • bun run build — client + SSR + Nitro all pass
  • bun run test — 375 tests pass (39 pre-existing html-encoding-sniffer ESM errors unrelated to this PR)
  • Local visual testing: verified safe, caution, pending states and all three scanners rendering together
  • End-to-end cron test: set skill to pending → ran fetchPendingOatheResults → resolved with real Oathe API data (Caution 72/100)
  • Report URL links to rendered page at oathe.ai/report/{slug}, not raw API JSON
  • Reviewer: confirm nothing breaks when OATHE_API_URL is not set (all Oathe code is skipped)

AI disclosure

This PR was developed with AI assistance (Claude). All code was reviewed, tested locally with seeded and live API data, and verified against the existing VT/OpenClaw scan UI patterns.

Closes #288

liamkauffman and others added 4 commits February 26, 2026 18:12
- countPublicSkills returns 0 instead of full table scan on fresh deploy
- adjustGlobalPublicSkillsCount logs warning instead of silent swallow
- Remove unused countPublicSkillsForGlobalStats import
- Tests: moderationFlags malware exclusion, undefined moderationStatus
- Tests: mock validates query builder callback filters softDeletedAt
- Docs: ordering requirement JSDoc, OCC contention trade-off comment
Add automatic security scanning via Oathe API:
- Submit skills for audit on publish (fire-and-forget)
- Cron polls every 10min for pending results, re-submits after 1hr, times out at 24hr
- Display Oathe verdict, score, dimensions alongside VT and LLM analysis
- New oatheAnalysis field on skillVersions schema

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add scan-result-icon-oathe, version-scan-icon-oathe (--seafoam), and
scan-status-unknown (gray fallback) CSS classes referenced by
SkillSecurityScanResults.tsx. Revert Footer.tsx "Powered by Convex"
removal to keep PR scoped to Oathe integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Change reportUrl from cloud.oathe.ai/api/skill/{slug}/latest (JSON)
to oathe.ai/report/{slug} (rendered report page). Remove unused
apiUrl parameter from mapReportToAnalysis.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@vercel
Copy link
Contributor

vercel bot commented Feb 28, 2026

@liamkauffman is attempting to deploy a commit to the Amantus Machina Team on Vercel.

A member of the Team first needs to authorize it.

…alidation

- Add submittedAt field to oatheAnalysis schema so cron escalation logic
  (force_rescan after 1h, timeout after 24h) works correctly instead of
  resetting pendingSince every cycle via checkedAt
- Add 17 unit tests for scoreToRating, verdictToStatus, mapReportToAnalysis
- Add protocol validation (isSafeUrl) for reportUrl href rendering
- Export pure functions via __test for testability

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@liamkauffman liamkauffman marked this pull request as ready for review February 28, 2026 07:07
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d732831dbd

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

versionId,
oatheAnalysis: {
status: 'pending',
submittedAt: pendingSince,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Refresh pending timestamp after force-rescan

After the 1h retry path runs, submittedAt is written back as the old pendingSince value, but pendingAge is computed from that same field on every cron run. In fetchPendingOatheResults, this keeps the age permanently > ONE_HOUR_MS, so the skill is force-resubmitted every 10-minute cycle instead of waiting, which can spam Oathe and trigger avoidable rate limiting for any long-running audit.

Useful? React with 👍 / 👎.

Comment on lines +2695 to +2699
const allSkills = await ctx.db
.query('skills')
.withIndex('by_active_updated', (q) => q.eq('softDeletedAt', undefined))
.order('desc')
.take(poolSize)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid starving older pending Oathe scans

getSkillsPendingOatheInternal only samples the most recently updated skills (by_active_updated descending) and caps the pool to 1000 records. If enough newer skill updates happen, older pending scans fall outside that window and will never be returned to the cron poller, so they can remain stuck in pending indefinitely without timeout/resubmit handling.

Useful? React with 👍 / 👎.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Feb 28, 2026

Greptile Summary

This PR integrates Oathe as a third behavioral security scanner alongside VirusTotal and OpenClaw's LLM analysis. The integration is well-structured and follows the existing VT/LLM patterns closely — schema, mutations, cron, and UI rendering are all consistent with how the other scanners are wired.

Notable issues found:

  • Hardcoded production base URL (convex/oathe.ts lines 166 and 304): https://clawhub.ai is hardcoded in both notifyOathe and the cron re-submit path. In dev/staging environments this will submit production skill URLs to Oathe, producing scan results against the live site rather than the environment being tested. A CLAWHUB_BASE_URL env var (defaulting to https://clawhub.ai) would make this environment-safe.
  • N+1 query in getSkillsPendingOatheInternal (convex/skills.ts lines 2694–2712): The cron fetches up to 1,000 skills via the by_active_updated index and then does a sequential ctx.db.get() per skill to check for pending Oathe status — up to 2,000 document reads every 10 minutes. Parallelising the version lookups with Promise.all would reduce latency, and a smaller pool multiplier or a dedicated pending-queue table would reduce read costs.
  • isSafeUrl allows http:// (SkillSecurityScanResults.tsx): Minor — in practice the URL is always written as https://oathe.ai/report/… server-side, but restricting the check to https:// only would be a safer default.

Confidence Score: 3/5

  • Safe to merge with caveats — the hardcoded production URL in notifyOathe and the cron re-submit path is a real behavioural bug that should be addressed before the integration is enabled in any non-production environment.
  • The overall structure and opt-in design are sound, and the UI rendering, schema, and cron wiring are clean. The hardcoded https://clawhub.ai base URL is a concrete logic bug (dev/staging will submit production URLs to Oathe), and the N+1 scan query has measurable cost implications at scale. Neither is a security vulnerability, but the URL bug in particular will produce silent, hard-to-debug misfires in non-production deployments.
  • convex/oathe.ts (hardcoded URL on lines 166 and 304) and convex/skills.ts (N+1 query in getSkillsPendingOatheInternal).

Last reviewed commit: d732831

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

14 files reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

convex/oathe.ts Outdated
return
}

const skillUrl = `https://clawhub.ai/${skill.slug}`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded production URL will misfire in dev/staging

https://clawhub.ai is hardcoded as the skill base URL in two places (notifyOathe on line 166 and the cron re-submit on line 304). In any non-production Convex deployment (dev, staging, preview), Oathe will scan the live production URL instead of the environment-appropriate one. This means:

  • Skills not yet published to production will get "not found" or stale scan results from Oathe.
  • Dev/staging publish events will silently inflate Oathe's scan queue with production URLs.

The Oathe API URL is already gated on OATHE_API_URL, so adding a companion env var for the base URL would be consistent. For example:

const skillBaseUrl = process.env.CLAWHUB_BASE_URL ?? 'https://clawhub.ai'
const skillUrl = `${skillBaseUrl}/${skill.slug}`

The same fix is needed at line 304 in the cron re-submit path.

Prompt To Fix With AI
This is a comment left during a code review.
Path: convex/oathe.ts
Line: 166

Comment:
**Hardcoded production URL will misfire in dev/staging**

`https://clawhub.ai` is hardcoded as the skill base URL in two places (`notifyOathe` on line 166 and the cron re-submit on line 304). In any non-production Convex deployment (dev, staging, preview), Oathe will scan the *live production* URL instead of the environment-appropriate one. This means:
- Skills not yet published to production will get "not found" or stale scan results from Oathe.
- Dev/staging publish events will silently inflate Oathe's scan queue with production URLs.

The Oathe API URL is already gated on `OATHE_API_URL`, so adding a companion env var for the base URL would be consistent. For example:

```typescript
const skillBaseUrl = process.env.CLAWHUB_BASE_URL ?? 'https://clawhub.ai'
const skillUrl = `${skillBaseUrl}/${skill.slug}`
```

The same fix is needed at line 304 in the cron re-submit path.

How can I resolve this? If you propose a fix, please make it concise.

convex/skills.ts Outdated
Comment on lines 2694 to 2712
const poolSize = Math.min(Math.max(limit * 20, 200), 1000)
const allSkills = await ctx.db
.query('skills')
.withIndex('by_active_updated', (q) => q.eq('softDeletedAt', undefined))
.order('desc')
.take(poolSize)

const results: Array<{
skillId: Id<'skills'>
versionId: Id<'skillVersions'>
slug: string
pendingSince: number
}> = []

for (const skill of allSkills) {
if (results.length >= limit) break
if (!skill.latestVersionId) continue

const version = await ctx.db.get(skill.latestVersionId)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sequential N+1 reads up to 1000 documents per cron run

With limit=50 (the default batchSize from the cron), poolSize evaluates to Math.min(Math.max(50 * 20, 200), 1000) = 1000. The loop then does a sequential ctx.db.get(skill.latestVersionId) for each of the 1000 fetched skills, resulting in up to 2,000 document reads every 10 minutes just to find 50 pending skills. The reads are sequential (not Promise.all), so this adds latency on top of the read cost.

This is especially wasteful in the steady state where most skills don't have a pending Oathe scan — the full 1,000-skill pool will be scanned without finding anything to return.

Two suggestions:

  1. Short-term: Replace the sequential loop with parallel reads using Promise.all to reduce latency, and consider a smaller poolSize multiplier now that the cron runs frequently (every 10 min).
  2. Longer-term: Store pending Oathe version IDs in a lightweight auxiliary table (e.g., oathePendingQueue) and index on it, avoiding the skills scan entirely. The existing VT integration likely has a comparable pattern worth following.
Prompt To Fix With AI
This is a comment left during a code review.
Path: convex/skills.ts
Line: 2694-2712

Comment:
**Sequential N+1 reads up to 1000 documents per cron run**

With `limit=50` (the default batchSize from the cron), `poolSize` evaluates to `Math.min(Math.max(50 * 20, 200), 1000) = 1000`. The loop then does a sequential `ctx.db.get(skill.latestVersionId)` for each of the 1000 fetched skills, resulting in up to **2,000 document reads every 10 minutes** just to find 50 pending skills. The reads are sequential (not `Promise.all`), so this adds latency on top of the read cost.

This is especially wasteful in the steady state where most skills don't have a pending Oathe scan — the full 1,000-skill pool will be scanned without finding anything to return.

Two suggestions:
1. **Short-term**: Replace the sequential loop with parallel reads using `Promise.all` to reduce latency, and consider a smaller `poolSize` multiplier now that the cron runs frequently (every 10 min).
2. **Longer-term**: Store pending Oathe version IDs in a lightweight auxiliary table (e.g., `oathePendingQueue`) and index on it, avoiding the skills scan entirely. The existing VT integration likely has a comparable pattern worth following.

How can I resolve this? If you propose a fix, please make it concise.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Feb 28, 2026

Additional Comments (1)

src/components/SkillSecurityScanResults.tsx
isSafeUrl permits http:// links

return url.startsWith('https://') || url.startsWith('http://')

The reportUrl is always written server-side as https://oathe.ai/report/${slug}, so http:// is never reached in practice today. However, the permissive check means any future code path that writes an http:// value would render as a mixed-content anchor on the HTTPS site without any warning. Restricting to https:// only would be a safer default:

function isSafeUrl(url: string): boolean {
  return url.startsWith('https://')
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/components/SkillSecurityScanResults.tsx
Line: 1083-1085

Comment:
**`isSafeUrl` permits `http://` links**

```typescript
return url.startsWith('https://') || url.startsWith('http://')
```

The `reportUrl` is always written server-side as `https://oathe.ai/report/${slug}`, so `http://` is never reached in practice today. However, the permissive check means any future code path that writes an `http://` value would render as a mixed-content anchor on the HTTPS site without any warning. Restricting to `https://` only would be a safer default:

```suggestion
function isSafeUrl(url: string): boolean {
  return url.startsWith('https://')
}
```

How can I resolve this? If you propose a fix, please make it concise.

- Add rescanAt field to prevent force-rescan from firing every cron cycle (P1)
- Parallelize version reads with Promise.all in getSkillsPendingOatheInternal
- Replace hardcoded https://clawhub.ai with SITE_URL env var in both paths
- Restrict isSafeUrl to https:// only

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5e1ff4904a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

convex/oathe.ts Outdated
}

const siteUrl = (process.env.SITE_URL ?? 'https://clawhub.ai').replace(/\/+$/, '')
const skillUrl = `${siteUrl}/${skill.slug}`

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Submit Oathe scans with canonical skill URL

notifyOathe constructs skill_url as ${siteUrl}/${skill.slug}, but skill detail routes are defined as /$owner/$slug (see src/routes/$owner/$slug.tsx) and there is no single-segment skill route under src/routes. When Oathe resolves the submitted URL, this can target a non-skill page/404, so behavioral scans run against the wrong target (and the same pattern is reused in the force-rescan submit path), which undermines scan correctness for published skills.

Useful? React with 👍 / 👎.

The Oathe API requires two-segment skill URLs (owner/slug) but we were
sending one-segment URLs (slug only), causing 400s on submit and silent
404s on poll. Look up owner handle and skip submission if missing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d1f58ea499

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +173 to +175
if (!ownerHandle) {
console.warn(`[oathe] Skipping ${skill.slug}: owner has no handle`)
return

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Submit scans even when owner handle is missing

notifyOathe exits early when ownerHandle is empty, but user handles are optional and skill URLs elsewhere already fall back to other owner identifiers, so this path silently skips behavioral scanning for valid published skills from users without a populated handle. In that scenario no Oathe request is sent and no oatheAnalysis state is written, leaving those versions permanently unscanned even when OATHE_API_URL is configured.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request] Behavioral security signal alongside VT + LLM scanners

1 participant