mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-13 05:28:02 +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:
|
jobs:
|
||||||
lint-typescript:
|
lint-typescript:
|
||||||
name: Lint TypeScript/React
|
name: Lint TypeScript/React (${{ matrix.os }})
|
||||||
runs-on: ubuntu-latest
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os:
|
||||||
|
- ubuntu-latest
|
||||||
|
- macos-latest
|
||||||
|
- windows-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
@@ -98,3 +105,22 @@ jobs:
|
|||||||
run: node skills/clawsec-suite/test/feed_verification.test.mjs
|
run: node skills/clawsec-suite/test/feed_verification.test.mjs
|
||||||
- name: Guarded Install Tests
|
- name: Guarded Install Tests
|
||||||
run: node skills/clawsec-suite/test/guarded_install.test.mjs
|
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.
|
# Repository Guidelines
|
||||||
- **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.
|
## Project Structure & Module Organization
|
||||||
- Coordinate with other agents before removing their in-progress edits—don't revert or delete work you didn't author unless everyone agrees.
|
ClawSec combines a Vite + React frontend with security skill packages and release tooling.
|
||||||
- Moving/renaming and restoring files is allowed.
|
- Frontend entrypoints: `index.tsx`, `App.tsx`
|
||||||
- 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.)*
|
- UI and routes: `components/`, `pages/`
|
||||||
- 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.
|
- Shared types/constants: `types.ts`, `constants.ts`
|
||||||
- Always double-check git status before any commit
|
- Skills: `skills/<skill-name>/` (`skill.json`, `SKILL.md`, optional `scripts/`, `test/`)
|
||||||
- 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`.
|
- Advisory feed: `advisories/feed.json`, `advisories/feed.json.sig`
|
||||||
- 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.
|
- Automation: `scripts/`, `.github/workflows/`
|
||||||
- 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.
|
- Python utilities: `utils/validate_skill.py`, `utils/package_skill.py`
|
||||||
- Never amend commits unless you have explicit written approval in the task thread.
|
|
||||||
|
## 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.
|
> 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
|
## 📱 NanoClaw Platform Support
|
||||||
@@ -277,8 +295,8 @@ Each skill release includes:
|
|||||||
### Signing Operations Documentation
|
### Signing Operations Documentation
|
||||||
|
|
||||||
For feed/release signing rollout and operations guidance:
|
For feed/release signing rollout and operations guidance:
|
||||||
- [`SECURITY-SIGNING.md`](SECURITY-SIGNING.md) - key generation, GitHub secrets, rotation/revocation, incident response
|
- [`docs/SECURITY-SIGNING.md`](docs/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/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",
|
"version": "7.29.0",
|
||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -1186,6 +1187,7 @@
|
|||||||
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
|
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
@@ -1285,6 +1287,7 @@
|
|||||||
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
|
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.56.0",
|
"@typescript-eslint/scope-manager": "8.56.0",
|
||||||
"@typescript-eslint/types": "8.56.0",
|
"@typescript-eslint/types": "8.56.0",
|
||||||
@@ -1594,6 +1597,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -1850,6 +1854,7 @@
|
|||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -2050,8 +2055,7 @@
|
|||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/data-view-buffer": {
|
"node_modules/data-view-buffer": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
@@ -2438,6 +2442,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
|
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
|
||||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -4714,6 +4719,7 @@
|
|||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -4794,6 +4800,7 @@
|
|||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.2.4",
|
"version": "19.2.4",
|
||||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -4801,6 +4808,7 @@
|
|||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.2.4",
|
"version": "19.2.4",
|
||||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -5548,6 +5556,7 @@
|
|||||||
"version": "5.8.3",
|
"version": "5.8.3",
|
||||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -5723,6 +5732,7 @@
|
|||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -5915,6 +5925,7 @@
|
|||||||
"version": "4.3.6",
|
"version": "4.3.6",
|
||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"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
|
- [Skill Documentation](skills/clawsec-nanoclaw/SKILL.md) - Features and architecture
|
||||||
- [Installation Guide](skills/clawsec-nanoclaw/INSTALL.md) - Detailed setup instructions
|
- [Installation Guide](skills/clawsec-nanoclaw/INSTALL.md) - Detailed setup instructions
|
||||||
- [ClawSec Main README](README.md) - Overall ClawSec documentation
|
- [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
|
## 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/),
|
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).
|
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]
|
## [0.1.2]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: clawsec-suite
|
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.
|
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
|
homepage: https://clawsec.prompt.security
|
||||||
clawdis:
|
clawdis:
|
||||||
@@ -45,6 +45,14 @@ Fallback behavior:
|
|||||||
|
|
||||||
## Installation
|
## 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)
|
### Option A: Via clawhub (recommended)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -148,6 +156,7 @@ node "$SUITE_DIR/scripts/setup_advisory_cron.mjs"
|
|||||||
What this adds:
|
What this adds:
|
||||||
- scan on `agent:bootstrap` and `/new` (`command:new`),
|
- scan on `agent:bootstrap` and `/new` (`command:new`),
|
||||||
- compare advisory `affected` entries against installed skills,
|
- 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,
|
- notify when new matches appear,
|
||||||
- and ask for explicit user approval before any removal flow.
|
- and ask for explicit user approval before any removal flow.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
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 { defaultChecksumsUrl, loadLocalFeed, loadRemoteFeed } from "./lib/feed.mjs";
|
||||||
import type { HookEvent, FeedPayload, AdvisoryMatch } from "./lib/types.ts";
|
import type { HookEvent, FeedPayload, AdvisoryMatch } from "./lib/types.ts";
|
||||||
import { loadState, persistState } from "./lib/state.ts";
|
import { loadState, persistState } from "./lib/state.ts";
|
||||||
@@ -13,13 +13,6 @@ const DEFAULT_FEED_URL =
|
|||||||
const DEFAULT_SCAN_INTERVAL_SECONDS = 300;
|
const DEFAULT_SCAN_INTERVAL_SECONDS = 300;
|
||||||
let unsignedModeWarningShown = false;
|
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 {
|
function parsePositiveInteger(value: string | undefined, fallback: number): number {
|
||||||
const parsed = Number.parseInt(String(value ?? ""), 10);
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
||||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
@@ -51,6 +44,21 @@ function scannedRecently(lastScan: string | null, minIntervalSeconds: number): b
|
|||||||
return sinceMs >= 0 && sinceMs < minIntervalSeconds * 1000;
|
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: {
|
async function loadFeed(options: {
|
||||||
feedUrl: string;
|
feedUrl: string;
|
||||||
feedSignatureUrl: string;
|
feedSignatureUrl: string;
|
||||||
@@ -92,25 +100,45 @@ async function loadFeed(options: {
|
|||||||
const handler = async (event: HookEvent): Promise<void> => {
|
const handler = async (event: HookEvent): Promise<void> => {
|
||||||
if (!shouldHandleEvent(event)) return;
|
if (!shouldHandleEvent(event)) return;
|
||||||
|
|
||||||
const installRoot = expandHome(
|
const installRoot = configuredPath(
|
||||||
process.env.CLAWSEC_INSTALL_ROOT || process.env.INSTALL_ROOT || path.join(os.homedir(), ".openclaw", "skills"),
|
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 suiteDir = configuredPath(
|
||||||
const localFeedPath = expandHome(process.env.CLAWSEC_LOCAL_FEED || path.join(suiteDir, "advisories", "feed.json"));
|
process.env.CLAWSEC_SUITE_DIR,
|
||||||
const localFeedSignaturePath = expandHome(
|
path.join(installRoot, "clawsec-suite"),
|
||||||
process.env.CLAWSEC_LOCAL_FEED_SIG || `${localFeedPath}.sig`,
|
"CLAWSEC_SUITE_DIR",
|
||||||
);
|
);
|
||||||
const localFeedChecksumsPath = expandHome(
|
const localFeedPath = configuredPath(
|
||||||
process.env.CLAWSEC_LOCAL_FEED_CHECKSUMS || path.join(path.dirname(localFeedPath), "checksums.json"),
|
process.env.CLAWSEC_LOCAL_FEED,
|
||||||
|
path.join(suiteDir, "advisories", "feed.json"),
|
||||||
|
"CLAWSEC_LOCAL_FEED",
|
||||||
);
|
);
|
||||||
const localFeedChecksumsSignaturePath = expandHome(
|
const localFeedSignaturePath = configuredPath(
|
||||||
process.env.CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG || `${localFeedChecksumsPath}.sig`,
|
process.env.CLAWSEC_LOCAL_FEED_SIG,
|
||||||
|
`${localFeedPath}.sig`,
|
||||||
|
"CLAWSEC_LOCAL_FEED_SIG",
|
||||||
);
|
);
|
||||||
const feedPublicKeyPath = expandHome(
|
const localFeedChecksumsPath = configuredPath(
|
||||||
process.env.CLAWSEC_FEED_PUBLIC_KEY || path.join(suiteDir, "advisories", "feed-signing-public.pem"),
|
process.env.CLAWSEC_LOCAL_FEED_CHECKSUMS,
|
||||||
|
path.join(path.dirname(localFeedPath), "checksums.json"),
|
||||||
|
"CLAWSEC_LOCAL_FEED_CHECKSUMS",
|
||||||
);
|
);
|
||||||
const stateFile = expandHome(
|
const localFeedChecksumsSignaturePath = configuredPath(
|
||||||
process.env.CLAWSEC_SUITE_STATE_FILE || path.join(os.homedir(), ".openclaw", "clawsec-suite-feed-state.json"),
|
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 feedUrl = process.env.CLAWSEC_FEED_URL || DEFAULT_FEED_URL;
|
||||||
const feedSignatureUrl = process.env.CLAWSEC_FEED_SIG_URL || `${feedUrl}.sig`;
|
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 fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { isObject, normalizeSkillName, uniqueStrings } from "./utils.mjs";
|
import { isObject, normalizeSkillName, uniqueStrings } from "./utils.mjs";
|
||||||
|
import { advisoryAppliesToOpenclaw } from "./advisory_scope.mjs";
|
||||||
import { versionMatches } from "./version.mjs";
|
import { versionMatches } from "./version.mjs";
|
||||||
import { parseAffectedSpecifier } from "./feed.mjs";
|
import { parseAffectedSpecifier } from "./feed.mjs";
|
||||||
import type { Advisory, FeedPayload, InstalledSkill, AdvisoryMatch } from "./types.ts";
|
import type { Advisory, FeedPayload, InstalledSkill, AdvisoryMatch } from "./types.ts";
|
||||||
@@ -68,6 +69,8 @@ export function findMatches(feed: FeedPayload, installedSkills: InstalledSkill[]
|
|||||||
const matches: AdvisoryMatch[] = [];
|
const matches: AdvisoryMatch[] = [];
|
||||||
|
|
||||||
for (const advisory of feed.advisories) {
|
for (const advisory of feed.advisories) {
|
||||||
|
if (!advisoryAppliesToOpenclaw(advisory)) continue;
|
||||||
|
|
||||||
const affected = Array.isArray(advisory.affected) ? advisory.affected : [];
|
const affected = Array.isArray(advisory.affected) ? advisory.affected : [];
|
||||||
if (affected.length === 0) continue;
|
if (affected.length === 0) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import os from "node:os";
|
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_PRIMARY_PATH = path.join(os.homedir(), ".openclaw", "security-audit.json");
|
||||||
const DEFAULT_FALLBACK_PATH = ".clawsec/allowlist.json";
|
const DEFAULT_FALLBACK_PATH = ".clawsec/allowlist.json";
|
||||||
@@ -94,8 +94,9 @@ async function loadConfigFromPath(configPath) {
|
|||||||
export async function loadAdvisorySuppression(configPath) {
|
export async function loadAdvisorySuppression(configPath) {
|
||||||
// Priority 1: Explicit path
|
// Priority 1: Explicit path
|
||||||
if (configPath) {
|
if (configPath) {
|
||||||
const config = await loadConfigFromPath(configPath);
|
const resolved = resolveUserPath(configPath, { label: "advisory suppression config path" });
|
||||||
if (!config) throw new Error(`Advisory suppression config not found: ${configPath}`);
|
const config = await loadConfigFromPath(resolved);
|
||||||
|
if (!config) throw new Error(`Advisory suppression config not found: ${resolved}`);
|
||||||
if (!config.enabledFor.includes("advisory")) return { ...EMPTY_CONFIG };
|
if (!config.enabledFor.includes("advisory")) return { ...EMPTY_CONFIG };
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
@@ -103,7 +104,8 @@ export async function loadAdvisorySuppression(configPath) {
|
|||||||
// Priority 2: Environment variable
|
// Priority 2: Environment variable
|
||||||
const envPath = process.env.OPENCLAW_AUDIT_CONFIG;
|
const envPath = process.env.OPENCLAW_AUDIT_CONFIG;
|
||||||
if (typeof envPath === "string" && envPath.trim()) {
|
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;
|
if (config && config.enabledFor.includes("advisory")) return config;
|
||||||
return { ...EMPTY_CONFIG };
|
return { ...EMPTY_CONFIG };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type Advisory = {
|
|||||||
id?: string;
|
id?: string;
|
||||||
severity?: string;
|
severity?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
|
application?: string | string[];
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
action?: string;
|
action?: string;
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {unknown} value
|
* @param {unknown} value
|
||||||
* @returns {value is Record<string, unknown>}
|
* @returns {value is Record<string, unknown>}
|
||||||
@@ -23,3 +26,110 @@ export function normalizeSkillName(value) {
|
|||||||
export function uniqueStrings(values) {
|
export function uniqueStrings(values) {
|
||||||
return Array.from(new Set(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 fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
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 { versionMatches } from "../hooks/clawsec-advisory-guardian/lib/version.mjs";
|
||||||
import {
|
import {
|
||||||
defaultChecksumsUrl,
|
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 DEFAULT_FEED_PUBLIC_KEY = path.join(DEFAULT_SUITE_DIR, "advisories", "feed-signing-public.pem");
|
||||||
const EXIT_CONFIRM_REQUIRED = 42;
|
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() {
|
function printUsage() {
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
[
|
[
|
||||||
@@ -118,11 +124,19 @@ async function loadFeed() {
|
|||||||
const feedSignatureUrl = process.env.CLAWSEC_FEED_SIG_URL || `${feedUrl}.sig`;
|
const feedSignatureUrl = process.env.CLAWSEC_FEED_SIG_URL || `${feedUrl}.sig`;
|
||||||
const feedChecksumsUrl = process.env.CLAWSEC_FEED_CHECKSUMS_URL || defaultChecksumsUrl(feedUrl);
|
const feedChecksumsUrl = process.env.CLAWSEC_FEED_CHECKSUMS_URL || defaultChecksumsUrl(feedUrl);
|
||||||
const feedChecksumsSignatureUrl = process.env.CLAWSEC_FEED_CHECKSUMS_SIG_URL || `${feedChecksumsUrl}.sig`;
|
const feedChecksumsSignatureUrl = process.env.CLAWSEC_FEED_CHECKSUMS_SIG_URL || `${feedChecksumsUrl}.sig`;
|
||||||
const localFeedPath = process.env.CLAWSEC_LOCAL_FEED || DEFAULT_LOCAL_FEED;
|
const localFeedPath = envPathOrDefault("CLAWSEC_LOCAL_FEED", DEFAULT_LOCAL_FEED, "CLAWSEC_LOCAL_FEED");
|
||||||
const localFeedSigPath = process.env.CLAWSEC_LOCAL_FEED_SIG || DEFAULT_LOCAL_FEED_SIG;
|
const localFeedSigPath = envPathOrDefault("CLAWSEC_LOCAL_FEED_SIG", DEFAULT_LOCAL_FEED_SIG, "CLAWSEC_LOCAL_FEED_SIG");
|
||||||
const localFeedChecksumsPath = process.env.CLAWSEC_LOCAL_FEED_CHECKSUMS || DEFAULT_LOCAL_FEED_CHECKSUMS;
|
const localFeedChecksumsPath = envPathOrDefault(
|
||||||
const localFeedChecksumsSigPath = process.env.CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG || DEFAULT_LOCAL_FEED_CHECKSUMS_SIG;
|
"CLAWSEC_LOCAL_FEED_CHECKSUMS",
|
||||||
const feedPublicKeyPath = process.env.CLAWSEC_FEED_PUBLIC_KEY || DEFAULT_FEED_PUBLIC_KEY;
|
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 allowUnsigned = process.env.CLAWSEC_ALLOW_UNSIGNED_FEED === "1";
|
||||||
const verifyChecksumManifest = process.env.CLAWSEC_VERIFY_CHECKSUM_MANIFEST !== "0";
|
const verifyChecksumManifest = process.env.CLAWSEC_VERIFY_CHECKSUM_MANIFEST !== "0";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "clawsec-suite",
|
"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.",
|
"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",
|
"author": "prompt-security",
|
||||||
"license": "AGPL-3.0-or-later",
|
"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
|
// Main test runner
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -350,6 +409,8 @@ async function runAllTests() {
|
|||||||
await testLoadWithBothSentinels();
|
await testLoadWithBothSentinels();
|
||||||
await testLoadNonexistentExplicitPath();
|
await testLoadNonexistentExplicitPath();
|
||||||
await testLoadNoConfigReturnsEmpty();
|
await testLoadNoConfigReturnsEmpty();
|
||||||
|
await testEnvPathHomeExpansion();
|
||||||
|
await testEscapedHomeTokenRejected();
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestDir();
|
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
|
// Main test runner
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -361,6 +410,8 @@ async function runTests() {
|
|||||||
await testConfirmAdvisoryAllowsProceeding();
|
await testConfirmAdvisoryAllowsProceeding();
|
||||||
await testAllowUnsignedWarning();
|
await testAllowUnsignedWarning();
|
||||||
await testMissingSignatureFails();
|
await testMissingSignatureFails();
|
||||||
|
await testHomeExpansionForLocalFeedPaths();
|
||||||
|
await testEscapedHomeTokenRejected();
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestDir();
|
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/),
|
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).
|
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]
|
## [0.1.0]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ export PROMPTSEC_HOST_LABEL="prod-agent-1"
|
|||||||
| `PROMPTSEC_GIT_PULL` | Pull latest before audit (0/1) | `0` |
|
| `PROMPTSEC_GIT_PULL` | Pull latest before audit (0/1) | `0` |
|
||||||
| `OPENCLAW_AUDIT_CONFIG` | Path to suppression config file | Auto-detected |
|
| `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
|
## 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.
|
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
|
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.
|
description: Automated daily security audits for OpenClaw agents with email reporting. Runs deep audits and sends formatted reports.
|
||||||
homepage: https://clawsec.prompt.security
|
homepage: https://clawsec.prompt.security
|
||||||
metadata: {"openclaw":{"emoji":"🔭","category":"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_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)
|
- `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.
|
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.
|
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)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
CODEX_BIN="/opt/homebrew/bin/codex"
|
if [[ -n "${CODEX_BIN:-}" ]]; then
|
||||||
if [[ ! -x "$CODEX_BIN" ]]; then
|
RESOLVED_CODEX_BIN="$CODEX_BIN"
|
||||||
echo "codex not found at $CODEX_BIN" >&2
|
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
|
exit 127
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Use GPT-5.1 Codex Max (high reasoning). Note: some models (e.g. o3) may be blocked
|
# Use GPT-5.1 Codex Max (high reasoning). Note: some models (e.g. o3) may be blocked
|
||||||
# depending on the account type.
|
# 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)." \
|
"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 "workdir=\"$ROOT_DIR\"" \
|
||||||
-c "reasoning_effort=\"xhigh\""
|
-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_PRIMARY_PATH = path.join(os.homedir(), ".openclaw", "security-audit.json");
|
||||||
const DEFAULT_FALLBACK_PATH = ".clawsec/allowlist.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) {
|
function isObject(value) {
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(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) {
|
async function resolveConfig(customPath) {
|
||||||
// Priority 1: Custom path provided as argument
|
// Priority 1: Custom path provided as argument
|
||||||
if (customPath) {
|
if (customPath) {
|
||||||
const config = await loadConfigFromPath(customPath);
|
const resolved = resolveUserPath(customPath, "custom suppression config path");
|
||||||
|
const config = await loadConfigFromPath(resolved);
|
||||||
if (!config) {
|
if (!config) {
|
||||||
throw new Error(`Custom config file not found: ${customPath}`);
|
throw new Error(`Custom config file not found: ${resolved}`);
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
@@ -140,9 +190,10 @@ async function resolveConfig(customPath) {
|
|||||||
// Priority 2: Environment variable
|
// Priority 2: Environment variable
|
||||||
const envPath = process.env.OPENCLAW_AUDIT_CONFIG;
|
const envPath = process.env.OPENCLAW_AUDIT_CONFIG;
|
||||||
if (envPath) {
|
if (envPath) {
|
||||||
const config = await loadConfigFromPath(envPath);
|
const resolved = resolveUserPath(envPath, "OPENCLAW_AUDIT_CONFIG");
|
||||||
|
const config = await loadConfigFromPath(resolved);
|
||||||
if (!config) {
|
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;
|
return config;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import readline from "node:readline";
|
import readline from "node:readline";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
@@ -20,6 +21,8 @@ const DEFAULT_TZ = "UTC";
|
|||||||
const DEFAULT_EXPR = "0 23 * * *"; // 23:00 daily
|
const DEFAULT_EXPR = "0 23 * * *"; // 23:00 daily
|
||||||
|
|
||||||
const SCRIPT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
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 } = {}) {
|
function sh(cmd, args, { input } = {}) {
|
||||||
const res = spawnSync(cmd, args, {
|
const res = spawnSync(cmd, args, {
|
||||||
@@ -49,6 +52,51 @@ function envOrEmpty(name) {
|
|||||||
return typeof v === "string" ? v.trim() : "";
|
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) {
|
function oneline(v) {
|
||||||
return String(v ?? "")
|
return String(v ?? "")
|
||||||
.replace(/[\r\n]+/g, " ")
|
.replace(/[\r\n]+/g, " ")
|
||||||
@@ -69,10 +117,10 @@ function escapeForShellEnvVar(v) {
|
|||||||
|
|
||||||
function defaultInstallDir() {
|
function defaultInstallDir() {
|
||||||
const env = envOrEmpty("PROMPTSEC_INSTALL_DIR");
|
const env = envOrEmpty("PROMPTSEC_INSTALL_DIR");
|
||||||
if (env) return env;
|
if (env) return resolveUserPath(env, "PROMPTSEC_INSTALL_DIR");
|
||||||
const home = envOrEmpty("HOME");
|
const home = detectHomeDirectory();
|
||||||
if (home) return path.join(home, ".config", "security-checkup");
|
if (home) return path.join(home, ".config", "security-checkup");
|
||||||
return SCRIPT_ROOT;
|
return resolveUserPath(SCRIPT_ROOT, "script root");
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAgentMessage({ dmChannel, dmTo, hostLabel, installDir }) {
|
function buildAgentMessage({ dmChannel, dmTo, hostLabel, installDir }) {
|
||||||
@@ -127,9 +175,10 @@ async function run() {
|
|||||||
: hostLabelEnv;
|
: hostLabelEnv;
|
||||||
|
|
||||||
const installDirDefault = defaultInstallDir();
|
const installDirDefault = defaultInstallDir();
|
||||||
const installDir = interactive
|
const installDirInput = interactive
|
||||||
? await prompt("Install dir containing scripts/runner.sh", { defaultValue: installDirDefault })
|
? await prompt("Install dir containing scripts/runner.sh", { defaultValue: installDirDefault })
|
||||||
: installDirDefault;
|
: installDirDefault;
|
||||||
|
const installDir = resolveUserPath(installDirInput, "install dir containing scripts/runner.sh");
|
||||||
|
|
||||||
if (!dmChannel || !dmTo) {
|
if (!dmChannel || !dmTo) {
|
||||||
throw new Error("Missing DM target. Set PROMPTSEC_DM_CHANNEL and PROMPTSEC_DM_TO (or run interactively). ");
|
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",
|
"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.",
|
"description": "Automated daily security audits for OpenClaw agents with email reporting. Runs deep audits and sends formatted reports.",
|
||||||
"author": "prompt-security",
|
"author": "prompt-security",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
|
|||||||
@@ -20,20 +20,10 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { execSync } from "node:child_process";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const SCRIPT_PATH = path.resolve(__dirname, "..", "scripts", "render_report.mjs");
|
const SCRIPT_PATH = path.resolve(__dirname, "..", "scripts", "render_report.mjs");
|
||||||
|
const NODE_BIN = process.execPath;
|
||||||
// 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";
|
|
||||||
}
|
|
||||||
|
|
||||||
let tempDir;
|
let tempDir;
|
||||||
let passCount = 0;
|
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
|
// Test: missing suppressions array fails
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -665,6 +737,8 @@ async function runTests() {
|
|||||||
await testFileNotFoundGracefulFallback();
|
await testFileNotFoundGracefulFallback();
|
||||||
await testCustomPathPriority();
|
await testCustomPathPriority();
|
||||||
await testEnvironmentVariableOverride();
|
await testEnvironmentVariableOverride();
|
||||||
|
await testEnvironmentVariableHomeExpansion();
|
||||||
|
await testEscapedHomeTokenRejected();
|
||||||
await testMissingSuppressions();
|
await testMissingSuppressions();
|
||||||
await testEmptySuppressions();
|
await testEmptySuppressions();
|
||||||
await testCustomPathNotFoundFails();
|
await testCustomPathNotFoundFails();
|
||||||
|
|||||||
Reference in New Issue
Block a user