fix(portability): harden cross-platform path handling and install workflows (#62)

* docs: add agent collaboration and git safety rules to AGENTS.md

* fix(portability): harden cross-platform path handling and install workflows

- add shared path resolution utility for advisory guardian components
- expand and normalize home-path tokens: ~, $HOME, ${HOME}, %USERPROFILE%, $env:USERPROFILE
- reject unresolved/escaped home tokens to prevent literal "$HOME" directory creation
- fix install/runtime path handling in:
  - openclaw-audit-watchdog setup_cron and suppression config loader
  - clawsec-suite advisory hook handler, suppression loader, and guarded installer
- remove hardcoded Homebrew binary assumptions in watchdog scripts/tests
- add LF enforcement via .gitattributes to reduce CRLF script breakage
- expand CI Node checks to linux/macos/windows matrix
- add cross-platform test coverage for path expansion and token rejection
- update README and SKILL docs with bash/zsh/PowerShell-safe path guidance
- add compatibility deliverables:
  - docs/COMPATIBILITY_REPORT.md
  - docs/REMEDIATION_PLAN.md
  - docs/PLATFORM_VERIFICATION.md

Validation:
- node skills/clawsec-suite/test/path_resolution.test.mjs
- node skills/clawsec-suite/test/guarded_install.test.mjs
- node skills/clawsec-suite/test/advisory_suppression.test.mjs
- node skills/openclaw-audit-watchdog/test/suppression_config.test.mjs
- node skills/openclaw-audit-watchdog/test/render_report_suppression.test.mjs

* fix(advisory): avoid fail-open on invalid path vars and cover watchdog tests

* docs: move signing runbooks into docs folder

* docs: remove root-level signing runbooks after move

* chore(clawsec-suite): bump version to 0.1.3

* chore(openclaw-audit-watchdog): bump version to 0.1.1

* docs(changelog): add entries for clawsec-suite 0.1.3 and watchdog 0.1.1

* docs(changelog): credit @aldodelgado for PR #62 contributions

* feat(clawsec-suite): scope advisories to openclaw application

* fix(ci): run advisory scope tests without TypeScript loader

---------

Co-authored-by: David Abutbul <David.a@prompt.security>
This commit is contained in:
Aldo Delgado
2026-02-25 06:24:31 -05:00
committed by GitHub
parent 73dd63f714
commit 7cdb4ab7e2
34 changed files with 1278 additions and 78 deletions
+24
View File
@@ -0,0 +1,24 @@
* text=auto
# Keep executable/script sources LF across platforms.
*.sh text eol=lf
*.bash text eol=lf
*.zsh text eol=lf
*.mjs text eol=lf
*.js text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.py text eol=lf
# Keep config/docs deterministic in CI and local tooling.
*.md text eol=lf
*.json text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.toml text eol=lf
*.pem text eol=lf
# Binary assets.
*.png binary
*.ico binary
*.ttf binary
+28 -2
View File
@@ -10,8 +10,15 @@ permissions: read-all
jobs:
lint-typescript:
name: Lint TypeScript/React
runs-on: ubuntu-latest
name: Lint TypeScript/React (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
- macos-latest
- windows-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
@@ -98,3 +105,22 @@ jobs:
run: node skills/clawsec-suite/test/feed_verification.test.mjs
- name: Guarded Install Tests
run: node skills/clawsec-suite/test/guarded_install.test.mjs
- name: Advisory Suppression Tests
run: node skills/clawsec-suite/test/advisory_suppression.test.mjs
- name: Path Resolution Tests
run: node skills/clawsec-suite/test/path_resolution.test.mjs
- name: Advisory Application Scope Tests
run: node skills/clawsec-suite/test/advisory_application_scope.test.mjs
openclaw-audit-watchdog-tests:
name: OpenClaw Audit Watchdog Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: '20'
- name: Suppression Config Tests
run: node skills/openclaw-audit-watchdog/test/suppression_config.test.mjs
- name: Render Report Suppression Tests
run: node skills/openclaw-audit-watchdog/test/render_report_suppression.test.mjs
+57 -12
View File
@@ -1,12 +1,57 @@
- Delete unused or obsolete files when your changes make them irrelevant (refactors, feature removals, etc.), and revert files only when the change is yours or explicitly requested. If a git operation leaves you unsure about other agents' in-flight work, stop and coordinate instead of deleting.
- **Before attempting to delete a file to resolve a local type/lint failure, stop and ask the user.** Other agents are often editing adjacent files; deleting their work to silence an error is never acceptable without explicit approval.
- NEVER edit `.env` or any environment variable files—only the user may change them.
- Coordinate with other agents before removing their in-progress edits—don't revert or delete work you didn't author unless everyone agrees.
- Moving/renaming and restoring files is allowed.
- ABSOLUTELY NEVER run destructive git operations (e.g., `git reset --hard`, `rm`, `git checkout`/`git restore` to an older commit) unless the user gives an explicit, written instruction in this conversation. Treat these commands as catastrophic; if you are even slightly unsure, stop and ask before touching them. *(When working within Cursor or Codex Web, these git limitations do not apply; use the tooling's capabilities as needed.)*
- Never use `git restore` (or similar commands) to revert files you didn't author—coordinate with other agents instead so their in-progress work stays intact.
- Always double-check git status before any commit
- Keep commits atomic: commit only the files you touched and list each path explicitly. For tracked files run `git commit -m "<scoped message>" -- path/to/file1 path/to/file2`. For brand-new files, use the one-liner `git restore --staged :/ && git add "path/to/file1" "path/to/file2" && git commit -m "<scoped message>" -- path/to/file1 path/to/file2`.
- Quote any git paths containing brackets or parentheses (e.g., `src/app/[candidate]/**`) when staging or committing so the shell does not treat them as globs or subshells.
- When running `git rebase`, avoid opening editors—export `GIT_EDITOR=:` and `GIT_SEQUENCE_EDITOR=:` (or pass `--no-edit`) so the default messages are used automatically.
- Never amend commits unless you have explicit written approval in the task thread.
# Repository Guidelines
## Project Structure & Module Organization
ClawSec combines a Vite + React frontend with security skill packages and release tooling.
- Frontend entrypoints: `index.tsx`, `App.tsx`
- UI and routes: `components/`, `pages/`
- Shared types/constants: `types.ts`, `constants.ts`
- Skills: `skills/<skill-name>/` (`skill.json`, `SKILL.md`, optional `scripts/`, `test/`)
- Advisory feed: `advisories/feed.json`, `advisories/feed.json.sig`
- Automation: `scripts/`, `.github/workflows/`
- Python utilities: `utils/validate_skill.py`, `utils/package_skill.py`
## Build, Test, and Development Commands
- `npm install`: install dependencies.
- `npm run dev`: run local Vite server.
- `npm run build`: create production build (CI gate).
- `npm run preview`: preview built app.
- `./scripts/prepare-to-push.sh [--fix]`: run lint, types, build, and security checks.
- `npx eslint . --ext .ts,.tsx,.js,.jsx,.mjs --max-warnings 0`: lint JS/TS.
- `npx tsc --noEmit`: type-check TypeScript.
- `node skills/clawsec-suite/test/feed_verification.test.mjs`: run a skill-local Node test.
- `python utils/validate_skill.py skills/<skill-name>`: validate skill schema/metadata.
## Coding Style & Naming Conventions
- Use TypeScript/TSX for frontend code and ESM for scripts.
- Follow `eslint.config.js`; prefix intentionally unused vars/args with `_`.
- Python under `utils/` follows `pyproject.toml` Ruff/Bandit rules (line length 120).
- Name React files in PascalCase (for example, `SkillCard.tsx`), skill directories in kebab-case (for example, `skills/clawsec-feed`), and tests as `*.test.mjs`.
## Testing Guidelines
There is no root `npm test`; tests are mostly skill-local.
- Run changed tests directly: `node skills/<skill>/test/<name>.test.mjs`.
- For frontend/config changes, run ESLint, `npx tsc --noEmit`, and `npm run build`.
- For Python utility updates, run `ruff check utils/` and `bandit -r utils/ -ll`.
## Pull Request Guidelines
- Follow Conventional Commits: `feat(scope): ...`, `fix(scope): ...`, `chore(scope): ...`.
- Use skill branches like `skill/<name>-...`.
- Keep PRs focused and include summary, security benefit, and testing performed.
- Keep versions aligned between `skills/<skill>/skill.json` and `skills/<skill>/SKILL.md`.
- Do not push release tags from PR branches; releases are tagged from `main`.
## Agent Collaboration & Git Safety
- Delete unused or obsolete files only when your changes make them irrelevant; revert files only when the change is yours or explicitly requested. If a git operation creates uncertainty about another agents in-flight work, stop and coordinate instead of deleting.
- Before deleting any file to fix local type/lint failures, stop and ask the user.
- Never edit `.env` or any environment variable files.
- Coordinate with other agents before removing their in-progress edits; do not revert or delete work you did not author unless everyone agrees.
- Moving, renaming, and restoring files is allowed when done safely.
- Never run destructive git operations without explicit written instruction in this conversation: `git reset --hard`, `rm`, `git checkout`/`git restore` to older commits. Treat these as catastrophic; if unsure, stop and ask. In Cursor or Codex Web, use platform tooling as applicable.
- Never use `git restore` (or similar revert commands) on files you did not author.
- Always run `git status` before committing.
- Keep commits atomic and commit only touched files with explicit paths.
- For tracked files: `git commit -m "<scoped message>" -- path/to/file1 path/to/file2`.
- For new files: `git restore --staged :/ && git add "path/to/file1" "path/to/file2" && git commit -m "<scoped message>" -- path/to/file1 path/to/file2`.
- Quote any git path containing brackets or parentheses when staging/committing (for example, `"src/app/[candidate]/**"`).
- For rebases, avoid editors: `GIT_EDITOR=:` and `GIT_SEQUENCE_EDITOR=:` (or `--no-edit`).
- Never amend commits without explicit written approval in this task thread.
+20 -2
View File
@@ -72,6 +72,24 @@ Copy this instruction to your AI agent:
> Read https://clawsec.prompt.security/releases/latest/download/SKILL.md and follow the instructions to install the protection skill suite.
### Shell and OS Notes
ClawSec scripts are split between:
- Cross-platform Node/Python tooling (`npm run build`, hook/setup `.mjs`, `utils/*.py`)
- POSIX shell workflows (`*.sh`, most manual install snippets)
For Linux/macOS (`bash`/`zsh`):
- Use unquoted or double-quoted home vars: `export INSTALL_ROOT="$HOME/.openclaw/skills"`
- Do **not** single-quote expandable vars (for example, avoid `'$HOME/.openclaw/skills'`)
For Windows (PowerShell):
- Prefer explicit path building:
- `$env:INSTALL_ROOT = Join-Path $HOME ".openclaw\\skills"`
- `node "$env:INSTALL_ROOT\\clawsec-suite\\scripts\\setup_advisory_hook.mjs"`
- POSIX `.sh` scripts require WSL or Git Bash.
Troubleshooting: if you see directories such as `~/.openclaw/workspace/$HOME/...`, a home variable was passed literally. Re-run using an absolute path or an unquoted home expression.
---
## 📱 NanoClaw Platform Support
@@ -277,8 +295,8 @@ Each skill release includes:
### Signing Operations Documentation
For feed/release signing rollout and operations guidance:
- [`SECURITY-SIGNING.md`](SECURITY-SIGNING.md) - key generation, GitHub secrets, rotation/revocation, incident response
- [`MIGRATION-SIGNED-FEED.md`](MIGRATION-SIGNED-FEED.md) - phased migration from unsigned feed, enforcement gates, rollback plan
- [`docs/SECURITY-SIGNING.md`](docs/SECURITY-SIGNING.md) - key generation, GitHub secrets, rotation/revocation, incident response
- [`docs/MIGRATION-SIGNED-FEED.md`](docs/MIGRATION-SIGNED-FEED.md) - phased migration from unsigned feed, enforcement gates, rollback plan
---
+97
View File
@@ -0,0 +1,97 @@
# Cross-Platform Compatibility Report
## 1) Executive Summary
### Overall status by OS
- Linux: **Good**, primary workflows validated; still some POSIX-only scripts/docs.
- macOS: **Good**, with caveats around POSIX tool availability and Homebrew-specific assumptions.
- Windows: **Partial**, Node/Python pieces work, but many shell-first install/release workflows still require WSL/Git Bash.
### Highest-risk incompatibilities
1. **(Fixed)** Literal `$HOME` path creation risk in audit watchdog cron setup payload generation.
2. **(Fixed)** Path env vars accepted as raw strings in multiple Node entrypoints without expansion/validation.
3. **(Open)** Large portions of manual install/release guidance remain POSIX-only (`bash`, `jq`, `curl`, `unzip`, `chmod`, `find -exec`).
### SKILLS install path-expansion root cause
Root cause was a combination of:
- shell-side literal env assignment (for example, `PROMPTSEC_INSTALL_DIR='$HOME/...')`
- Node scripts not expanding home tokens
- cron payload construction escaping `$` (`\$HOME`), forcing literal interpretation in downstream shell execution
This could produce paths like `~/.openclaw/workspace/$HOME/...`.
---
## 2) Findings Table
| ID | Severity | OS Impact | Component | Description | Proposed Fix | Status |
|---|---|---|---|---|---|---|
| CP-001 | Blocker | Linux/macOS/Windows | `skills/openclaw-audit-watchdog/scripts/setup_cron.mjs` | Literal `$HOME` could be propagated into cron payload, creating wrong runtime paths. | Expand/normalize home tokens and reject unresolved escaped tokens before job creation. | **Fixed** |
| CP-002 | High | Linux/macOS/Windows | `skills/clawsec-suite/hooks/.../handler.ts`, `.../scripts/guarded_skill_install.mjs`, `.../lib/suppression.mjs`, `skills/openclaw-audit-watchdog/scripts/load_suppression_config.mjs` | Env path vars treated as opaque strings; `~`, `$HOME` not consistently handled. | Shared/consistent path resolution + fail-fast validation. | **Fixed** |
| CP-003 | Medium | macOS/Windows | `skills/openclaw-audit-watchdog/test/render_report_suppression.test.mjs`, `.../scripts/codex_review.sh` | Hardcoded `/opt/homebrew` and `which` assumptions. | Use `process.execPath` for tests; PATH-first Codex discovery. | **Fixed** |
| CP-004 | Medium | Windows (+ CI) | repo-wide line endings | Missing `.gitattributes` could introduce CRLF script breakage (`env bash^M`). | Add `.gitattributes` with LF enforcement for scripts/config/text. | **Fixed** |
| CP-005 | Medium | macOS/Windows | `.github/workflows/ci.yml` | TS/lint/build checks were Linux-only. | Add OS matrix for Node checks (`ubuntu`, `macos`, `windows`). | **Fixed** |
| CP-006 | High | Windows | Multiple SKILL docs and shell scripts | Install/maintenance flow is still heavily POSIX-shell based. | Add PowerShell equivalents or Node wrappers for critical flows. | Open |
| CP-007 | Medium | Linux/macOS/Windows | `skills/soul-guardian/scripts/soul_guardian.py` | `Path(...).expanduser()` handles `~` but not `$HOME`/`%USERPROFILE%`. | Add explicit env-token expansion + validation for `--state-dir`. | Open |
| CP-008 | Medium | Windows | `scripts/release-skill.sh`, `scripts/populate-local-*.sh` | GNU/BSD shell toolchain assumptions block native Windows usage. | Provide cross-platform Node/Python replacements or PowerShell equivalents. | Open |
| CP-009 | Low | Windows | docs + scripts using `chmod 600/644` | POSIX permission semantics are partial/non-portable on Windows. | Document best-effort behavior and Windows ACL alternatives. | Open |
| CP-010 | Low | macOS/Windows | CI non-Node jobs | Shell/Python/security scan jobs remain Ubuntu-only. | Add scoped matrix or dedicated non-Linux smoke jobs where practical. | Open |
---
## 3) Detailed Findings
## Paths
- Fixed: centralized home-token expansion and suspicious token rejection for critical runtime/install path env vars.
- Fixed: path normalization before filesystem access and before cron payload construction.
- Open: `soul_guardian.py` still expands only `~`, not `$HOME`/Windows env tokens.
## Shell / Command Dependencies
- Confirmed extensive POSIX dependencies (`bash`, `curl`, `jq`, `mktemp`, `chmod`, `find`, `unzip`, `openssl`, `shasum/sha256sum`).
- Fixed minor hardcoded binary path assumptions.
- Open: no full native PowerShell parity for core shell workflows.
## Permissions / Filesystem Semantics
- Confirmed many scripts rely on POSIX permission commands.
- Existing `state.ts` already handles `chmod` failures on unsupported filesystems.
- Open: docs still mostly assume POSIX permissions.
## Line Endings
- Fixed by adding `.gitattributes` with LF rules for scripts and key text/config files.
## Runtime Dependencies
- Node scripts generally portable.
- Python utilities are portable.
- OpenSSL usage in docs/workflows remains shell/toolchain dependent.
## CI / Automation
- Fixed: TS/lint/build matrix now runs on Linux/macOS/Windows.
- Open: remaining security/shell/python jobs are Linux-only by design.
---
## 4) SKILLS Install Investigation
### Reproduction (pre-fix)
1. Set install dir with literal token (common quoting mistake):
- `export PROMPTSEC_INSTALL_DIR='$HOME/.config/security-checkup'`
2. Run:
- `node skills/openclaw-audit-watchdog/scripts/setup_cron.mjs`
3. The generated payload command used escaped `$` in `cd` path, resulting in literal token usage at execution time (`cd "\$HOME/..."`), which can resolve under current working directory (for example, `~/.openclaw/workspace/$HOME/...`).
### Root cause analysis
- POSIX single quotes prevent variable expansion.
- Node does not auto-expand env vars inside strings.
- Existing payload escaping converted `$` to literal in shell command text.
### Fix implemented
- Added explicit path resolution (supports `~`, `$HOME`, `${HOME}`, `%USERPROFILE%`, `$env:USERPROFILE`) and normalization.
- Added fail-fast validation for unresolved/escaped home tokens.
- Applied to watchdog cron setup, watchdog suppression config loader, suite hook handler, suite advisory suppression loader, and suite guarded installer.
- Added tests covering expansion and escaped-token rejection.
### Validation targets
- `bash` / `zsh`: expanded env values and reject literal escaped home tokens.
- `sh` (where scripts are invoked through Node entrypoints): same path behavior in Node layer.
- Windows PowerShell: `%USERPROFILE%` / `$env:USERPROFILE` expansion and path normalization validated in Node tests.
+87
View File
@@ -0,0 +1,87 @@
# Platform Verification Checklist
Use this checklist to validate portability and path-handling behavior after changes.
## Linux Verification
1. Run core Node tests:
```bash
node skills/clawsec-suite/test/path_resolution.test.mjs
node skills/clawsec-suite/test/guarded_install.test.mjs
node skills/clawsec-suite/test/advisory_suppression.test.mjs
node skills/openclaw-audit-watchdog/test/suppression_config.test.mjs
```
Expected: all tests pass.
2. Verify no literal `$HOME` path acceptance:
```bash
CLAWSEC_LOCAL_FEED='\$HOME/advisories/feed.json' \
node skills/clawsec-suite/scripts/guarded_skill_install.mjs --skill test-skill --dry-run
```
Expected: exits non-zero with `Unexpanded home token` error.
3. Verify `$HOME` expansion works:
```bash
HOME=/tmp/clawsec-home node skills/clawsec-suite/test/path_resolution.test.mjs
```
Expected: `$HOME` expansion tests pass.
## macOS Verification
1. Run the same Node test suite as Linux.
2. Confirm OpenSSL tooling path assumptions are documented:
- If using LibreSSL/OpenSSL variations, ensure checks use tested command forms from docs.
3. Verify tilde expansion in config path:
```bash
OPENCLAW_AUDIT_CONFIG=~/.openclaw/security-audit.json \
node skills/openclaw-audit-watchdog/scripts/load_suppression_config.mjs --enable-suppressions
```
Expected: path resolves correctly (or clear file-not-found error at expanded location).
## Windows Verification (PowerShell)
1. Run Node tests:
```powershell
node skills/clawsec-suite/test/path_resolution.test.mjs
node skills/clawsec-suite/test/guarded_install.test.mjs
node skills/clawsec-suite/test/advisory_suppression.test.mjs
```
Expected: all pass.
2. Verify PowerShell env path expansion behavior:
```powershell
$env:CLAWSEC_LOCAL_FEED = '$env:USERPROFILE\advisories\feed.json'
node skills/clawsec-suite/scripts/guarded_skill_install.mjs --skill test-skill --dry-run
```
Expected: path token is expanded/normalized or fails with a clear error if target files are missing.
3. Verify escaped literal token rejection:
```powershell
$env:CLAWSEC_LOCAL_FEED = '\$HOME\advisories\feed.json'
node skills/clawsec-suite/scripts/guarded_skill_install.mjs --skill test-skill --dry-run
```
Expected: `Unexpanded home token` error; no directory creation with literal `$HOME`.
## Line Endings Sanity
1. Confirm LF policy is present:
```bash
test -f .gitattributes && grep -n "eol=lf" .gitattributes
```
Expected: script/config file patterns enforce LF.
2. After a CRLF-prone checkout, verify scripts still parse:
```bash
bash -n scripts/populate-local-feed.sh
bash -n scripts/populate-local-skills.sh
```
Expected: no `^M` shebang/parse errors.
## Explicit Bug Check: No Literal `$HOME` Directory Creation
1. Configure a path with a literal/escaped token.
2. Run setup/install command.
3. Verify command fails early with token error.
4. Confirm no `$HOME` segment directory was created under working directories.
Expected outcome: **no directories containing literal `$HOME` are created by supported setup scripts.**
+73
View File
@@ -0,0 +1,73 @@
# Cross-Platform Remediation Plan
## Phase 1: Immediate Risk Closure (Completed)
### Milestones
- Implement explicit home-path expansion + suspicious token rejection in high-risk runtime/install paths.
- Add regression tests for path expansion and escaped-token rejection.
- Add `.gitattributes` LF policy.
- Expand Node lint/type/build CI coverage to Linux/macOS/Windows.
- Update install docs with shell-specific guidance and literal `$HOME` troubleshooting.
### Outcomes
- Literal `$HOME` path propagation bug addressed at source.
- Core advisory/install path config now fails fast on invalid path tokens.
---
## Phase 2: Windows Parity for Critical Workflows (Next)
### Quick wins
- Add PowerShell equivalents for the most-used manual install/check commands in:
- `skills/clawsec-suite/SKILL.md`
- `skills/openclaw-audit-watchdog/SKILL.md`
- `README.md`
- Add a lightweight `scripts/preflight.mjs` to detect missing tools and print OS-specific install hints.
### Milestones
- Native PowerShell instructions for suite setup and advisory hook.
- WSL/Git Bash fallback documented where shell scripts are unavoidable.
---
## Phase 3: Reduce POSIX Shell Surface (Deeper Refactor)
### Refactor targets
- `scripts/populate-local-feed.sh`
- `scripts/populate-local-skills.sh`
- `scripts/release-skill.sh`
### Approach
- Re-implement critical paths in Node/Python to remove dependency on `jq/sed/awk/find/chmod` pipelines.
- Preserve shell wrappers for backward compatibility; route to new cross-platform implementations.
### Migration notes
- Keep old script entrypoints as wrappers for at least one minor release.
- Emit deprecation warnings with exact migration commands.
---
## Phase 4: CI Hardening and Ongoing Verification
### Milestones
- Keep Node matrix (Linux/macOS/Windows) as required check.
- Add targeted Windows smoke tests for install path handling.
- Add macOS check for OpenSSL command compatibility notes where relevant.
### Test strategy
- Local:
- Run Node test suites that cover path expansion/suppression/install behavior.
- Run syntax checks for modified scripts.
- CI:
- Matrix Node checks + guarded installer/suppression/path tests.
- Linux-only security scans remain, but explicitly marked as Linux-scoped.
---
## Rollout / Release Considerations
- No breaking interface changes introduced in this patch set; behavior is stricter only for invalid/unexpanded path tokens.
- Communicate in release notes:
- path token validation now enforced
- how to correct invalid quoted env values
- where PowerShell examples live
+13 -2
View File
@@ -54,6 +54,7 @@
"version": "7.29.0",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1186,6 +1187,7 @@
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -1285,6 +1287,7 @@
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.0",
"@typescript-eslint/types": "8.56.0",
@@ -1594,6 +1597,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1850,6 +1854,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -2050,8 +2055,7 @@
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"peer": true
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
},
"node_modules/data-view-buffer": {
"version": "1.0.2",
@@ -2438,6 +2442,7 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -4714,6 +4719,7 @@
"version": "4.0.3",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"peer": true,
"engines": {
"node": ">=12"
},
@@ -4794,6 +4800,7 @@
"node_modules/react": {
"version": "19.2.4",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -4801,6 +4808,7 @@
"node_modules/react-dom": {
"version": "19.2.4",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -5548,6 +5556,7 @@
"version": "5.8.3",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -5723,6 +5732,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -5915,6 +5925,7 @@
"version": "4.3.6",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+1 -1
View File
@@ -142,7 +142,7 @@ Planned features for future releases:
- [Skill Documentation](skills/clawsec-nanoclaw/SKILL.md) - Features and architecture
- [Installation Guide](skills/clawsec-nanoclaw/INSTALL.md) - Detailed setup instructions
- [ClawSec Main README](README.md) - Overall ClawSec documentation
- [Security & Signing](SECURITY-SIGNING.md) - Signature verification details
- [Security & Signing](../../docs/SECURITY-SIGNING.md) - Signature verification details
## Support
+23
View File
@@ -5,6 +5,29 @@ 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.3]
### Added
- Contributor credit: portability and path-hardening improvements in this release were contributed by [@aldodelgado](https://github.com/aldodelgado) in PR #62.
- Cross-shell path resolution support for home-directory tokens in suite path configuration (`~`, `$HOME`, `${HOME}`, `%USERPROFILE%`, `$env:HOME`).
- Dedicated path-resolution regression coverage (`test/path_resolution.test.mjs`) including fallback behavior for invalid explicit path values.
- Additional advisory/installer tests validating home-token expansion and escaped-token rejection.
### Changed
- Advisory guardian hook now resolves configured path environment variables through a shared portability helper.
- Guarded install flow now resolves feed/signature/checksum/public-key path overrides through the same shared path helper for consistent behavior across shells/OSes.
- Advisory matching now explicitly scopes to `application: "openclaw"` when present; legacy advisories without `application` remain eligible for backward compatibility.
### Fixed
- Prevented advisory-check bypass when a single explicit path env var is malformed: invalid explicit values now fall back to safe defaults instead of aborting the entire hook run.
### Security
- Escaped/unexpanded home-token inputs in path config are explicitly rejected while preserving secure defaults.
## [0.1.2]
### Added
+10 -1
View File
@@ -1,6 +1,6 @@
---
name: clawsec-suite
version: 0.1.2
version: 0.1.3
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:
@@ -45,6 +45,14 @@ Fallback behavior:
## Installation
### Cross-shell path note
- In `bash`/`zsh`, keep path variables expandable (for example, `INSTALL_ROOT="$HOME/.openclaw/skills"`).
- Do not single-quote home-variable paths (avoid `'$HOME/.openclaw/skills'`).
- In PowerShell, set an explicit path:
- `$env:INSTALL_ROOT = Join-Path $HOME ".openclaw\\skills"`
- If a path is passed with unresolved tokens (like `\$HOME/...`), suite scripts now fail fast with a clear error.
### Option A: Via clawhub (recommended)
```bash
@@ -148,6 +156,7 @@ node "$SUITE_DIR/scripts/setup_advisory_cron.mjs"
What this adds:
- scan on `agent:bootstrap` and `/new` (`command:new`),
- compare advisory `affected` entries against installed skills,
- consider advisories with `application: "openclaw"` (and legacy entries without `application` for backward compatibility),
- notify when new matches appear,
- and ask for explicit user approval before any removal flow.
@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { uniqueStrings } from "./lib/utils.mjs";
import { uniqueStrings, resolveConfiguredPath } from "./lib/utils.mjs";
import { defaultChecksumsUrl, loadLocalFeed, loadRemoteFeed } from "./lib/feed.mjs";
import type { HookEvent, FeedPayload, AdvisoryMatch } from "./lib/types.ts";
import { loadState, persistState } from "./lib/state.ts";
@@ -13,13 +13,6 @@ const DEFAULT_FEED_URL =
const DEFAULT_SCAN_INTERVAL_SECONDS = 300;
let unsignedModeWarningShown = false;
function expandHome(inputPath: string): string {
if (!inputPath) return inputPath;
if (inputPath === "~") return os.homedir();
if (inputPath.startsWith("~/")) return path.join(os.homedir(), inputPath.slice(2));
return inputPath;
}
function parsePositiveInteger(value: string | undefined, fallback: number): number {
const parsed = Number.parseInt(String(value ?? ""), 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
@@ -51,6 +44,21 @@ function scannedRecently(lastScan: string | null, minIntervalSeconds: number): b
return sinceMs >= 0 && sinceMs < minIntervalSeconds * 1000;
}
function configuredPath(
explicit: string | undefined,
fallback: string,
label: string,
): string {
return resolveConfiguredPath(explicit, fallback, {
label,
onInvalid: (error, rawValue) => {
console.warn(
`[clawsec-advisory-guardian] invalid ${label} path "${rawValue}", using default "${fallback}": ${String(error)}`,
);
},
});
}
async function loadFeed(options: {
feedUrl: string;
feedSignatureUrl: string;
@@ -92,25 +100,45 @@ async function loadFeed(options: {
const handler = async (event: HookEvent): Promise<void> => {
if (!shouldHandleEvent(event)) return;
const installRoot = expandHome(
process.env.CLAWSEC_INSTALL_ROOT || process.env.INSTALL_ROOT || path.join(os.homedir(), ".openclaw", "skills"),
const installRoot = configuredPath(
process.env.CLAWSEC_INSTALL_ROOT || process.env.INSTALL_ROOT,
path.join(os.homedir(), ".openclaw", "skills"),
"CLAWSEC_INSTALL_ROOT",
);
const suiteDir = expandHome(process.env.CLAWSEC_SUITE_DIR || path.join(installRoot, "clawsec-suite"));
const localFeedPath = expandHome(process.env.CLAWSEC_LOCAL_FEED || path.join(suiteDir, "advisories", "feed.json"));
const localFeedSignaturePath = expandHome(
process.env.CLAWSEC_LOCAL_FEED_SIG || `${localFeedPath}.sig`,
const suiteDir = configuredPath(
process.env.CLAWSEC_SUITE_DIR,
path.join(installRoot, "clawsec-suite"),
"CLAWSEC_SUITE_DIR",
);
const localFeedChecksumsPath = expandHome(
process.env.CLAWSEC_LOCAL_FEED_CHECKSUMS || path.join(path.dirname(localFeedPath), "checksums.json"),
const localFeedPath = configuredPath(
process.env.CLAWSEC_LOCAL_FEED,
path.join(suiteDir, "advisories", "feed.json"),
"CLAWSEC_LOCAL_FEED",
);
const localFeedChecksumsSignaturePath = expandHome(
process.env.CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG || `${localFeedChecksumsPath}.sig`,
const localFeedSignaturePath = configuredPath(
process.env.CLAWSEC_LOCAL_FEED_SIG,
`${localFeedPath}.sig`,
"CLAWSEC_LOCAL_FEED_SIG",
);
const feedPublicKeyPath = expandHome(
process.env.CLAWSEC_FEED_PUBLIC_KEY || path.join(suiteDir, "advisories", "feed-signing-public.pem"),
const localFeedChecksumsPath = configuredPath(
process.env.CLAWSEC_LOCAL_FEED_CHECKSUMS,
path.join(path.dirname(localFeedPath), "checksums.json"),
"CLAWSEC_LOCAL_FEED_CHECKSUMS",
);
const stateFile = expandHome(
process.env.CLAWSEC_SUITE_STATE_FILE || path.join(os.homedir(), ".openclaw", "clawsec-suite-feed-state.json"),
const localFeedChecksumsSignaturePath = configuredPath(
process.env.CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG,
`${localFeedChecksumsPath}.sig`,
"CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG",
);
const feedPublicKeyPath = configuredPath(
process.env.CLAWSEC_FEED_PUBLIC_KEY,
path.join(suiteDir, "advisories", "feed-signing-public.pem"),
"CLAWSEC_FEED_PUBLIC_KEY",
);
const stateFile = configuredPath(
process.env.CLAWSEC_SUITE_STATE_FILE,
path.join(os.homedir(), ".openclaw", "clawsec-suite-feed-state.json"),
"CLAWSEC_SUITE_STATE_FILE",
);
const feedUrl = process.env.CLAWSEC_FEED_URL || DEFAULT_FEED_URL;
const feedSignatureUrl = process.env.CLAWSEC_FEED_SIG_URL || `${feedUrl}.sig`;
@@ -0,0 +1,48 @@
const ADVISORY_APPLICATION_OPENCLAW = "openclaw";
const ADVISORY_APPLICATION_ALL = "all";
/**
* @param {unknown} value
* @returns {string[]}
*/
function normalizeApplicationValue(value) {
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
return normalized ? [normalized] : [];
}
if (Array.isArray(value)) {
return value
.filter((entry) => typeof entry === "string")
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
}
return [];
}
/**
* Decide whether an advisory should be considered by OpenClaw-facing flows.
*
* Backward compatibility rule:
* - Advisories without `application` remain eligible.
*
* @param {{ application?: unknown }} advisory
* @returns {boolean}
*/
export function advisoryAppliesToOpenclaw(advisory) {
const application = advisory?.application;
if (application === undefined || application === null) {
return true;
}
const applications = normalizeApplicationValue(application);
if (applications.length === 0) {
return true;
}
return (
applications.includes(ADVISORY_APPLICATION_OPENCLAW) ||
applications.includes(ADVISORY_APPLICATION_ALL)
);
}
@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { isObject, normalizeSkillName, uniqueStrings } from "./utils.mjs";
import { advisoryAppliesToOpenclaw } from "./advisory_scope.mjs";
import { versionMatches } from "./version.mjs";
import { parseAffectedSpecifier } from "./feed.mjs";
import type { Advisory, FeedPayload, InstalledSkill, AdvisoryMatch } from "./types.ts";
@@ -68,6 +69,8 @@ export function findMatches(feed: FeedPayload, installedSkills: InstalledSkill[]
const matches: AdvisoryMatch[] = [];
for (const advisory of feed.advisories) {
if (!advisoryAppliesToOpenclaw(advisory)) continue;
const affected = Array.isArray(advisory.affected) ? advisory.affected : [];
if (affected.length === 0) continue;
@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { isObject, normalizeSkillName } from "./utils.mjs";
import { isObject, normalizeSkillName, resolveUserPath } from "./utils.mjs";
const DEFAULT_PRIMARY_PATH = path.join(os.homedir(), ".openclaw", "security-audit.json");
const DEFAULT_FALLBACK_PATH = ".clawsec/allowlist.json";
@@ -94,8 +94,9 @@ async function loadConfigFromPath(configPath) {
export async function loadAdvisorySuppression(configPath) {
// Priority 1: Explicit path
if (configPath) {
const config = await loadConfigFromPath(configPath);
if (!config) throw new Error(`Advisory suppression config not found: ${configPath}`);
const resolved = resolveUserPath(configPath, { label: "advisory suppression config path" });
const config = await loadConfigFromPath(resolved);
if (!config) throw new Error(`Advisory suppression config not found: ${resolved}`);
if (!config.enabledFor.includes("advisory")) return { ...EMPTY_CONFIG };
return config;
}
@@ -103,7 +104,8 @@ export async function loadAdvisorySuppression(configPath) {
// Priority 2: Environment variable
const envPath = process.env.OPENCLAW_AUDIT_CONFIG;
if (typeof envPath === "string" && envPath.trim()) {
const config = await loadConfigFromPath(envPath.trim());
const resolved = resolveUserPath(envPath.trim(), { label: "OPENCLAW_AUDIT_CONFIG" });
const config = await loadConfigFromPath(resolved);
if (config && config.enabledFor.includes("advisory")) return config;
return { ...EMPTY_CONFIG };
}
@@ -8,6 +8,7 @@ export type Advisory = {
id?: string;
severity?: string;
type?: string;
application?: string | string[];
title?: string;
description?: string;
action?: string;
@@ -1,3 +1,6 @@
import os from "node:os";
import path from "node:path";
/**
* @param {unknown} value
* @returns {value is Record<string, unknown>}
@@ -23,3 +26,110 @@ export function normalizeSkillName(value) {
export function uniqueStrings(values) {
return Array.from(new Set(values));
}
function detectHomeDirectory(env = process.env) {
if (typeof env.HOME === "string" && env.HOME.trim()) return env.HOME.trim();
if (typeof env.USERPROFILE === "string" && env.USERPROFILE.trim()) return env.USERPROFILE.trim();
if (
typeof env.HOMEDRIVE === "string" &&
env.HOMEDRIVE.trim() &&
typeof env.HOMEPATH === "string" &&
env.HOMEPATH.trim()
) {
return `${env.HOMEDRIVE.trim()}${env.HOMEPATH.trim()}`;
}
return os.homedir();
}
const UNEXPANDED_HOME_TOKEN_PATTERN =
/(?:^|[\\/])(?:\\?\$HOME|\\?\$\{HOME\}|\\?\$USERPROFILE|\\?\$\{USERPROFILE\}|%HOME%|%USERPROFILE%|\$env:HOME|\$env:USERPROFILE)(?:$|[\\/])/i;
/**
* @param {string} value
* @returns {string}
*/
function expandKnownHomeTokens(value) {
const homeDir = detectHomeDirectory(process.env);
if (!homeDir) return value;
let expanded = String(value ?? "");
if (expanded === "~") {
expanded = homeDir;
} else if (expanded.startsWith("~/") || expanded.startsWith("~\\")) {
expanded = path.join(homeDir, expanded.slice(2));
}
expanded = expanded
.replace(/(?<!\\)\$\{HOME\}/g, homeDir)
.replace(/(?<!\\)\$HOME(?=$|[\\/])/g, homeDir)
.replace(/(?<!\\)\$\{USERPROFILE\}/gi, homeDir)
.replace(/(?<!\\)\$USERPROFILE(?=$|[\\/])/gi, homeDir)
.replace(/%HOME%/gi, homeDir)
.replace(/%USERPROFILE%/gi, homeDir)
.replace(/(?<!\\)\$env:HOME/gi, homeDir)
.replace(/(?<!\\)\$env:USERPROFILE/gi, homeDir);
return expanded;
}
/**
* @param {string} value
* @returns {boolean}
*/
export function hasUnexpandedHomeToken(value) {
return UNEXPANDED_HOME_TOKEN_PATTERN.test(String(value ?? "").trim());
}
/**
* Expand `~` and known home env var patterns in user-provided path-like strings.
* Also fails fast when unresolved home tokens remain.
*
* @param {string} inputPath
* @param {{label?: string}} [options]
* @returns {string}
*/
export function resolveUserPath(inputPath, { label = "path" } = {}) {
const raw = String(inputPath ?? "").trim();
if (!raw) return raw;
const expanded = expandKnownHomeTokens(raw);
const normalized = path.normalize(expanded);
if (hasUnexpandedHomeToken(normalized)) {
throw new Error(
`Unexpanded home token detected in ${label}: ${raw}. ` +
"Use an absolute path or an unquoted home-path expression.",
);
}
return normalized;
}
/**
* Resolve an optional explicit path; if invalid, fall back to a default path.
*
* @param {string | undefined} explicitPath
* @param {string} fallbackPath
* @param {{label?: string, onInvalid?: (error: unknown, rawValue: string) => void}} [options]
* @returns {string}
*/
export function resolveConfiguredPath(
explicitPath,
fallbackPath,
{ label = "path", onInvalid } = {},
) {
const explicit = typeof explicitPath === "string" ? explicitPath.trim() : "";
if (!explicit) {
return resolveUserPath(fallbackPath, { label });
}
try {
return resolveUserPath(explicit, { label });
} catch (error) {
if (typeof onInvalid === "function") {
onInvalid(error, explicit);
}
return resolveUserPath(fallbackPath, { label });
}
}
@@ -4,7 +4,7 @@ import { spawnSync } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { normalizeSkillName, uniqueStrings } from "../hooks/clawsec-advisory-guardian/lib/utils.mjs";
import { normalizeSkillName, uniqueStrings, resolveUserPath } from "../hooks/clawsec-advisory-guardian/lib/utils.mjs";
import { versionMatches } from "../hooks/clawsec-advisory-guardian/lib/version.mjs";
import {
defaultChecksumsUrl,
@@ -23,6 +23,12 @@ const DEFAULT_LOCAL_FEED_CHECKSUMS_SIG = `${DEFAULT_LOCAL_FEED_CHECKSUMS}.sig`;
const DEFAULT_FEED_PUBLIC_KEY = path.join(DEFAULT_SUITE_DIR, "advisories", "feed-signing-public.pem");
const EXIT_CONFIRM_REQUIRED = 42;
function envPathOrDefault(name, fallback, label) {
const envValue = process.env[name];
const candidate = typeof envValue === "string" && envValue.trim() ? envValue.trim() : fallback;
return resolveUserPath(candidate, { label });
}
function printUsage() {
process.stderr.write(
[
@@ -118,11 +124,19 @@ async function loadFeed() {
const feedSignatureUrl = process.env.CLAWSEC_FEED_SIG_URL || `${feedUrl}.sig`;
const feedChecksumsUrl = process.env.CLAWSEC_FEED_CHECKSUMS_URL || defaultChecksumsUrl(feedUrl);
const feedChecksumsSignatureUrl = process.env.CLAWSEC_FEED_CHECKSUMS_SIG_URL || `${feedChecksumsUrl}.sig`;
const localFeedPath = process.env.CLAWSEC_LOCAL_FEED || DEFAULT_LOCAL_FEED;
const localFeedSigPath = process.env.CLAWSEC_LOCAL_FEED_SIG || DEFAULT_LOCAL_FEED_SIG;
const localFeedChecksumsPath = process.env.CLAWSEC_LOCAL_FEED_CHECKSUMS || DEFAULT_LOCAL_FEED_CHECKSUMS;
const localFeedChecksumsSigPath = process.env.CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG || DEFAULT_LOCAL_FEED_CHECKSUMS_SIG;
const feedPublicKeyPath = process.env.CLAWSEC_FEED_PUBLIC_KEY || DEFAULT_FEED_PUBLIC_KEY;
const localFeedPath = envPathOrDefault("CLAWSEC_LOCAL_FEED", DEFAULT_LOCAL_FEED, "CLAWSEC_LOCAL_FEED");
const localFeedSigPath = envPathOrDefault("CLAWSEC_LOCAL_FEED_SIG", DEFAULT_LOCAL_FEED_SIG, "CLAWSEC_LOCAL_FEED_SIG");
const localFeedChecksumsPath = envPathOrDefault(
"CLAWSEC_LOCAL_FEED_CHECKSUMS",
DEFAULT_LOCAL_FEED_CHECKSUMS,
"CLAWSEC_LOCAL_FEED_CHECKSUMS",
);
const localFeedChecksumsSigPath = envPathOrDefault(
"CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG",
DEFAULT_LOCAL_FEED_CHECKSUMS_SIG,
"CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG",
);
const feedPublicKeyPath = envPathOrDefault("CLAWSEC_FEED_PUBLIC_KEY", DEFAULT_FEED_PUBLIC_KEY, "CLAWSEC_FEED_PUBLIC_KEY");
const allowUnsigned = process.env.CLAWSEC_ALLOW_UNSIGNED_FEED === "1";
const verifyChecksumManifest = process.env.CLAWSEC_VERIFY_CHECKSUM_MANIFEST !== "0";
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "clawsec-suite",
"version": "0.1.2",
"version": "0.1.3",
"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",
@@ -0,0 +1,98 @@
#!/usr/bin/env node
/**
* Advisory application scope tests:
* - openclaw advisories are considered
* - nanoclaw advisories are ignored
* - legacy advisories without application remain eligible
*
* Run: node skills/clawsec-suite/test/advisory_application_scope.test.mjs
*/
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LIB_PATH = path.resolve(__dirname, "..", "hooks", "clawsec-advisory-guardian", "lib");
const { advisoryAppliesToOpenclaw } = await import(`${LIB_PATH}/advisory_scope.mjs`);
let passCount = 0;
let failCount = 0;
function pass(name) {
passCount += 1;
console.log(`\u2713 ${name}`);
}
function fail(name, error) {
failCount += 1;
console.error(`\u2717 ${name}`);
console.error(` ${String(error)}`);
}
function testFindMatchesFiltersByApplicationScope() {
const testName = "advisoryAppliesToOpenclaw: openclaw + legacy advisories are considered";
const inputs = [
{ id: "ADV-OPENCLAW-001", application: "openclaw", expect: true },
{ id: "ADV-NANOCLAW-001", application: "nanoclaw", expect: false },
{ id: "ADV-LEGACY-001", expect: true },
];
for (const input of inputs) {
const result = advisoryAppliesToOpenclaw({ application: input.application });
if (result !== input.expect) {
fail(testName, `Unexpected result for ${input.id}: expected ${input.expect}, got ${result}`);
return;
}
}
pass(testName);
}
function testApplicationAllAccepted() {
const testName = "advisoryAppliesToOpenclaw: application=all is considered";
const result = advisoryAppliesToOpenclaw({ application: "all" });
if (!result) {
fail(testName, "Expected true for application=all");
return;
}
pass(testName);
}
function testFindMatchesAcceptsApplicationArray() {
const testName = "advisoryAppliesToOpenclaw: application array containing openclaw is considered";
const result = advisoryAppliesToOpenclaw({ application: ["nanoclaw", "openclaw"] });
if (!result) {
fail(testName, "Expected true for application array containing openclaw");
return;
}
pass(testName);
}
function testInvalidApplicationValueFallsBackCompat() {
const testName = "advisoryAppliesToOpenclaw: invalid application values keep legacy compatibility";
const result = advisoryAppliesToOpenclaw({ application: { invalid: true } });
if (!result) {
fail(testName, "Expected true for non-string application to preserve backward compatibility");
return;
}
pass(testName);
}
function runTests() {
console.log("=== ClawSec Advisory Application Scope Tests ===\n");
testFindMatchesFiltersByApplicationScope();
testApplicationAllAccepted();
testFindMatchesAcceptsApplicationArray();
testInvalidApplicationValueFallsBackCompat();
console.log(`\n=== Results: ${passCount} passed, ${failCount} failed ===`);
if (failCount > 0) {
process.exit(1);
}
}
runTests();
@@ -325,6 +325,65 @@ async function testLoadNoConfigReturnsEmpty() {
}
}
async function testEnvPathHomeExpansion() {
const testName = "loadAdvisorySuppression: OPENCLAW_AUDIT_CONFIG expands $HOME";
try {
const configFile = path.join(tempDir, "env-home.json");
await fs.writeFile(configFile, JSON.stringify({
enabledFor: ["advisory"],
suppressions: [{
checkId: "CVE-2026-25593",
skill: "clawsec-suite",
reason: "Env home expansion",
suppressedAt: "2026-02-15",
}],
}));
const savedConfig = process.env.OPENCLAW_AUDIT_CONFIG;
const savedHome = process.env.HOME;
process.env.HOME = tempDir;
process.env.OPENCLAW_AUDIT_CONFIG = "$HOME/env-home.json";
try {
const config = await loadAdvisorySuppression();
if (config.suppressions.length === 1 && config.source === configFile) {
pass(testName);
} else {
fail(testName, `Expected env-expanded config, got: ${JSON.stringify(config)}`);
}
} finally {
if (savedConfig !== undefined) process.env.OPENCLAW_AUDIT_CONFIG = savedConfig;
else delete process.env.OPENCLAW_AUDIT_CONFIG;
if (savedHome !== undefined) process.env.HOME = savedHome;
else delete process.env.HOME;
}
} catch (error) {
fail(testName, error);
}
}
async function testEscapedHomeTokenRejected() {
const testName = "loadAdvisorySuppression: escaped home token is rejected";
try {
const savedEnv = process.env.OPENCLAW_AUDIT_CONFIG;
process.env.OPENCLAW_AUDIT_CONFIG = "\\$HOME/not-real.json";
try {
await loadAdvisorySuppression();
fail(testName, "Expected error for escaped token");
} catch (error) {
if (String(error).includes("Unexpanded home token")) {
pass(testName);
} else {
fail(testName, `Unexpected error: ${error}`);
}
} finally {
if (savedEnv !== undefined) process.env.OPENCLAW_AUDIT_CONFIG = savedEnv;
else delete process.env.OPENCLAW_AUDIT_CONFIG;
}
} catch (error) {
fail(testName, error);
}
}
// ---------------------------------------------------------------------------
// Main test runner
// ---------------------------------------------------------------------------
@@ -350,6 +409,8 @@ async function runAllTests() {
await testLoadWithBothSentinels();
await testLoadNonexistentExplicitPath();
await testLoadNoConfigReturnsEmpty();
await testEnvPathHomeExpansion();
await testEscapedHomeTokenRejected();
} finally {
await cleanupTestDir();
}
@@ -346,6 +346,55 @@ async function testMissingSignatureFails() {
}
}
// -----------------------------------------------------------------------------
// Test: $HOME path expansion for local feed paths
// -----------------------------------------------------------------------------
async function testHomeExpansionForLocalFeedPaths() {
const testName = "guarded_install: expands $HOME in local feed env paths";
try {
const keyPair = generateEd25519KeyPair();
await setupSignedFeed([], keyPair);
const result = await runGuardedInstall(["--skill", "test-skill", "--dry-run"], {
HOME: tempDir,
CLAWSEC_LOCAL_FEED: "$HOME/advisories/feed.json",
CLAWSEC_LOCAL_FEED_SIG: "$HOME/advisories/feed.json.sig",
CLAWSEC_LOCAL_FEED_CHECKSUMS: "$HOME/advisories/checksums.json",
CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG: "$HOME/advisories/checksums.json.sig",
CLAWSEC_FEED_PUBLIC_KEY: "$HOME/advisories/feed-signing-public.pem",
CLAWSEC_FEED_URL: "file:///nonexistent",
});
if (result.code === 0 && result.stdout.includes("Advisory source: local:")) {
pass(testName);
} else {
fail(testName, `Expected local feed success, got ${result.code}: ${result.stdout} ${result.stderr}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: escaped home token is rejected
// -----------------------------------------------------------------------------
async function testEscapedHomeTokenRejected() {
const testName = "guarded_install: escaped $HOME token is rejected";
try {
const result = await runGuardedInstall(["--skill", "test-skill", "--dry-run"], {
CLAWSEC_LOCAL_FEED: "\\$HOME/advisories/feed.json",
});
if (result.code === 1 && result.stderr.includes("Unexpanded home token")) {
pass(testName);
} else {
fail(testName, `Expected token validation error, got ${result.code}: ${result.stderr || result.stdout}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Main test runner
// -----------------------------------------------------------------------------
@@ -361,6 +410,8 @@ async function runTests() {
await testConfirmAdvisoryAllowsProceeding();
await testAllowUnsignedWarning();
await testMissingSignatureFails();
await testHomeExpansionForLocalFeedPaths();
await testEscapedHomeTokenRejected();
} finally {
await cleanupTestDir();
}
@@ -0,0 +1,169 @@
#!/usr/bin/env node
/**
* Path resolution tests for shared home-path expansion logic.
*
* Run: node skills/clawsec-suite/test/path_resolution.test.mjs
*/
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LIB_PATH = path.resolve(__dirname, "..", "hooks", "clawsec-advisory-guardian", "lib");
const { resolveUserPath, resolveConfiguredPath } = await import(`${LIB_PATH}/utils.mjs`);
let passCount = 0;
let failCount = 0;
function pass(name) {
passCount += 1;
console.log(`\u2713 ${name}`);
}
function fail(name, error) {
failCount += 1;
console.error(`\u2717 ${name}`);
console.error(` ${String(error)}`);
}
async function withEnv(key, value, fn) {
const oldValue = process.env[key];
try {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
return await fn();
} finally {
if (oldValue === undefined) {
delete process.env[key];
} else {
process.env[key] = oldValue;
}
}
}
async function testTildeExpansion() {
const testName = "resolveUserPath: expands leading tilde";
await withEnv("HOME", "/tmp/clawsec-home", async () => {
const resolved = resolveUserPath("~/skills/clawsec-suite", { label: "test tilde" });
const expected = path.normalize("/tmp/clawsec-home/skills/clawsec-suite");
if (resolved === expected) {
pass(testName);
} else {
fail(testName, `Expected ${expected}, got ${resolved}`);
}
});
}
async function testHomeVariableExpansion() {
const testName = "resolveUserPath: expands $HOME and ${HOME}";
await withEnv("HOME", "/tmp/clawsec-home", async () => {
const resolved1 = resolveUserPath("$HOME/skills", { label: "test $HOME" });
const resolved2 = resolveUserPath("${HOME}/skills", { label: "test ${HOME}" });
const expected = path.normalize("/tmp/clawsec-home/skills");
if (resolved1 === expected && resolved2 === expected) {
pass(testName);
} else {
fail(testName, `Expected ${expected}, got ${resolved1} / ${resolved2}`);
}
});
}
async function testUserProfileExpansion() {
const testName = "resolveUserPath: expands USERPROFILE syntaxes";
await withEnv("HOME", undefined, async () => {
await withEnv("USERPROFILE", "C:\\Users\\clawsec", async () => {
const resolved1 = resolveUserPath("%USERPROFILE%\\skills", { label: "test %USERPROFILE%" });
const resolved2 = resolveUserPath("$env:USERPROFILE\\skills", { label: "test $env:USERPROFILE" });
const expected = path.normalize("C:\\Users\\clawsec\\skills");
if (resolved1 === expected && resolved2 === expected) {
pass(testName);
} else {
fail(testName, `Expected ${expected}, got ${resolved1} / ${resolved2}`);
}
});
});
}
async function testEscapedTokenFails() {
const testName = "resolveUserPath: rejects escaped or unresolved home tokens";
try {
resolveUserPath("\\$HOME/skills", { label: "test escaped token" });
fail(testName, "Expected error for escaped token");
} catch (error) {
if (String(error).includes("Unexpanded home token")) {
pass(testName);
} else {
fail(testName, `Unexpected error: ${error}`);
}
}
}
async function testConfiguredPathFallbackOnInvalidExplicit() {
const testName = "resolveConfiguredPath: falls back when explicit env value is invalid";
try {
let fallbackReason = "";
const resolved = resolveConfiguredPath("\\$HOME/skills", "/tmp/clawsec-default", {
label: "CLAWSEC_LOCAL_FEED_SIG",
onInvalid: (error, rawValue) => {
fallbackReason = `${rawValue} :: ${String(error)}`;
},
});
const expected = path.normalize("/tmp/clawsec-default");
if (
resolved === expected &&
fallbackReason.includes("\\$HOME/skills") &&
fallbackReason.includes("Unexpanded home token")
) {
pass(testName);
} else {
fail(testName, `Expected fallback ${expected}, got ${resolved} (${fallbackReason})`);
}
} catch (error) {
fail(testName, error);
}
}
async function testConfiguredPathUsesValidExplicit() {
const testName = "resolveConfiguredPath: keeps valid explicit value";
try {
const resolved = resolveConfiguredPath("$HOME/skills", "/tmp/clawsec-default", {
label: "CLAWSEC_INSTALL_ROOT",
onInvalid: () => {
throw new Error("onInvalid should not run for a valid explicit path");
},
});
const expected = path.normalize(`${process.env.HOME || ""}/skills`);
if (resolved === expected) {
pass(testName);
} else {
fail(testName, `Expected ${expected}, got ${resolved}`);
}
} catch (error) {
fail(testName, error);
}
}
async function runTests() {
console.log("=== ClawSec Path Resolution Tests ===\n");
await testTildeExpansion();
await testHomeVariableExpansion();
await testUserProfileExpansion();
await testEscapedTokenFails();
await testConfiguredPathFallbackOnInvalidExplicit();
await testConfiguredPathUsesValidExplicit();
console.log(`\n=== Results: ${passCount} passed, ${failCount} failed ===`);
if (failCount > 0) {
process.exit(1);
}
}
runTests().catch((error) => {
console.error("Test runner failed:", error);
process.exit(1);
});
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.1]
### Added
- Contributor credit: portability and path-hardening improvements in this release were contributed by [@aldodelgado](https://github.com/aldodelgado) in PR #62.
- Cross-shell home-path expansion support in watchdog path inputs (`~`, `$HOME`, `${HOME}`, `%USERPROFILE%`, `$env:HOME`).
- Regression coverage for suppression-config home-token expansion and escaped-token rejection (`test/suppression_config.test.mjs`).
### Changed
- `scripts/codex_review.sh` now resolves the Codex CLI from `CODEX_BIN`, then `PATH`, then Homebrew fallback for improved portability.
- `scripts/setup_cron.mjs` now normalizes and validates install-dir/home-derived paths before job creation.
- `scripts/load_suppression_config.mjs` now resolves/normalizes configured file paths consistently across shell styles.
### Security
- Escaped or unresolved home tokens in suppression config paths now fail fast to avoid silently using unintended literal paths.
## [0.1.0]
### Added
+9
View File
@@ -39,6 +39,15 @@ export PROMPTSEC_HOST_LABEL="prod-agent-1"
| `PROMPTSEC_GIT_PULL` | Pull latest before audit (0/1) | `0` |
| `OPENCLAW_AUDIT_CONFIG` | Path to suppression config file | Auto-detected |
### Path Expansion and Quoting
- `PROMPTSEC_INSTALL_DIR` and `OPENCLAW_AUDIT_CONFIG` support `~`, `$HOME`, `${HOME}`, `%USERPROFILE%`, and `$env:USERPROFILE`.
- In `bash`/`zsh`, use double quotes for expandable paths:
- `export PROMPTSEC_INSTALL_DIR="$HOME/.config/security-checkup"`
- Avoid single-quoted literals such as `'$HOME/.config/security-checkup'`.
- In PowerShell:
- `$env:PROMPTSEC_INSTALL_DIR = Join-Path $HOME ".config/security-checkup"`
## Suppression / Allowlist
Manage false-positive findings with the built-in suppression mechanism. Suppressed findings remain visible in reports but are demoted to informational status and do not count toward critical/warning totals.
+7 -1
View File
@@ -1,6 +1,6 @@
---
name: openclaw-audit-watchdog
version: 0.1.0
version: 0.1.1
description: Automated daily security audits for OpenClaw agents with email reporting. Runs deep audits and sends formatted reports.
homepage: https://clawsec.prompt.security
metadata: {"openclaw":{"emoji":"🔭","category":"security"}}
@@ -271,6 +271,12 @@ Optional env:
- `PROMPTSEC_INSTALL_DIR` (stable path used by cron payload to `cd` before running runner; default: `~/.config/security-checkup`)
- `PROMPTSEC_GIT_PULL=1` (runner will `git pull --ff-only` if installed from git)
Path expansion rules (important):
- In `bash`/`zsh`, use `PROMPTSEC_INSTALL_DIR="$HOME/.config/security-checkup"` (or absolute path).
- Do not pass a single-quoted literal like `'$HOME/.config/security-checkup'`.
- On PowerShell, prefer: `$env:PROMPTSEC_INSTALL_DIR = Join-Path $HOME ".config/security-checkup"`.
- If path resolution fails, setup now exits with a clear error instead of creating a literal `$HOME` directory segment.
Interactive install is last resort if env vars or defaults are not set.
even in that case keep prompts minimalistic the watchdog tool is pretty straight up configured out of the box.
@@ -6,15 +6,20 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CODEX_BIN="/opt/homebrew/bin/codex"
if [[ ! -x "$CODEX_BIN" ]]; then
echo "codex not found at $CODEX_BIN" >&2
if [[ -n "${CODEX_BIN:-}" ]]; then
RESOLVED_CODEX_BIN="$CODEX_BIN"
elif command -v codex >/dev/null 2>&1; then
RESOLVED_CODEX_BIN="$(command -v codex)"
elif [[ -x "/opt/homebrew/bin/codex" ]]; then
RESOLVED_CODEX_BIN="/opt/homebrew/bin/codex"
else
echo "codex CLI not found. Install Codex CLI and ensure 'codex' is in PATH." >&2
exit 127
fi
# Use GPT-5.1 Codex Max (high reasoning). Note: some models (e.g. o3) may be blocked
# depending on the account type.
exec "$CODEX_BIN" review -s read-only -m gpt-5.1-codex-max \
exec "$RESOLVED_CODEX_BIN" review -s read-only -m gpt-5.1-codex-max \
"Review this skill for security/reliability issues. Focus on: shell quoting, command injection, sendmail header injection, dependency checks, cron payload safety, and failure modes. Provide concrete patch suggestions (with diffs if possible)." \
-c "workdir=\"$ROOT_DIR\"" \
-c "reasoning_effort=\"xhigh\""
@@ -6,6 +6,55 @@ import os from "node:os";
const DEFAULT_PRIMARY_PATH = path.join(os.homedir(), ".openclaw", "security-audit.json");
const DEFAULT_FALLBACK_PATH = ".clawsec/allowlist.json";
const UNEXPANDED_HOME_TOKEN_PATTERN =
/(?:^|[\\/])(?:\\?\$HOME|\\?\$\{HOME\}|\\?\$USERPROFILE|\\?\$\{USERPROFILE\}|%HOME%|%USERPROFILE%|\$env:HOME|\$env:USERPROFILE)(?:$|[\\/])/i;
function detectHomeDirectory(env = process.env) {
if (typeof env.HOME === "string" && env.HOME.trim()) return env.HOME.trim();
if (typeof env.USERPROFILE === "string" && env.USERPROFILE.trim()) return env.USERPROFILE.trim();
if (
typeof env.HOMEDRIVE === "string" &&
env.HOMEDRIVE.trim() &&
typeof env.HOMEPATH === "string" &&
env.HOMEPATH.trim()
) {
return `${env.HOMEDRIVE.trim()}${env.HOMEPATH.trim()}`;
}
return os.homedir();
}
function resolveUserPath(inputPath, label) {
const raw = String(inputPath ?? "").trim();
if (!raw) return raw;
const homeDir = detectHomeDirectory(process.env);
let expanded = raw;
if (expanded === "~") {
expanded = homeDir;
} else if (expanded.startsWith("~/") || expanded.startsWith("~\\")) {
expanded = path.join(homeDir, expanded.slice(2));
}
expanded = expanded
.replace(/(?<!\\)\$\{HOME\}/g, homeDir)
.replace(/(?<!\\)\$HOME(?=$|[\\/])/g, homeDir)
.replace(/(?<!\\)\$\{USERPROFILE\}/gi, homeDir)
.replace(/(?<!\\)\$USERPROFILE(?=$|[\\/])/gi, homeDir)
.replace(/%HOME%/gi, homeDir)
.replace(/%USERPROFILE%/gi, homeDir)
.replace(/(?<!\\)\$env:HOME/gi, homeDir)
.replace(/(?<!\\)\$env:USERPROFILE/gi, homeDir);
const normalized = path.normalize(expanded);
if (UNEXPANDED_HOME_TOKEN_PATTERN.test(normalized)) {
throw new Error(
`Unexpanded home token detected in ${label}: ${raw}. ` +
"Use an absolute path or an unquoted home-path expression.",
);
}
return normalized;
}
function isObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -130,9 +179,10 @@ const EMPTY_RESULT = Object.freeze({ suppressions: [], source: "none" });
async function resolveConfig(customPath) {
// Priority 1: Custom path provided as argument
if (customPath) {
const config = await loadConfigFromPath(customPath);
const resolved = resolveUserPath(customPath, "custom suppression config path");
const config = await loadConfigFromPath(resolved);
if (!config) {
throw new Error(`Custom config file not found: ${customPath}`);
throw new Error(`Custom config file not found: ${resolved}`);
}
return config;
}
@@ -140,9 +190,10 @@ async function resolveConfig(customPath) {
// Priority 2: Environment variable
const envPath = process.env.OPENCLAW_AUDIT_CONFIG;
if (envPath) {
const config = await loadConfigFromPath(envPath);
const resolved = resolveUserPath(envPath, "OPENCLAW_AUDIT_CONFIG");
const config = await loadConfigFromPath(resolved);
if (!config) {
throw new Error(`Config file from OPENCLAW_AUDIT_CONFIG not found: ${envPath}`);
throw new Error(`Config file from OPENCLAW_AUDIT_CONFIG not found: ${resolved}`);
}
return config;
}
@@ -10,6 +10,7 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import readline from "node:readline";
import { fileURLToPath } from "node:url";
@@ -20,6 +21,8 @@ const DEFAULT_TZ = "UTC";
const DEFAULT_EXPR = "0 23 * * *"; // 23:00 daily
const SCRIPT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const UNEXPANDED_HOME_TOKEN_PATTERN =
/(?:^|[\\/])(?:\\?\$HOME|\\?\$\{HOME\}|\\?\$USERPROFILE|\\?\$\{USERPROFILE\}|%HOME%|%USERPROFILE%|\$env:HOME|\$env:USERPROFILE)(?:$|[\\/])/i;
function sh(cmd, args, { input } = {}) {
const res = spawnSync(cmd, args, {
@@ -49,6 +52,51 @@ function envOrEmpty(name) {
return typeof v === "string" ? v.trim() : "";
}
function detectHomeDirectory() {
const home = envOrEmpty("HOME");
if (home) return home;
const userProfile = envOrEmpty("USERPROFILE");
if (userProfile) return userProfile;
const homeDrive = envOrEmpty("HOMEDRIVE");
const homePath = envOrEmpty("HOMEPATH");
if (homeDrive && homePath) return `${homeDrive}${homePath}`;
return os.homedir();
}
function resolveUserPath(inputPath, label) {
const raw = String(inputPath ?? "").trim();
if (!raw) return raw;
const homeDir = detectHomeDirectory();
let expanded = raw;
if (expanded === "~") {
expanded = homeDir;
} else if (expanded.startsWith("~/") || expanded.startsWith("~\\")) {
expanded = path.join(homeDir, expanded.slice(2));
}
expanded = expanded
.replace(/(?<!\\)\$\{HOME\}/g, homeDir)
.replace(/(?<!\\)\$HOME(?=$|[\\/])/g, homeDir)
.replace(/(?<!\\)\$\{USERPROFILE\}/gi, homeDir)
.replace(/(?<!\\)\$USERPROFILE(?=$|[\\/])/gi, homeDir)
.replace(/%HOME%/gi, homeDir)
.replace(/%USERPROFILE%/gi, homeDir)
.replace(/(?<!\\)\$env:HOME/gi, homeDir)
.replace(/(?<!\\)\$env:USERPROFILE/gi, homeDir);
const normalized = path.normalize(expanded);
if (UNEXPANDED_HOME_TOKEN_PATTERN.test(normalized)) {
throw new Error(
`Unexpanded home token detected in ${label}: ${raw}. ` +
"Use an absolute path or an unquoted home-path expression.",
);
}
return normalized;
}
function oneline(v) {
return String(v ?? "")
.replace(/[\r\n]+/g, " ")
@@ -69,10 +117,10 @@ function escapeForShellEnvVar(v) {
function defaultInstallDir() {
const env = envOrEmpty("PROMPTSEC_INSTALL_DIR");
if (env) return env;
const home = envOrEmpty("HOME");
if (env) return resolveUserPath(env, "PROMPTSEC_INSTALL_DIR");
const home = detectHomeDirectory();
if (home) return path.join(home, ".config", "security-checkup");
return SCRIPT_ROOT;
return resolveUserPath(SCRIPT_ROOT, "script root");
}
function buildAgentMessage({ dmChannel, dmTo, hostLabel, installDir }) {
@@ -127,9 +175,10 @@ async function run() {
: hostLabelEnv;
const installDirDefault = defaultInstallDir();
const installDir = interactive
const installDirInput = interactive
? await prompt("Install dir containing scripts/runner.sh", { defaultValue: installDirDefault })
: installDirDefault;
const installDir = resolveUserPath(installDirInput, "install dir containing scripts/runner.sh");
if (!dmChannel || !dmTo) {
throw new Error("Missing DM target. Set PROMPTSEC_DM_CHANNEL and PROMPTSEC_DM_TO (or run interactively). ");
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openclaw-audit-watchdog",
"version": "0.1.0",
"version": "0.1.1",
"description": "Automated daily security audits for OpenClaw agents with email reporting. Runs deep audits and sends formatted reports.",
"author": "prompt-security",
"license": "AGPL-3.0-or-later",
@@ -20,20 +20,10 @@ import os from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { execSync } from "node:child_process";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SCRIPT_PATH = path.resolve(__dirname, "..", "scripts", "render_report.mjs");
// Find node executable (may not be in PATH in restricted environments)
let NODE_BIN = "node";
try {
NODE_BIN = execSync("which node 2>/dev/null || echo /opt/homebrew/bin/node", {
encoding: "utf8",
}).trim();
} catch {
NODE_BIN = "/opt/homebrew/bin/node";
}
const NODE_BIN = process.execPath;
let tempDir;
let passCount = 0;
@@ -379,6 +379,78 @@ async function testEnvironmentVariableOverride() {
}
}
// -----------------------------------------------------------------------------
// Test: environment variable path expands $HOME
// -----------------------------------------------------------------------------
async function testEnvironmentVariableHomeExpansion() {
const testName = "loadSuppressionConfig: OPENCLAW_AUDIT_CONFIG expands $HOME path";
let fixture = null;
try {
const envConfig = makeConfig([
{
checkId: "ENV-HOME-001",
skill: "env-skill",
reason: "Environment variable home expansion",
suppressedAt: "2026-02-15",
},
]);
fixture = await withTempFile(envConfig);
const fixtureDir = path.dirname(fixture.path);
const fixtureBase = path.basename(fixture.path);
await withEnv("HOME", fixtureDir, async () => {
await withEnv("OPENCLAW_AUDIT_CONFIG", `$HOME/${fixtureBase}`, async () => {
const config = await silenceStderr(() =>
loadSuppressionConfig(null, { enabled: true })
);
if (
config.source === fixture.path &&
config.suppressions.length === 1 &&
config.suppressions[0].checkId === "ENV-HOME-001"
) {
pass(testName);
} else {
fail(testName, `Unexpected config: ${JSON.stringify(config)}`);
}
});
});
} catch (error) {
fail(testName, error);
} finally {
if (fixture) {
await fixture.cleanup();
}
}
}
// -----------------------------------------------------------------------------
// Test: escaped token is rejected (no silent literal path use)
// -----------------------------------------------------------------------------
async function testEscapedHomeTokenRejected() {
const testName = "loadSuppressionConfig: escaped $HOME token is rejected";
try {
await withEnv("OPENCLAW_AUDIT_CONFIG", "\\$HOME/config.json", async () => {
try {
await silenceStderr(() =>
loadSuppressionConfig(null, { enabled: true })
);
fail(testName, "Expected error for escaped home token");
} catch (err) {
if (String(err.message || err).includes("Unexpanded home token")) {
pass(testName);
} else {
fail(testName, `Wrong error message: ${err.message || err}`);
}
}
});
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: missing suppressions array fails
// -----------------------------------------------------------------------------
@@ -665,6 +737,8 @@ async function runTests() {
await testFileNotFoundGracefulFallback();
await testCustomPathPriority();
await testEnvironmentVariableOverride();
await testEnvironmentVariableHomeExpansion();
await testEscapedHomeTokenRejected();
await testMissingSuppressions();
await testEmptySuppressions();
await testCustomPathNotFoundFails();