mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-01 15:52:26 +03:00
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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 agent’s 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.**
|
||||
@@ -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
|
||||
Generated
+13
-2
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
Reference in New Issue
Block a user