diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5abe759 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: + - dependencies + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: + - dependencies diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23ffd35..072643e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,16 +9,21 @@ on: jobs: syntax-check: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: [22, 24] steps: - uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 18 + node-version: ${{ matrix.node }} + cache: npm - name: Install dependencies - run: npm install + run: npm ci - name: Check syntax of all JS files run: | @@ -47,9 +52,56 @@ jobs: uses: actions/setup-node@v4 with: node-version: 22 + cache: npm - name: Install dependencies - run: npm install + run: npm ci - name: Run focused BoundaryAttest tests run: npm run test:boundaryattest + + audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Audit production dependencies (fails on high or critical) + run: npm audit --omit=dev --audit-level=high + + # The docs live in the agenv repo. This job checks that the tool pages still match + # packs/manifest.json and the permission levels in tools/security.js. + docs-drift: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check out the docs repo + uses: actions/checkout@v4 + with: + repository: KNIHAL/causly-agent-environment + ref: master + path: agenv + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Docs drift test + env: + CAUSLY_DOCS_DIR: ${{ github.workspace }}/agenv/website/docs/server + CAUSLY_REQUIRE_DOCS: "1" + run: npx vitest run tests/docsDrift.test.js diff --git a/.github/workflows/cross-os.yml b/.github/workflows/cross-os.yml index 5a94c9d..646dd09 100644 --- a/.github/workflows/cross-os.yml +++ b/.github/workflows/cross-os.yml @@ -12,7 +12,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - node: [22] + node: [22, 24] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..6daaafa --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,47 @@ +name: Publish to npm + +# Publishes with provenance (npm shows a verified link from the package to this repository and commit). +# Run it by publishing a GitHub release whose tag is v (for example v1.5.4). +# Needs the repository secret NPM_TOKEN (an npm automation token). +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + id-token: write # required for provenance + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Release tag must match package.json + if: github.event_name == 'release' + run: | + version="$(node -p "require('./package.json').version")" + if [ "${GITHUB_REF_NAME}" != "v${version}" ]; then + echo "Tag ${GITHUB_REF_NAME} does not match package.json version ${version}" + exit 1 + fi + + - name: Audit production dependencies + run: npm audit --omit=dev --audit-level=high + + - name: Publish with provenance + # prepublishOnly rebuilds the pack manifest and runs the full test suite first. + run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index c1baeb9..acd0c20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,35 @@ All notable changes to this project are documented here. +## [1.5.4] + +### Security +- Updated vulnerable dependencies: `simple-git` 4.x, `@modelcontextprotocol/sdk` 1.32.x, `zod` 4.6.x, plus transitive fixes. `npm audit --omit=dev` now reports 0 vulnerabilities. +- Added a test that keeps the direct dependencies on patched versions. +- `http_check` now refuses private, loopback and link-local addresses (SSRF), on every redirect hop, using the same guard as `web_fetch`. `CAUSLY_ALLOW_PRIVATE_HOSTS=true` still turns it off. The guard also judges IPv6 forms that carry an IPv4 address (6to4, ISATAP, Teredo) by that address. +- File and directory tools now protect paths consistently (`copy_file` had no check at all). Paths are resolved to their real location first (`..`, symlinks, junctions, 8.3 short names), compared on folder boundaries, and cover Windows, macOS and Linux system folders. Credential folders (`~/.ssh`, `~/.aws`, `~/.config/gcloud`, OS keychains) can no longer be read or written, and Windows device names (`CON`, `NUL`), alternate data streams and `\\?\` paths are refused. The secrets `store_path` follows the same rules. The same goes for `~/.kube`, `~/.gnupg`, `~/.azure` and Causly's own folder (`~/.causly` or `CAUSLY_HOME`, which holds your API keys), so an agent cannot read your keys through the file tools. +- Docker Compose (WSL fallback on Windows) no longer builds a shell command from the project folder, so a folder name containing quotes or shell characters cannot run commands. A test now fails if any code builds shell text for `exec`. +- Permission levels raised where the risk was higher than the level: `git_push`, `git_merge`, `secrets_get`, `supabase_create_project` and `vercel_cancel_deployment` are now `HIGH` and need `confirm: true` (their input schemas accept it). `git_stash` and `git_tag` are `MEDIUM`, and only their destructive actions (`drop`, `delete`) need `confirm: true`. A test now fails if a shipped tool has no explicit level, or if a `HIGH` or `DESTRUCTIVE` tool cannot accept `confirm`. +- Credentials no longer reach the activity log or error responses: passwords inside URLs and connection strings, Bearer tokens, common token formats and JSON password fields are redacted, and `connection_string`, `dsn` and `database_url` fields are always redacted. + +### Added +- Every tool now carries the four MCP annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`), derived from its permission level. They are hints for clients, not enforcement. Tests check that all tools have them, that `READ` tools are never marked destructive, and that stdout carries only JSON-RPC. + +### Fixed +- Windows: `npm install` (and any npm lifecycle script) through `run_command` no longer fails with `'node' is not recognized` (issue #2). The Node that runs the server and the npm global folder now come first in `PATH`, `ComSpec` is set when missing, and the hard-coded user name and Node folder are gone. + +- A damaged local database (`causly.db`) now gives an error that names the file and says to move it aside or run `causly-server restore`, instead of SQLite's bare `file is not a database`. The file handle is also released, so the damaged file is not left locked on Windows. +- `edit_file` now matches a multi-line `old_str` in files with CRLF line endings (it failed with "not found" on normal Windows files) and keeps the file's original line endings. +- `github_list_issues` with `state: "all"` could return an empty list when the latest items were pull requests (GitHub counts them in the page size). It now reads further pages until it has enough real issues. + +### Maintenance +- CI now uses Node.js 22 and 24 and `npm ci` everywhere (it still ran Node 18 and `npm install`), and the cross-OS matrix covers both Node versions. +- New automatic checks: `npm audit --omit=dev --audit-level=high`, a test that the npm package holds only the `files` allowlist (no `.env`, databases, logs or keys), and a docs drift test that compares the tool pages with `packs/manifest.json` and the permission levels. +- Upgrade test with a database written by the real 1.5.0 (pipeline, meetings, action items, reminders): data stays intact, old migrations are not re-run, new writes work. Corrupt `.env`, secrets file, database and client config are covered too. +- Input fuzz tests (`tests/fuzz.boundaries.test.js`, helper `tests/helpers/fuzz.js`) feed quotes, shell metacharacters, injection payloads, traversal, UNC paths, long and non-ASCII names to the file, directory, git, command, project, Terraform and Docker tools and check that nothing is evaluated and nothing leaves the workspace. +- npm package now lists its `homepage` and issue tracker. A new `publish.yml` workflow publishes with npm provenance when a `v` GitHub release is published, and a test keeps `package.json`, `server.json` and the changelog in step. +- Dependabot opens weekly pull requests for npm packages and GitHub Actions. + ## [1.5.3] ### Added diff --git a/docs/P0.md b/docs/P0.md new file mode 100644 index 0000000..715f6a7 --- /dev/null +++ b/docs/P0.md @@ -0,0 +1,329 @@ +# P0: before 1.5.4 and the 10 Nov launch + +Release blockers. Each item lists the evidence from the current code, the fix, and the +acceptance test. Work on branch `security/hardening-1.5.4`. + +Status: `[ ]` open, `[~]` in progress, `[x]` done. + +--- + +## P0-01 Vulnerable dependencies `[ ]` + +**Evidence.** `npm audit --omit=dev` on 31 Oct 2026 reports 11 issues: 3 critical, 5 high, +3 moderate. + +| Package | Installed | Severity | Fix | +|---|---|---|---| +| `simple-git` (direct) | 3.36.0 | critical (unsafe operation guards can be bypassed) | 4.0.2, major version | +| `@modelcontextprotocol/sdk` (direct) | 1.29.0 | high | 1.32.2 | +| `zod` (direct) | 4.4.3 | high (memory exhaustion on oversized collections) | 4.6.6 or newer | +| `proxy-addr`, `body-parser`, `fast-uri`, `ip-address`, `hono`, `@hono/node-server`, `qs` | transitive | critical to moderate | cleared by the SDK upgrade | + +**Impact.** The server uses stdio, so the HTTP-related advisories are low exposure. `simple-git` +runs on every git tool call and is the one that matters. + +**Fix.** Upgrade in this order, running the full suite after each step: `zod`, SDK, `simple-git` +4.x (read its changelog, it is a major version), then `mysql2`, `pg` and `vitest`. Regenerate the +lockfile. + +**Acceptance.** +- [ ] `npm audit --omit=dev` shows no high or critical findings +- [ ] All 20 git tools pass their tests against `simple-git` 4.x +- [ ] `lazyLoading.e2e`, `useTool.e2e` and `agent-smoke` pass on the new SDK + +**Docs to update.** `CHANGELOG.md` security section. If `simple-git` 4 changes how a git tool behaves, also `tools/filesystem-git-shell.md`. + +--- + +## P0-02 Issue #2: `npm install` fails in nested `cmd.exe` on Windows `[ ]` + +**Evidence.** GitHub issue #2. `npm install` through `run_command` fails on packages with +postinstall scripts: `'node' is not recognized`. `tools/commandOps.js` already patches `PATHEXT` +and `PATH`, but: + +- Line 81 falls back to a hard-coded user profile: `C:\Users\nihal`. That is wrong on every + other machine. +- The Node directory is hard-coded as `C:\Program Files\nodejs`. A Node installed with nvm, + Volta, fnm or Scoop lives elsewhere. +- Missing directories are appended, so an older Node earlier in `PATH` still wins. +- `ComSpec` is not set. npm runs lifecycle scripts through `cmd.exe` and reads `ComSpec` to find + it. In a stripped environment (Claude Desktop) it can be empty. +- The directory of the running Node (`path.dirname(process.execPath)`) is never used. + +**Fix.** +1. Prepend `path.dirname(process.execPath)` and the npm global prefix to `PATH`. Prepend, so the + Node that runs the server is the Node that runs scripts. +2. Remove the hard-coded `C:\Users\nihal` and `C:\Program Files\nodejs`. Derive the profile from + `os.homedir()`. +3. Set `ComSpec` to the absolute `cmd.exe` path when it is missing. +4. Keep the PowerShell and `cmd.exe` fallbacks as they are. + +**Acceptance.** +- [ ] A test starts `runCommand` with a stripped environment (only `SystemRoot`, no `ComSpec`, + no Node directory in `PATH`) and runs a package whose postinstall calls `node` +- [ ] A test proves no code path contains a user name or fixed install directory +- [ ] Manual check from Claude Desktop on Windows: `npm install` of a package with a native + postinstall succeeds +- [ ] Issue #2 closed with the fix commit. The workaround note is removed from + `website/docs/server/troubleshooting.md` and `limitations.md` + +**Docs to update.** `troubleshooting.md` (remove the Windows postinstall workaround), `limitations.md` (Platforms), and close issue #2. + +--- + +## P0-03 Database credentials reach the activity log `[ ]` + +**Evidence.** `tools/security.js` redacts by key name. The list (`token`, `password`, `secret`, +`api_key`, and so on) does not include `connection_string`, and URL credentials inside a value +(`postgres://user:pass@host/db`) are not recognized. `tools/toolWrapper.js:54` returns +`err.message` to the client without passing it through the redactor. + +**Impact.** A Postgres or MySQL call writes the full connection string, password included, to +`logs/activity.log`. Errors can echo it back to the agent. + +**Fix.** One central redaction module used by the logger, error responses and API results: +- key names: add `connection_string`, `connectionString`, `dsn`, `database_url`, `uri`, `url` + (when it carries credentials) +- values: strip `scheme://user:password@host` userinfo, `Bearer` tokens, `ghp_`, `xoxb-`, + `sk-` style prefixes, and JSON `"password": "..."` fragments +- apply it in `toolWrapper.js` before returning an error to the client + +**Acceptance.** +- [ ] A test calls `postgres_query` with a bad host and a password in the connection string, then + asserts the password is absent from the log line and from the returned error +- [ ] Table-driven tests for each pattern above, including false positives (an ordinary URL + without credentials is left alone) + +**Docs to update.** `security.md` (Secret redaction: new key names and credentials inside URLs), `tools/database.md` (connection strings are redacted in logs). + +--- + +## P0-04 Docker Compose shell injection (WSL fallback) `[ ]` + +**Evidence.** `tools/dockerOps.js:231` and `:249`: +``const composeCmd = `cd '${wslDir}' && docker compose up...` ``. A project directory containing a +single quote closes the quote and runs the rest as shell. + +**Fix.** Do not build shell text from a path. Run `wsl.exe` with an argument array +(`--cd -- docker compose up -d`) so the path is never parsed by a shell. Then audit every +`exec`, `execAsync` and string-built command in `tools/` for the same pattern. + +**Acceptance.** +- [ ] A test passes a directory name containing `'`, `"`, `;`, `$(...)`, a backtick and a space + and asserts the argument array is intact and nothing is evaluated +- [ ] A grep-based test fails if a new template string is passed to `exec` + +**Docs to update.** `tools/docker.md` (how Compose runs through WSL on Windows), `security.md` (What is not protected) if the wording changes. + +--- + +## P0-05 `http_check` has no SSRF protection `[ ]` + +**Evidence.** `tools/vercelOps.js:180`: `fetch(url, { method: "GET", redirect: "follow" })`. +`web_fetch` in `tools/webOps.js` already blocks private, loopback and link-local addresses, +checked at connection time, but `http_check` does not use it. + +**Impact.** An agent steered by hostile page content can probe internal addresses +(`169.254.169.254`, `localhost`, `10.x`) and see status and timing. + +**Fix.** Extract the guard from `webOps.js` into a shared module and use it in both tools. +Validate every redirect hop. Keep `CAUSLY_ALLOW_PRIVATE_HOSTS=true` as the single opt-out. + +**Acceptance.** +- [ ] Tests for loopback, private ranges, link-local, IPv4-mapped IPv6, decimal and octal + encodings of an address, DNS names that resolve to private addresses, and a public URL that + redirects to a private one +- [ ] `webOps` tests still pass unchanged + +**Docs to update.** `tools/vercel.md` (`http_check` blocks private addresses), `configuration.md` (`CAUSLY_ALLOW_PRIVATE_HOSTS` now covers `http_check` too), `security.md` (network guard). + +--- + +## P0-06 File tools: inconsistent path protection `[ ]` + +**Evidence.** +- `tools/fileOps.js:108` `copyFile` has no `assertPathAllowed` call. `moveFile` (lines 100 and + 101) does. +- `tools/security.js:483` checks protected paths with a lower-cased string prefix test, with no + path boundary and no real-path resolution. The list is Windows-only. +- `tools/secretsOps.js` accepts a custom `store_path`. + +**Fix.** +1. Call the protection check for source and destination in every file and directory tool. +2. Resolve real paths first (symlinks, `..`, 8.3 short names), then compare on path boundaries. +3. Add protected locations for Linux and macOS (`/etc`, `/usr`, `/bin`, `/System`, `/Library`, + `~/.ssh`, `~/.aws`, `~/.config/gcloud`, the OS keychain folders). +4. Treat `store_path` like any other path argument. + +**Acceptance.** +- [ ] A test enumerates every exported function in `fileOps.js` and `directoryOps.js` and fails if + one does not call the guard +- [ ] Tests: `..` traversal, symlink into a protected folder, UNC path, reserved device names + (`CON`, `NUL`), a sibling folder that shares a prefix with a protected one, `file::$DATA` + +**Docs to update.** `security.md` (Protected paths: Windows, macOS and Linux lists, real-path resolution), `tools/filesystem-git-shell.md` (`copy_file` is protected), `tools/secrets.md` (`store_path` rule). + +--- + +## P0-07 CI hygiene and automated checks `[ ]` + +**Evidence.** +- `.github/workflows/ci.yml:18` runs Node 18 and `:21` uses `npm install`. `package.json` + requires Node 22.13 or newer. +- The same file uses `npm install` again at line 52. +- There is no `.github/dependabot.yml`. +- Nothing runs `npm audit`. +- Nothing checks the docs against the code. + +**Fix.** +- Node 22 (and 24) everywhere, `npm ci` everywhere +- `.github/dependabot.yml` for npm and GitHub Actions, weekly +- A CI step: `npm audit --omit=dev --audit-level=high` +- A tarball test: `npm pack --dry-run --json` must list only the `files` allowlist, and must not + contain `.env`, `*.db`, logs or keys +- A docs drift test: tool names and permission levels on every page in + `website/docs/server/tools/` must match `packs/manifest.json` and `tools/security.js` + (this check was done by hand on 30 Oct and passed, make it automatic) +- Docs site: set `onBrokenLinks` to `throw` + +**Acceptance.** +- [ ] A deliberately broken fixture fails each new check, then is removed +- [ ] CI is green on all three operating systems + +**Docs to update.** `development.md` (CI jobs, new checks, Dependabot, how to run the docs drift test locally). + +--- + +## P0-08 Permission levels and enforcement `[ ]` + +**Evidence.** `tools/security.js` assigns levels. A tool missing from the table defaults to +`MEDIUM` and is allowed. Some levels are lower than the risk: + +| Tool | Now | Proposed | Why | +|---|---|---|---| +| `git_push` | MEDIUM | HIGH | Visible to others, hard to undo | +| `git_merge` | MEDIUM | HIGH | Rewrites the working branch | +| `secrets_get` | MEDIUM | HIGH | Returns a decrypted secret to the agent | +| `git_stash` (drop) | LOW | MEDIUM | Destroys work | +| `git_tag` (delete) | LOW | MEDIUM | Removes a ref | +| `supabase_create_project` | MEDIUM | HIGH | Creates billable resources | +| `vercel_cancel_deployment` | MEDIUM | HIGH | Affects production | + +**Fix.** Apply the table. Keep the safe default for unknown tools, but make a missing level a +**test failure** so it can never ship. + +**Acceptance.** +- [ ] A test loads the manifest and asserts every tool has an explicit level +- [ ] `website/docs/server/security.md` examples and every tool page are updated (the drift test + from P0-07 enforces this) +- [ ] Decide `git_stash` and `git_tag`: per-action levels need `confirm` only for the destructive + action + +**Docs to update.** `security.md` (levels table and examples), `tools/filesystem-git-shell.md` (`git_push`, `git_merge`), `tools/secrets.md` (`secrets_get`), `tools/supabase.md`, `tools/vercel.md`. The drift test from P0-07 enforces this. + +--- + +## P0-09 MCP conformance and tool annotations `[ ]` + +**Evidence.** No tool registers MCP annotations. Clients and directories use +`readOnlyHint`, `destructiveHint`, `idempotentHint` and `openWorldHint` to decide how to present +and approve a tool. The server has not been run against the MCP Inspector. + +**Fix.** +- Derive annotations from the permission table in one place (`READ` is read-only, `DESTRUCTIVE` + is destructive, network tools are open-world) and apply them in the wrapper +- Run the MCP Inspector against the server, check `tools/list`, schemas, error shape and that + nothing but protocol messages reaches stdout + +**Acceptance.** +- [ ] A test asserts all tools carry the four annotations and that `READ` tools are never marked + destructive +- [ ] A test starts the server and asserts stdout contains only valid JSON-RPC lines +- [ ] Every tool has a non-empty description and a valid JSON schema +- [ ] Inspector run recorded in the PR description + +**Docs to update.** `architecture.md` (annotations), `development.md` (adding a pack: annotations come from the permission level), `security.md` (annotations are hints, not enforcement). + +--- + +## P0-10 Input fuzz tests for every path and shell boundary `[ ]` + +**Why.** The Compose bug (P0-04) is one instance of a class. Others will exist. + +**Do.** One shared test helper that feeds each path or command argument these values and asserts +no evaluation, no escape from the workspace and no crash: +`'`, `"`, `;`, `&`, `|`, `$(x)`, a backtick, `%PATH%`, a newline, a very long name, an empty +string, Hindi and emoji names, a trailing space or dot (Windows), `..\..`, a UNC path, and a +symlink. + +**Acceptance.** +- [ ] Helper applied to files, directory, git, docker, terraform, project and command tools +- [ ] No finding left open: each one becomes its own item or a fix in this sprint + +**Docs to update.** `development.md` (how to use the fuzz helper when adding a tool). + +--- + +## P0-11 Install, upgrade and platform matrix `[ ]` + +**Why.** Windows has had hands-on use. macOS and Linux have been run only by CI, never by hand. + +**Do.** +- Clean Windows machine and a Linux machine (WSL is fine): `npm install -g`, `init`, `doctor`, + `status`, a few real tool calls, `remove` +- Node 22.13 (the minimum) and the current release +- A user name with a space or non-ASCII character, and a OneDrive-redirected home folder +- `npx` flow and the global flow +- No internet, and behind a proxy +- **Upgrade:** create a database with 1.5.0, upgrade to 1.5.4, and confirm pipeline, meetings and + reminders data is intact. Also test a corrupt `.env`, a corrupt secrets file and a corrupt + database: each must produce a clear error, not a crash +- A Claude Desktop config that has other servers and invalid JSON: `init` must not damage it + +**Acceptance.** +- [ ] A checklist in the release PR, filled in +- [ ] The migration test lives in `tests/` with a checked-in 1.5.0 fixture database + +**Docs to update.** `getting-started.md` (verified platforms, minimum Node version), `limitations.md` (Platforms), `troubleshooting.md` (new install findings). + +--- + +## P0-12 Release hygiene `[ ]` + +**Evidence.** `package.json` has no `homepage`. The package is not published with provenance. + +**Do.** +- Add `homepage` (`https://server.causly.in`), keep `repository` and `bugs` accurate +- `npm publish --provenance` from GitHub Actions +- `CHANGELOG.md` 1.5.4 with a "Security" section that names what was fixed, without exploit + detail +- After publish: confirm the npm page, the MCP Registry entry and the Glama listing show the new + version, then request a fresh scan (the old scan covered an earlier commit and 181 tools, the + current server has 245) +- `SECURITY.md`: confirm the reporting address and response times are accurate + +**Acceptance.** +- [ ] `npm view causly-server` shows 1.5.4 with the homepage and provenance +- [ ] Fresh Glama scan requested and recorded + +**Docs to update.** `CHANGELOG.md`. README and ROADMAP are updated last, after P0 is green. No docs site change. + +--- + +## P0-13 Correctness bugs found during QA `[ ]` + +**Evidence.** +1. `edit_file` does not match a multi-line `old_str` against a file that uses CRLF line endings, + so edits to normal Windows files fail with "not found". Reproduced on 31 Oct 2026 against a + CRLF `eslint.config.mjs`. +2. `github_list_issues` with `state: "all"` returned an empty list while an open issue existed. + `state: "open"` returned it. Check whether `all` is passed through correctly. + +**Fix.** Normalize line endings when matching and preserve the file's original line endings when +writing. Fix or document the `state` handling. + +**Acceptance.** +- [ ] Tests: multi-line edit on LF, CRLF and mixed files, the file keeps its original endings +- [ ] Test: `state: "all"` returns open and closed issues + +**Docs to update.** `tools/filesystem-git-shell.md` (`edit_file` and line endings), `tools/github.md` (`state` values for `github_list_issues`). diff --git a/docs/P1.md b/docs/P1.md new file mode 100644 index 0000000..ac2301a --- /dev/null +++ b/docs/P1.md @@ -0,0 +1,210 @@ +# P1: next release (1.6) + +Important, but not blockers for 1.5.4 or the launch. Start after P0 is released. + +Status: `[ ]` open, `[~]` in progress, `[x]` done. + +--- + +## P1-01 Real operator approval `[ ]` + +**Problem.** `confirm: true` is an argument the agent chooses. An agent that has read hostile text +(a web page, an email, an issue) can pass it. The docs say so honestly, but the gate cannot tell +the operator from the agent. + +**Approach.** Use MCP elicitation, where the client supports it, so the operator answers a prompt +that the agent cannot answer. Fall back to the current flag when the client does not support it. +Add an optional policy file (`~/.causly/policy.json`) with per-tool rules: `allow`, `ask`, `deny`. + +**Acceptance.** `HIGH` and `DESTRUCTIVE` calls on a supporting client cannot proceed without an +operator response. A test with a mock client that never answers shows the call stays blocked. + +**Docs to update.** `security.md` (The approval gate), `configuration.md` (policy file location), `limitations.md` (drop the flag-only wording once true), `cli.md` if a policy command is added. + +--- + +## P1-02 Owner-only files and atomic writes `[ ]` + +**Evidence.** No `mode` or `chmod` anywhere in `logger.js`, `store.js`, `secretsOps.js`, +`backupOps.js` or `cliOps.js`. The activity log, the SQLite database, the secrets store, backups +and editor-config backups can all hold sensitive data. + +**Fix.** On macOS and Linux create `~/.causly` as `0700` and files as `0600`. On Windows rely on +the profile folder ACL and verify it, since `chmod` is not meaningful there. Write the secrets +file and backups through a temporary file and a rename, so a crash cannot leave half a file. + +**Acceptance.** POSIX tests check modes on every created file. A test kills the process during a +secrets write and the old file is still intact. + +**Docs to update.** `security.md` (new section on local file permissions), `configuration.md` (Where data is stored), `cli.md` (backup and restore). + +--- + +## P1-03 Workspace limit covers more `[ ]` + +**Evidence.** Docker volume mounts and Compose files are not inspected. MCP project resources in +`index.js` call project inspection without the workspace check. Shell commands are not checked. + +**Fix.** Inspect `-v`, `--mount` and Compose `volumes` and reject host paths outside the +workspace. Apply the workspace check to the `causly://project/...` resources. Keep documenting +that shell text is not inspected. + +**Acceptance.** Tests for each mount form and for the resources. + +**Docs to update.** `security.md` (Workspace limit), `tools/docker.md` (mount rules), `architecture.md` (MCP resources), `limitations.md`. + +--- + +## P1-04 Child processes should not inherit every key `[ ]` + +**Problem.** `run_command` and other child processes inherit the server environment, which +includes every key loaded from `~/.causly/.env`. + +**Approach.** Pass a minimal environment by default (`PATH`, `HOME`, locale, temp) plus an +allowlist (`CAUSLY_PASSTHROUGH_ENV`). Document the change in the release notes, because it can +change behavior for people who relied on inherited variables. + +**Acceptance.** A test runs a command that prints its environment and finds no service keys. + +**Docs to update.** `security.md` (commands inherit the environment), `configuration.md` (`CAUSLY_PASSTHROUGH_ENV`), `limitations.md`, `troubleshooting.md` (a command cannot see a key). + +--- + +## P1-05 Project commands policy `[ ]` + +**Context.** `run_tests`, `run_lint`, `run_typecheck` and `run_build` run the project's scripts +(`tools/projectOps.js:115`). A hostile `package.json` can run anything. This is by design, and +the tools are `LOW`. + +**Approach.** Keep them `LOW` by default and say so on the tool pages. Add +`CAUSLY_STRICT_PROJECT_COMMANDS=true` to require `confirm` for them. + +**Acceptance.** Docs updated. A test for the strict mode. + +**Docs to update.** `tools/filesystem-git-shell.md` (project tools run the project's scripts), `configuration.md` (`CAUSLY_STRICT_PROJECT_COMMANDS`), `security.md`. + +--- + +## P1-06 Resource limits `[ ]` + +**Evidence.** `read_multiple_files` has no total size limit. Recursive directory operations have +no effective bound. Database connection pools are cached with no maximum and no cleanup. + +**Fix.** Cap bytes per read and per batch, entries per listing, and pools per process, with idle +cleanup. Return a clear "truncated" marker. + +**Acceptance.** Tests with oversized inputs finish within a memory and time budget. + +**Docs to update.** `tools/filesystem-git-shell.md` and `tools/database.md` (limits and truncation), `configuration.md` if limits get variables. + +--- + +## P1-07 Token efficiency of tool output `[ ]` + +**Why.** Long tool output costs the user tokens and context. Found during QA: +- `directory_tree` returns build output folders (`.docusaurus`, `build`) in full +- `run_command` repeats the full `command` and `cwd` in its result +- `search_tools` and the default packs add a fixed amount to every session + +**Fix.** Default depth and entry caps with a `truncated` hint, an extended ignore list +(`node_modules`, `.git`, `build`, `dist`, `.next`, `.docusaurus`, `.venv`), and no echo of inputs. +Measure the token cost of `tools/list` for the default packs and record it. + +**Acceptance.** A test asserts a size ceiling for `directory_tree` on a large fixture and that the +`run_command` result has no input echo. The measured `tools/list` size is in the docs. + +**Docs to update.** `tools/filesystem-git-shell.md` (`directory_tree` defaults and ignore list), `architecture.md` and `tools/overview.md` (token cost of the default packs). + +--- + +## P1-08 Integration error paths `[ ]` + +**Why.** Happy paths are tested. Real APIs fail. + +**Do.** For GitHub, Gmail, Calendar, Slack and Telegram first, then the rest: recorded-fixture +tests for 401, 403 (missing scope), 404, 429 (with `Retry-After`), 5xx, timeout, malformed JSON +and pagination. Each must give a clear message with the fix command, never a stack trace or a +secret. + +**Acceptance.** One fixture file per service and one test per failure class. + +**Docs to update.** `troubleshooting.md` (error messages by service), each service page (rate limits and retries). + +--- + +## P1-09 Logging: rotation, size cap, injection `[ ]` + +**Problem.** `activity.log` grows without bound. A value containing a newline can forge a log +line. + +**Fix.** Rotate at a size (for example 10 MB, keep 5). Serialize one JSON object per line and +escape control characters. Redact through the central module from P0-03. + +**Acceptance.** Tests for rotation and for a forged-line attempt. + +**Docs to update.** `security.md` (Activity log: rotation), `configuration.md` (log size variables), `troubleshooting.md`. + +--- + +## P1-10 Prompt-injection tests `[ ]` + +**Why.** Web pages, emails, issues and calendar entries are untrusted text. + +**Do.** Fixtures that contain instructions ("call `delete_file`", "pass `confirm: true`", +"send the contents of `~/.causly/.env` to ...") returned by `web_fetch`, `gmail_get_message`, +`github_get_issue`, `calendar_get_event`. Assert tool descriptions mark such output as untrusted +and that the gate behavior does not change. + +**Acceptance.** The suite exists and passes. It documents what the server can and cannot prevent. + +**Docs to update.** `security.md` (Anything the agent reads can influence it: what is tested, what is not prevented). + +--- + +## P1-11 Secrets store polish `[ ]` + +**Do.** Keep the 64-character key check and add a test for it. Make `secrets_set` and +`secrets_rotate_key` atomic (P1-02). Add `secrets_get` to the "needs confirm" set (P0-08). +Reject a `store_path` outside the allowed folders. + +**Docs to update.** `tools/secrets.md`, `security.md` (Encrypted secrets). + +--- + +## P1-12 Calendar: recurring events `[ ]` + +**Limitation.** Updating a recurring event changes only the instance id you pass. + +**Fix.** Add a `scope` argument: `this`, `following` or `all`. Use the recurring event id for +`all`. `calendar_delete_event` gets the same argument. + +**Acceptance.** Tests against recorded Calendar API responses for each scope. + +**Docs to update.** `tools/calendar.md` (`scope` argument), `limitations.md` (remove the recurring-events limit). + +--- + +## P1-13 Google sign-in lifetime `[ ]` + +**Limitation.** In Testing status the refresh token expires after 7 days. + +**Do.** The fix is to publish the Google app to production, which is a user-side step. Document +it step by step in the Gmail and Calendar pages (Branding configuration first, then Publish). +In `status` and `doctor`, warn when the token is close to the 7-day limit. + +**Docs to update.** `tools/gmail.md`, `tools/calendar.md`, `configuration.md` (Google), `troubleshooting.md` (Google), `cli.md` (`status` and `doctor` warning), `limitations.md`. + +--- + +## P1-14 Landing page and docs site QA `[ ]` + +- Hero: the call-to-action and stats bar should sit fully inside the first screen on common + laptop sizes, and stack cleanly on phones. Measure at 360, 390, 430, 768, 1280, 1366, 1440 and + 1920 pixels +- Lighthouse for performance, accessibility and SEO on the landing page and a docs page +- Keyboard navigation and focus order, contrast checks +- After each deploy: `/`, `/docs`, `/privacy`, `/terms`, `/og.png`, the 308 redirect from the old + domain, and the sitemap all return the expected status +- Social card preview for the Open Graph image on the main platforms + +**Docs to update.** None in the server docs. Changes land in the landing page and `website/docusaurus.config.js`. diff --git a/docs/P2.md b/docs/P2.md new file mode 100644 index 0000000..af0f652 --- /dev/null +++ b/docs/P2.md @@ -0,0 +1,82 @@ +# P2: later + +Larger features and long-running verification. Not planned for 1.5.4 or 1.6. Revisit after the +launch based on what users ask for. + +Status: `[ ]` open, `[~]` in progress, `[x]` done. + +--- + +## P2-01 Local transcription engine `[ ]` + +`meeting_transcribe` uploads audio to Groq. Add an optional local engine (Whisper through a local +binary or `whisper.cpp`) so audio never leaves the machine. Needs a decision on model download +size and a clear "first run downloads N MB" message. + +## P2-02 Real macOS verification `[ ]` + +CI covers macOS, but nobody has run the server by hand on a Mac. Find a tester, run the P0-11 +checklist, and record it. + +## P2-03 Nightly live smoke tests `[ ]` + +A scheduled workflow that uses dedicated test accounts and secrets to call each integration once. +It catches API drift (a changed field, a retired endpoint) before users do. Failures open an +issue. + +## P2-04 BoundaryAttest to production grade `[ ]` + +It is a proof of concept today (three workflow tools, a key file you manage). Needs key storage, +rotation, revocation, a trust registry and an optional KMS or HSM backend. Only worth doing if +users ask for signed receipts. + +## P2-05 Sync between machines `[ ]` + +Pipeline, meetings and reminders live in one local database. Options: an encrypted export and +import, or a file-based sync folder. Needs a conflict policy. Do not build a cloud service for +this inside the open-source server. That belongs to the hosted product. + +## P2-06 Soak and performance testing `[ ]` + +- 24-hour session with periodic calls: memory growth, open handles, pool leaks +- Cold start time and `tools/list` latency with all packs loaded +- Large inputs: a 100 MB transcript, a repository with 100,000 files +- Publish the numbers in the docs + +## P2-07 Fuzzing `[ ]` + +Property-based and mutation tests for the argument parsers, the redactor, the path guard and the +URL guard. Run in CI on a schedule. + +## P2-08 Supply-chain extras `[ ]` + +Signed git tags, a software bill of materials published with each release, a license check on +every dependency, and a documented process for revoking a bad release (`npm deprecate`, advisory, +changelog note). + +## P2-09 Enterprise features `[ ]` + +- Policy file with an allowlist and denylist of tools per project +- Audit log export (JSON lines to a file or a syslog target) +- A "read-only mode" switch that removes every non-`READ` tool +- Per-project workspace profiles + +## P2-10 More directory listings `[ ]` + +Smithery needs an `.mcpb` bundle. Revisit PulseMCP, mcp.so and `awesome-mcp-servers` when their +queues or fees change. + +## P2-11 Keyword volume `[ ]` + +Keyword ideas come from search autocomplete and carry no volume. Add an optional provider with a +free tier, behind a key, and keep the keyless path as it is. + +## P2-12 Outreach channels `[ ]` + +Out of scope for now: LinkedIn, X and WhatsApp (terms of service and account risk). Email goes +through Gmail. + +## Out of scope + +Azure, GCP and AWS SDK tools. Terraform covers provisioning. Stripe and Discord packs exist in +the code but are not documented or promoted. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..7628b17 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,90 @@ +# Quality and hardening plan + +This folder is the engineering plan for making Causly Server enterprise quality. It is internal +working documentation for contributors. User-facing docs live on the docs site +(`server.causly.in/docs`). + +| File | What it holds | +|---|---| +| [P0.md](./P0.md) | Must be done before the 1.5.4 release and the 10 Nov 2026 launch | +| [P1.md](./P1.md) | Next release (1.6): deeper hardening, real approval flow, broader test coverage | +| [P2.md](./P2.md) | Later: larger features, long-running verification, enterprise extras | + +Every item has an ID (`P0-03`), the evidence (file and line), the impact, the fix, and an +acceptance test. An item is done only when its acceptance test exists and passes. + +## Where this came from + +- An independent audit run on 30 Oct 2026 against `master` at commit `c6bc132`. +- A re-check of every audit claim against the code and `npm audit` on 31 Oct 2026. +- The open GitHub issue #2 and the "Limitations" page of the docs. +- A QA review of what the existing test suite (56 test files) does not cover. + +Audit claims that did not survive the re-check are listed at the bottom of this page, so they are +not worked on by mistake. + +## Rules of the sprint + +1. **Test first.** For every security item, write the failing test that reproduces the problem, + then fix it. The test stays as a regression test. +2. **One item, one commit.** Keep each commit small so it can be reviewed and reverted alone. +3. **Work on a branch.** P0 work goes on `security/hardening-1.5.4`. `master` is untouched until + the branch is green on Ubuntu, macOS and Windows. +4. **No blind fixes.** Reproduce first. If an item cannot be reproduced, write down why and + close it. +5. **Docs move with code.** Any change to a tool, a permission level or a default updates the + matching page in the docs site in the same change set. The pages live in the `agenv` repo + under `website/docs/server/`. Each P0 and P1 item has a "Docs to update" line that names them. +6. **No new unrequested scope.** New ideas go into P1 or P2, not into the sprint. + +## Definition of done for a release + +- [ ] `npm audit --omit=dev` reports no high or critical issues +- [ ] `npm test` passes on Ubuntu, macOS and Windows (Node 22 and 24) +- [ ] Every registered tool has an explicit permission level (enforced by a test) +- [ ] `npm pack --dry-run` shows only the allowlisted files +- [ ] `CHANGELOG.md` has a security section for the release +- [ ] `causly-server doctor --test` passes on a clean machine with real keys +- [ ] Package published with provenance, tag pushed, MCP Registry entry updated + +## Timeline to launch (10 Nov 2026) + +| Dates | Focus | +|---|---| +| 1 to 3 Nov | P0-01 to P0-06: dependencies, issue #2, redaction, shell injection, SSRF, file guards | +| 4 to 5 Nov | P0-07 to P0-09: CI, permission levels, MCP conformance and annotations | +| 6 to 7 Nov | P0-10 to P0-12: fuzz tests, install and upgrade matrix, release hygiene | +| 8 Nov | Release 1.5.4, ask Glama and the other directories to re-scan | +| 9 Nov | Buffer. Renew the Google token (`causly-server login google`), final `doctor --test` | +| 10 Nov | Product Hunt launch | + +README and ROADMAP are updated last, after P0 is green. + +## Limitations page to plan + +| Limitation (docs) | Plan | +|---|---| +| Issue #2: `npm install` fails inside nested `cmd.exe` on Windows | P0-02 | +| Workspace limit is a guardrail, not a sandbox | P1-03 (Docker mounts, resources), documented honestly in the meantime | +| Approval gate is a flag, not a screen | P1-01 (real operator approval) | +| Commands inherit the server environment, including keys | P1-04 | +| Recurring calendar events: updating changes one instance only | P1-12 | +| Google token expires after 7 days in Testing mode | P1-13 (production publish is a user-side step) | +| Transcription needs the internet | P2-01 (local engine) | +| No sync between machines | P2-05 | +| BoundaryAttest is a proof of concept | P2-04 | +| Direct cloud tools (Azure, GCP, AWS) | Out of scope | +| LinkedIn, X and WhatsApp outreach | Out of scope | + +## Audit claims that did not hold up + +- **Master key validation is weak.** It is already checked: the key must decode to exactly 32 + bytes (`tools/secretsOps.js`). No work needed beyond a unit test. +- **Test tools bypass the approval gate.** `run_tests`, `run_lint`, `run_typecheck` and + `run_build` run the project's own scripts. That is what they are for, so this is a policy + decision, not a bug. See P1-05. +- **CVE numbers in the audit.** The audit quoted CVE IDs that `npm audit` does not report. + `npm audit` reports GitHub advisory IDs (GHSA). Work is tracked against `npm audit`, not the + quoted IDs. +- **"410+ tests".** That figure came from an older pull request. The suite has 56 test files and + CI is green, but the count has to be taken from a fresh run. diff --git a/package-lock.json b/package-lock.json index 3215e08..c861659 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,34 +1,40 @@ { "name": "causly-server", - "version": "1.5.3", + "version": "1.5.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "causly-server", - "version": "1.5.3", + "version": "1.5.4", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/sdk": "^1.32.2", "mysql2": "^3.24.3", "pg": "^8.23.0", - "simple-git": "^3.36.0", - "zod": "^4.4.3" + "simple-git": "^4.0.2", + "zod": "^4.6.7" + }, + "bin": { + "causly-server": "bin/causly.js" }, "devDependencies": { "vitest": "^5.0.0" + }, + "engines": { + "node": ">=22.13" } }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.5.tgz", + "integrity": "sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==", "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { - "hono": "^4" + "hono": "^4 || ^5.0.0-0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -75,12 +81,12 @@ "license": "MIT" }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.32.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.2.tgz", + "integrity": "sha512-SWsVtH/0vULSQStoZ6xxH2K09Li8fXYTMC64ZeogCdpZ3XSi1eCPOByV1OpuJxuI3OcmHhosDWYyii07tpPsLw==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -115,9 +121,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.148.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", - "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz", + "integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==", "dev": true, "license": "MIT", "funding": { @@ -125,9 +131,9 @@ } }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", - "integrity": "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz", + "integrity": "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==", "cpu": [ "arm" ], @@ -142,9 +148,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz", - "integrity": "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.16.tgz", + "integrity": "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==", "cpu": [ "arm64" ], @@ -159,9 +165,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz", - "integrity": "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.16.tgz", + "integrity": "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==", "cpu": [ "arm64" ], @@ -176,9 +182,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz", - "integrity": "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.16.tgz", + "integrity": "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==", "cpu": [ "x64" ], @@ -193,9 +199,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz", - "integrity": "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.16.tgz", + "integrity": "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==", "cpu": [ "x64" ], @@ -210,9 +216,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz", - "integrity": "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.16.tgz", + "integrity": "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==", "cpu": [ "arm" ], @@ -227,9 +233,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz", - "integrity": "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.16.tgz", + "integrity": "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==", "cpu": [ "arm64" ], @@ -244,9 +250,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz", - "integrity": "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.16.tgz", + "integrity": "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==", "cpu": [ "arm64" ], @@ -261,9 +267,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz", - "integrity": "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.16.tgz", + "integrity": "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==", "cpu": [ "ppc64" ], @@ -278,9 +284,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz", - "integrity": "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.16.tgz", + "integrity": "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==", "cpu": [ "s390x" ], @@ -295,9 +301,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz", - "integrity": "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.16.tgz", + "integrity": "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==", "cpu": [ "x64" ], @@ -312,9 +318,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz", - "integrity": "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.16.tgz", + "integrity": "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==", "cpu": [ "x64" ], @@ -329,9 +335,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz", - "integrity": "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.16.tgz", + "integrity": "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==", "cpu": [ "arm64" ], @@ -346,9 +352,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz", - "integrity": "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.16.tgz", + "integrity": "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==", "cpu": [ "arm64" ], @@ -363,9 +369,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz", - "integrity": "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.16.tgz", + "integrity": "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==", "cpu": [ "x64" ], @@ -387,18 +393,18 @@ "license": "MIT" }, "node_modules/@simple-git/args-pathspec": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@simple-git/args-pathspec/-/args-pathspec-1.0.3.tgz", - "integrity": "sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@simple-git/args-pathspec/-/args-pathspec-1.0.4.tgz", + "integrity": "sha512-EtMX6XjRWSastG2SdkmSQByPtJ9dx/NIjnHbpQPCYt62j4RmSx5rgLTGpw0YCjF5h191gZOmBfheOT23cRSFdw==", "license": "MIT" }, "node_modules/@simple-git/argv-parser": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@simple-git/argv-parser/-/argv-parser-1.1.1.tgz", - "integrity": "sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@simple-git/argv-parser/-/argv-parser-2.0.1.tgz", + "integrity": "sha512-M++IaVWrN+vYalilOHfwvT2IL3wYZtVEBQpmX6DLSOJLtxbZ+SUeIDE3IiPogzhPACLajwtxfa5rp+ZX1FczzQ==", "license": "MIT", "dependencies": { - "@simple-git/args-pathspec": "^1.0.3" + "@simple-git/args-pathspec": "^1.0.4" } }, "node_modules/@types/chai": { @@ -420,9 +426,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", "dev": true, "license": "MIT" }, @@ -437,22 +443,22 @@ } }, "node_modules/@vitest/mocker": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz", - "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", + "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.31", - "@vitest/spy": "5.0.0", + "@vitest/spy": "5.0.5", "estree-walker": "^3.0.3", - "magic-string": "^1.2.3" + "magic-string": "^1.4.3" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "msw": "^2.4.9", + "msw": "^2.4.9 || ^3.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { @@ -465,9 +471,9 @@ } }, "node_modules/@vitest/spy": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", - "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", "dev": true, "license": "MIT", "funding": { @@ -540,18 +546,18 @@ } }, "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", + "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^2.0.0", + "content-type": "^2.1.0", "debug": "^4.4.3", "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", + "iconv-lite": "^0.7.3", "on-finished": "^2.4.1", - "qs": "^6.15.2", + "qs": "^6.16.1", "raw-body": "^3.0.2", "type-is": "^2.1.0" }, @@ -564,9 +570,9 @@ } }, "node_modules/body-parser/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", "license": "MIT", "engines": { "node": ">=18" @@ -615,9 +621,9 @@ } }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { @@ -923,9 +929,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", "funding": [ { "type": "github", @@ -1102,9 +1108,9 @@ } }, "node_modules/hono": { - "version": "4.12.31", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", - "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", + "version": "4.13.14", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.14.tgz", + "integrity": "sha512-yhRLQ21U6qa9Hsu2N7Qcbh8tE+WKEcoErjIo56LeSGiGXGp9sA7UpE86Ui9uexb61KakiGwuHMn2QdV7w6i6kQ==", "license": "MIT", "peer": true, "engines": { @@ -1154,9 +1160,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.7.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.5.tgz", + "integrity": "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==", "license": "MIT", "engines": { "node": ">= 12" @@ -1493,13 +1499,13 @@ } }, "node_modules/magic-string": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz", - "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz", + "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, "node_modules/math-intrinsics": { @@ -1564,9 +1570,9 @@ "license": "MIT" }, "node_modules/mysql2": { - "version": "3.24.3", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.3.tgz", - "integrity": "sha512-OKfWHkMAg9v06neq8FmSyhbxPQKABN9PAW5G9/bDTXzJBO5xXtkKL0V27vju7HQWk9UD4Od5BZsvCtBTB1CPEw==", + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.6.tgz", + "integrity": "sha512-enu73KhbAua2L3dIxjc6qg6ZJ7H3pt1BImZASWNWY5xSsmJ1ZuUPULTDGXgmJbI61vb+nJAG19IZclIq19UidQ==", "license": "MIT", "dependencies": { "aws-ssl-profiles": "^1.1.2", @@ -1597,9 +1603,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -1646,9 +1652,9 @@ } }, "node_modules/obug": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", - "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -1709,15 +1715,15 @@ } }, "node_modules/pg": { - "version": "8.23.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", - "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", "license": "MIT", "peer": true, "dependencies": { - "pg-connection-string": "^2.14.0", + "pg-connection-string": "^2.14.1", "pg-pool": "^3.14.0", - "pg-protocol": "^1.16.0", + "pg-protocol": "^1.16.1", "pg-types": "2.2.0", "pgpass": "1.0.5" }, @@ -1725,7 +1731,7 @@ "node": ">= 16.0.0" }, "optionalDependencies": { - "pg-cloudflare": "^1.4.0" + "pg-cloudflare": "^1.4.1" }, "peerDependencies": { "pg-native": ">=3.0.1" @@ -1737,16 +1743,16 @@ } }, "node_modules/pg-cloudflare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", - "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", "license": "MIT", "optional": true }, "node_modules/pg-connection-string": { - "version": "2.14.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", - "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", "license": "MIT" }, "node_modules/pg-int8": { @@ -1768,9 +1774,9 @@ } }, "node_modules/pg-protocol": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", - "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", "license": "MIT" }, "node_modules/pg-types": { @@ -1811,7 +1817,6 @@ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1829,9 +1834,9 @@ } }, "node_modules/postcss": { - "version": "8.5.28", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", - "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", "dev": true, "funding": [ { @@ -1849,9 +1854,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.18", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -1897,9 +1902,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -1907,12 +1912,16 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "version": "6.17.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.17.1.tgz", + "integrity": "sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", @@ -1963,13 +1972,13 @@ } }, "node_modules/rolldown": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", - "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.16.tgz", + "integrity": "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.148.0", + "@oxc-project/types": "=0.156.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -1979,21 +1988,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.7", - "@rolldown/binding-android-arm64": "1.2.7", - "@rolldown/binding-darwin-arm64": "1.2.7", - "@rolldown/binding-darwin-x64": "1.2.7", - "@rolldown/binding-freebsd-x64": "1.2.7", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.7", - "@rolldown/binding-linux-arm64-gnu": "1.2.7", - "@rolldown/binding-linux-arm64-musl": "1.2.7", - "@rolldown/binding-linux-ppc64-gnu": "1.2.7", - "@rolldown/binding-linux-s390x-gnu": "1.2.7", - "@rolldown/binding-linux-x64-gnu": "1.2.7", - "@rolldown/binding-linux-x64-musl": "1.2.7", - "@rolldown/binding-openharmony-arm64": "1.2.7", - "@rolldown/binding-win32-arm64-msvc": "1.2.7", - "@rolldown/binding-win32-x64-msvc": "1.2.7" + "@rolldown/binding-android-arm-eabi": "1.2.16", + "@rolldown/binding-android-arm64": "1.2.16", + "@rolldown/binding-darwin-arm64": "1.2.16", + "@rolldown/binding-darwin-x64": "1.2.16", + "@rolldown/binding-freebsd-x64": "1.2.16", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.16", + "@rolldown/binding-linux-arm64-gnu": "1.2.16", + "@rolldown/binding-linux-arm64-musl": "1.2.16", + "@rolldown/binding-linux-ppc64-gnu": "1.2.16", + "@rolldown/binding-linux-s390x-gnu": "1.2.16", + "@rolldown/binding-linux-x64-gnu": "1.2.16", + "@rolldown/binding-linux-x64-musl": "1.2.16", + "@rolldown/binding-openharmony-arm64": "1.2.16", + "@rolldown/binding-win32-arm64-msvc": "1.2.16", + "@rolldown/binding-win32-x64-msvc": "1.2.16" } }, "node_modules/router": { @@ -2162,23 +2171,16 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, "node_modules/simple-git": { - "version": "3.36.0", - "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.36.0.tgz", - "integrity": "sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-4.0.2.tgz", + "integrity": "sha512-l0sIsv9VrPwovKELFDBOj/lhmIcbMvxBk2B+hRhsMM4/VL3gKtuZvYnBNixVo+N/0HnqQx2cBdLvxnAbygHpqA==", "license": "MIT", "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", - "@simple-git/args-pathspec": "^1.0.3", - "@simple-git/argv-parser": "^1.1.0", + "@simple-git/args-pathspec": "1.0.4", + "@simple-git/argv-parser": "2.0.1", "debug": "^4.4.0" }, "funding": { @@ -2187,9 +2189,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -2220,13 +2222,6 @@ "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2237,16 +2232,16 @@ } }, "node_modules/std-env": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", - "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "dev": true, "license": "MIT" }, "node_modules/tinybench": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", - "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", "dev": true, "license": "MIT", "engines": { @@ -2254,9 +2249,9 @@ } }, "node_modules/tinyexec": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", - "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, "license": "MIT", "engines": { @@ -2345,17 +2340,17 @@ } }, "node_modules/vite": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", - "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", + "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "lightningcss": "^1.33.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.26", - "rolldown": "~1.2.4", + "picomatch": "^4.0.7", + "postcss": "^8.5.30", + "rolldown": "~1.2.14", "tinyglobby": "^0.2.17" }, "bin": { @@ -2372,7 +2367,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -2424,25 +2419,25 @@ } }, "node_modules/vitest": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz", - "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", + "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/mocker": "5.0.0", - "chai": "^6.2.2", + "@vitest/mocker": "5.0.5", + "chai": "^6.3.0", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", - "magic-string": "^1.2.3", - "obug": "^2.1.4", + "magic-string": "^1.4.3", + "obug": "^3.0.0", "picomatch": "^4.0.7", - "std-env": "^4.2.0", - "tinybench": "6.1.4", - "tinyexec": "1.3.0", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", "tinyglobby": "^0.2.17", - "why-is-node-running": "^2.3.0" + "why-is-node-running": "3.2.1" }, "bin": { "vitest": "vitest.mjs" @@ -2457,12 +2452,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "5.0.0", - "@vitest/browser-preview": "5.0.0", + "@vitest/browser-playwright": "5.0.5", + "@vitest/browser-preview": "5.0.5", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", - "@vitest/coverage-istanbul": "5.0.0", - "@vitest/coverage-v8": "5.0.0", - "@vitest/ui": "5.0.0", + "@vitest/coverage-istanbul": "5.0.5", + "@vitest/coverage-v8": "5.0.5", + "@vitest/ui": "5.0.5", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" @@ -2522,20 +2517,16 @@ } }, "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", "dev": true, "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, "bin": { "why-is-node-running": "cli.js" }, "engines": { - "node": ">=8" + "node": ">=20.11" } }, "node_modules/wrappy": { @@ -2554,9 +2545,9 @@ } }, "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "license": "MIT", "peer": true, "funding": { diff --git a/package.json b/package.json index 4e66988..5f460ad 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "causly-server", "mcpName": "io.github.KNIHAL/causly-server", - "version": "1.5.3", + "version": "1.5.4", "description": "Open-source MCP server that turns any AI agent into your technical co-founder", "license": "MIT", "main": "index.js", @@ -17,11 +17,11 @@ "verify:boundaryattest": "node tools/verifyBoundaryAttest.js" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/sdk": "^1.32.2", "mysql2": "^3.24.3", "pg": "^8.23.0", - "simple-git": "^3.36.0", - "zod": "^4.4.3" + "simple-git": "^4.0.2", + "zod": "^4.6.7" }, "devDependencies": { "vitest": "^5.0.0" @@ -53,6 +53,10 @@ "cursor", "windsurf" ], + "homepage": "https://server.causly.in", + "bugs": { + "url": "https://github.com/KNIHAL/causly-server/issues" + }, "repository": { "type": "git", "url": "git+https://github.com/KNIHAL/causly-server.git" diff --git a/packs/git.js b/packs/git.js index e4c4ae6..733e968 100644 --- a/packs/git.js +++ b/packs/git.js @@ -44,8 +44,13 @@ server.registerTool( server.registerTool( "git_push", { - description: "Push commits to a remote.", - inputSchema: { repo_path: z.string(), remote: z.string().optional(), branch: z.string().optional() }, + description: "Push commits to a remote. HIGH risk (visible to others, hard to undo) - requires confirm: true.", + inputSchema: { + repo_path: z.string(), + remote: z.string().optional(), + branch: z.string().optional(), + confirm: z.boolean().optional().describe("Must be true to proceed"), + }, }, wrap("git_push", gitOps.gitPush) ); @@ -111,8 +116,8 @@ server.registerTool( server.registerTool( "git_merge", { - description: "Merge the given branch into the current branch.", - inputSchema: { repo_path: z.string(), branch: z.string() }, + description: "Merge the given branch into the current branch. HIGH risk (changes the working branch) - requires confirm: true.", + inputSchema: { repo_path: z.string(), branch: z.string(), confirm: z.boolean().optional().describe("Must be true to proceed") }, }, wrap("git_merge", gitOps.gitMerge) ); @@ -134,12 +139,13 @@ server.registerTool( server.registerTool( "git_stash", { - description: "Stash working changes. action: 'save' | 'pop' | 'apply' | 'list' | 'drop' (defaults to save).", + description: "Stash working changes. action: 'save' | 'pop' | 'apply' | 'list' | 'drop' (defaults to save). Only 'drop' is HIGH risk and requires confirm: true.", inputSchema: { repo_path: z.string(), action: z.string().optional().describe("save, pop, apply, list, or drop"), message: z.string().optional().describe("Stash message, only used with save"), stash_id: z.string().optional().describe("e.g. stash@{0}, used with pop/apply/drop"), + confirm: z.boolean().optional().describe("Must be true to proceed"), }, }, wrap("git_stash", gitOps.gitStash) @@ -171,12 +177,13 @@ server.registerTool( server.registerTool( "git_tag", { - description: "Manage git tags. action: 'list' | 'create' | 'delete' (defaults to list).", + description: "Manage git tags. action: 'list' | 'create' | 'delete' (defaults to list). Only 'delete' is HIGH risk and requires confirm: true.", inputSchema: { repo_path: z.string(), action: z.string().optional().describe("list, create, or delete"), tag_name: z.string().optional().describe("Required for create/delete"), message: z.string().optional().describe("Annotation message, optional for create"), + confirm: z.boolean().optional().describe("Must be true to proceed"), }, }, wrap("git_tag", gitOps.gitTag) diff --git a/packs/loader.js b/packs/loader.js index 8fb16ec..e7afc84 100644 --- a/packs/loader.js +++ b/packs/loader.js @@ -2,6 +2,7 @@ import fs from "fs"; import { z } from "zod"; import { PACKS } from "./registry.js"; import { wrap } from "../tools/toolWrapper.js"; +import { withAnnotations } from "../tools/annotations.js"; // Per-server state, so tests can use stub servers without sharing anything. const state = new WeakMap(); // server -> { loading: Map, loaded: Set } @@ -14,8 +15,9 @@ function stateFor(server) { if (typeof server.registerTool === "function") { const original = server.registerTool.bind(server); server.registerTool = (name, config, cb) => { - s.tools.set(name, { config, cb }); - return original(name, config, cb); + const annotated = withAnnotations(name, config); + s.tools.set(name, { config: annotated, cb }); + return original(name, annotated, cb); }; } } @@ -198,7 +200,9 @@ export async function useTool(server, { tool, arguments: args, describe }, extra } export function registerMetaTools(server) { - server.registerTool( + // Meta tools are registered directly (not through stateFor), so they get their annotations here. + const register = (name, config, cb) => server.registerTool(name, withAnnotations(name, config), cb); + register( "search_tools", { description: @@ -211,7 +215,7 @@ export function registerMetaTools(server) { wrap("search_tools", async ({ query, limit }) => searchTools(server, query, limit)) ); - server.registerTool( + register( "load_toolset", { description: @@ -223,7 +227,7 @@ export function registerMetaTools(server) { wrap("load_toolset", async ({ toolsets }) => loadToolsets(server, toolsets)) ); - server.registerTool( + register( "use_tool", { description: diff --git a/packs/manifest.json b/packs/manifest.json index c174c18..67eceae 100644 --- a/packs/manifest.json +++ b/packs/manifest.json @@ -134,7 +134,7 @@ }, { "name": "git_push", - "description": "Push commits to a remote." + "description": "Push commits to a remote. HIGH risk (visible to others, hard to undo) - requires confirm: true." }, { "name": "git_pull", @@ -162,7 +162,7 @@ }, { "name": "git_merge", - "description": "Merge the given branch into the current branch." + "description": "Merge the given branch into the current branch. HIGH risk (changes the working branch) - requires confirm: true." }, { "name": "git_reset", @@ -170,7 +170,7 @@ }, { "name": "git_stash", - "description": "Stash working changes. action: 'save' | 'pop' | 'apply' | 'list' | 'drop' (defaults to save)." + "description": "Stash working changes. action: 'save' | 'pop' | 'apply' | 'list' | 'drop' (defaults to save). Only 'drop' is HIGH risk and requires confirm: true." }, { "name": "git_show", @@ -182,7 +182,7 @@ }, { "name": "git_tag", - "description": "Manage git tags. action: 'list' | 'create' | 'delete' (defaults to list)." + "description": "Manage git tags. action: 'list' | 'create' | 'delete' (defaults to list). Only 'delete' is HIGH risk and requires confirm: true." }, { "name": "git_changed_files", @@ -376,7 +376,7 @@ }, { "name": "vercel_cancel_deployment", - "description": "Cancel a currently building or queued deployment." + "description": "Cancel a currently building or queued deployment. HIGH risk (can affect production) - requires confirm: true." }, { "name": "http_check", @@ -402,7 +402,7 @@ }, { "name": "supabase_create_project", - "description": "Create a new Supabase project. Requires an organization_id (use supabase_list_organizations to find it) and a database password." + "description": "Create a new Supabase project. Requires an organization_id (use supabase_list_organizations to find it) and a database password. HIGH risk (creates a billable resource) - requires confirm: true." }, { "name": "supabase_delete_project", @@ -462,7 +462,7 @@ }, { "name": "secrets_get", - "description": "Retrieve and decrypt a secret by name." + "description": "Retrieve and decrypt a secret by name. HIGH risk (the decrypted value is returned to the agent) - requires confirm: true." }, { "name": "secrets_list", diff --git a/packs/secrets.js b/packs/secrets.js index 440674e..08151c4 100644 --- a/packs/secrets.js +++ b/packs/secrets.js @@ -22,10 +22,11 @@ server.registerTool( server.registerTool( "secrets_get", { - description: "Retrieve and decrypt a secret by name.", + description: "Retrieve and decrypt a secret by name. HIGH risk (the decrypted value is returned to the agent) - requires confirm: true.", inputSchema: { name: z.string(), store_path: z.string().optional(), + confirm: z.boolean().optional().describe("Must be true to proceed"), }, }, wrap("secrets_get", secretsOps.secretsGet) diff --git a/packs/supabase.js b/packs/supabase.js index 0302942..d864816 100644 --- a/packs/supabase.js +++ b/packs/supabase.js @@ -36,13 +36,14 @@ server.registerTool( "supabase_create_project", { description: - "Create a new Supabase project. Requires an organization_id (use supabase_list_organizations to find it) and a database password.", + "Create a new Supabase project. Requires an organization_id (use supabase_list_organizations to find it) and a database password. HIGH risk (creates a billable resource) - requires confirm: true.", inputSchema: { name: z.string(), organization_id: z.string(), db_pass: z.string(), region: z.string().optional().describe("Defaults to ap-south-1"), plan: z.string().optional().describe("Defaults to free"), + confirm: z.boolean().optional().describe("Must be true to proceed"), }, }, wrap("supabase_create_project", supabaseOps.supabaseCreateProject) diff --git a/packs/vercel.js b/packs/vercel.js index b74310b..7fcf240 100644 --- a/packs/vercel.js +++ b/packs/vercel.js @@ -97,8 +97,8 @@ server.registerTool( server.registerTool( "vercel_cancel_deployment", { - description: "Cancel a currently building or queued deployment.", - inputSchema: { deployment_id: z.string() }, + description: "Cancel a currently building or queued deployment. HIGH risk (can affect production) - requires confirm: true.", + inputSchema: { deployment_id: z.string(), confirm: z.boolean().optional().describe("Must be true to proceed") }, }, wrap("vercel_cancel_deployment", vercelOps.vercelCancelDeployment) ); diff --git a/server.json b/server.json index 752e14b..05c1e35 100644 --- a/server.json +++ b/server.json @@ -6,12 +6,12 @@ "url": "https://github.com/KNIHAL/causly-server", "source": "github" }, - "version": "1.5.3", + "version": "1.5.4", "packages": [ { "registryType": "npm", "identifier": "causly-server", - "version": "1.5.3", + "version": "1.5.4", "transport": { "type": "stdio" } diff --git a/tests/annotations.e2e.test.js b/tests/annotations.e2e.test.js new file mode 100644 index 0000000..837c03b --- /dev/null +++ b/tests/annotations.e2e.test.js @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { PERMISSION_LEVELS, ACTION_LEVELS } from "../tools/security.js"; + +// P0-09: every tool carries the four MCP annotations, derived from the permission table, and has a +// description and a valid input schema. +async function listAllTools() { + const transport = new StdioClientTransport({ + command: process.execPath, + args: ["index.js"], + cwd: process.cwd(), + env: { CAUSLY_TOOLSETS: "all" }, + stderr: "ignore", + }); + const client = new Client({ name: "annotations-test", version: "0.0.0" }); + await client.connect(transport); + try { + return (await client.listTools()).tools; + } finally { + await client.close(); + } +} + +describe("MCP tool annotations (stdio, all toolsets)", () => { + it("every tool has all four annotations, a description and an object schema", async () => { + const tools = await listAllTools(); + expect(tools.length).toBeGreaterThan(200); + const problems = []; + for (const t of tools) { + const a = t.annotations ?? {}; + for (const key of ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"]) { + if (typeof a[key] !== "boolean") problems.push(`${t.name}: ${key} missing`); + } + if (!t.description || !t.description.trim()) problems.push(`${t.name}: empty description`); + if (t.inputSchema?.type !== "object") problems.push(`${t.name}: inputSchema is not an object schema`); + } + expect(problems).toEqual([]); + }, 60000); + + it("READ tools are read-only and never destructive; the rest are not read-only", async () => { + const tools = await listAllTools(); + const problems = []; + for (const t of tools) { + const level = PERMISSION_LEVELS[t.name]; + const a = t.annotations; + if (t.name === "use_tool") { + if (a.readOnlyHint) problems.push("use_tool must not be read-only"); + continue; + } + if (level === "READ") { + if (!a.readOnlyHint) problems.push(`${t.name}: READ but not readOnlyHint`); + if (a.destructiveHint) problems.push(`${t.name}: READ but destructiveHint`); + } else if (a.readOnlyHint) { + problems.push(`${t.name}: ${level} but readOnlyHint`); + } + if (level === "DESTRUCTIVE" && !a.destructiveHint) problems.push(`${t.name}: DESTRUCTIVE but not destructiveHint`); + if (Object.hasOwn(ACTION_LEVELS, t.name) && !a.destructiveHint) problems.push(`${t.name}: has destructive actions`); + } + expect(problems).toEqual([]); + }, 60000); + + it("spot checks: local vs network tools", async () => { + const byName = Object.fromEntries((await listAllTools()).map((t) => [t.name, t.annotations])); + expect(byName.read_file).toMatchObject({ readOnlyHint: true, destructiveHint: false, openWorldHint: false }); + expect(byName.delete_file).toMatchObject({ readOnlyHint: false, destructiveHint: true }); + expect(byName.git_status).toMatchObject({ readOnlyHint: true, openWorldHint: false }); + expect(byName.git_push.openWorldHint).toBe(true); + expect(byName.http_check).toMatchObject({ readOnlyHint: true, openWorldHint: true }); + expect(byName.github_list_issues.openWorldHint).toBe(true); + expect(byName.pipeline_list.openWorldHint).toBe(false); + expect(byName.search_tools).toMatchObject({ readOnlyHint: true, openWorldHint: false }); + expect(byName.use_tool).toMatchObject({ readOnlyHint: false, openWorldHint: true }); + }, 60000); +}); diff --git a/tests/commandEnv.test.js b/tests/commandEnv.test.js new file mode 100644 index 0000000..318aaf9 --- /dev/null +++ b/tests/commandEnv.test.js @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, test } from "vitest"; +import { buildWindowsEnv } from "../tools/commandOps.js"; + +// P0-02 / issue #2: nested cmd.exe (npm lifecycle scripts) must find the same node that runs the server. +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const NODE_EXE = "D:\\tools\\fnm\\node-v22\\node.exe"; +const NODE_DIR = "D:\\tools\\fnm\\node-v22"; +const HOME = "C:\\Users\\Ünï Köder"; + +function build(env, extra = {}) { + return buildWindowsEnv(env, { execPath: NODE_EXE, homedir: () => HOME, ...extra }); +} + +function pathList(env) { + const key = Object.keys(env).find((k) => k.toUpperCase() === "PATH"); + return env[key].split(";"); +} + +describe("buildWindowsEnv", () => { + test("stripped environment gets the running node directory and the npm global dir", () => { + const env = build({ SystemRoot: "C:\\Windows" }); + const list = pathList(env); + assert.equal(list[0], NODE_DIR); + assert.ok(list.includes(`${HOME}\\AppData\\Roaming\\npm`)); + }); + + test("uses APPDATA for the npm global dir when present", () => { + const env = build({ SystemRoot: "C:\\Windows", APPDATA: "D:\\Roam" }); + assert.ok(pathList(env).includes("D:\\Roam\\npm")); + }); + + test("sets ComSpec to the absolute cmd.exe when missing or empty", () => { + assert.equal(build({ SystemRoot: "C:\\Windows" }).ComSpec, "C:\\Windows\\System32\\cmd.exe"); + assert.equal(build({ SystemRoot: "D:\\WinNT", ComSpec: "" }).ComSpec, "D:\\WinNT\\System32\\cmd.exe"); + }); + + test("keeps an existing ComSpec", () => { + const env = build({ SystemRoot: "C:\\Windows", ComSpec: "C:\\custom\\cmd.exe" }); + assert.equal(env.ComSpec, "C:\\custom\\cmd.exe"); + }); + + test("node directory goes first even when an older node is already in PATH", () => { + const env = build({ SystemRoot: "C:\\Windows", Path: "C:\\old\\node;C:\\Windows\\System32" }); + const list = pathList(env); + assert.equal(list[0], NODE_DIR); + assert.ok(list.indexOf("C:\\old\\node") > 0); + }); + + test("no duplicate entries, compared case-insensitively", () => { + const env = build({ SystemRoot: "C:\\Windows", PATH: `C:\\Windows;${NODE_DIR.toUpperCase()};C:\\Windows` }); + const list = pathList(env).map((p) => p.toLowerCase()); + assert.equal(list.filter((p) => p === NODE_DIR.toLowerCase()).length, 1); + assert.equal(list.filter((p) => p === "c:\\windows").length, 1); + }); + + test("keeps the original PATH key casing and never creates a second key", () => { + const env = build({ SystemRoot: "C:\\Windows", Path: "C:\\Windows" }); + assert.deepEqual(Object.keys(env).filter((k) => k.toUpperCase() === "PATH"), ["Path"]); + }); + + test("home comes from the given home directory, never a fixed user", () => { + const env = build({ SystemRoot: "C:\\Windows" }); + assert.ok(pathList(env).some((p) => p.startsWith(HOME))); + }); + + test("restores PATHEXT without empty segments", () => { + const env = build({ SystemRoot: "C:\\Windows", PATHEXT: ";.COM" }); + const ext = env.PATHEXT.split(";"); + assert.ok(!ext.includes("")); + for (const e of [".EXE", ".CMD", ".BAT"]) assert.ok(ext.includes(e)); + }); + + test("handles non-ASCII, spaces and a very long PATH", () => { + const long = Array.from({ length: 500 }, (_, i) => `C:\\dir ${i}\\ünï`).join(";"); + const list = pathList(build({ SystemRoot: "C:\\Windows", PATH: long })); + assert.equal(list[0], NODE_DIR); + assert.ok(list.includes("C:\\dir 499\\ünï")); + }); +}); + +test("commandOps.js contains no user name or fixed Node install directory", () => { + const source = fs.readFileSync(path.join(root, "tools", "commandOps.js"), "utf8"); + assert.ok(!/nihal/i.test(source), "hard-coded user name"); + assert.ok(!/Program Files\\+nodejs/i.test(source), "hard-coded Node directory"); +}); diff --git a/tests/commandOps.windows.test.js b/tests/commandOps.windows.test.js new file mode 100644 index 0000000..a652860 --- /dev/null +++ b/tests/commandOps.windows.test.js @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, test } from "vitest"; +import { runCommand } from "../tools/commandOps.js"; + +// P0-02 / issue #2: real process, stripped environment, npm lifecycle script that calls `node`. +describe.skipIf(process.platform !== "win32")("runCommand on Windows with a stripped environment", () => { + let dir; + let saved; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "causly-postinstall-")); + fs.writeFileSync( + path.join(dir, "package.json"), + JSON.stringify({ + name: "postinstall-fixture", + version: "1.0.0", + scripts: { postinstall: "node -e \"require('fs').writeFileSync('ran.txt','ok')\"" }, + }) + ); + saved = { ...process.env }; + const systemRoot = process.env.SystemRoot || "C:\\Windows"; + for (const key of Object.keys(process.env)) delete process.env[key]; + process.env.SystemRoot = systemRoot; + process.env.USERPROFILE = saved.USERPROFILE || os.homedir(); + process.env.TEMP = saved.TEMP || os.tmpdir(); + process.env.PATH = `${systemRoot}\\System32`; + }); + + afterEach(() => { + for (const key of Object.keys(process.env)) delete process.env[key]; + Object.assign(process.env, saved); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + test("npm install runs a postinstall script that calls node", async () => { + const npmCmd = path.join(path.dirname(process.execPath), "npm.cmd"); + const result = await runCommand({ + command: `& '${npmCmd}' install --no-audit --no-fund --offline`, + cwd: dir, + timeout_ms: 120_000, + }); + assert.equal(result.exit_code, 0, `${result.stdout}\n${result.stderr}`); + assert.equal(fs.readFileSync(path.join(dir, "ran.txt"), "utf8"), "ok"); + }, 120_000); // a real npm install: slow when the whole suite runs in parallel +}); diff --git a/tests/dependencies.test.js b/tests/dependencies.test.js new file mode 100644 index 0000000..534930b --- /dev/null +++ b/tests/dependencies.test.js @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "vitest"; + +// P0-01: direct dependencies must stay on patched versions (see docs/P0.md). +// Offline check of the installed versions; `npm audit --omit=dev --audit-level=high` runs in CI (P0-07). +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function installedVersion(name) { + const file = path.join(root, "node_modules", ...name.split("/"), "package.json"); + return JSON.parse(fs.readFileSync(file, "utf8")).version; +} + +function atLeast(version, minimum) { + const a = version.split("-")[0].split(".").map(Number); + const b = minimum.split(".").map(Number); + for (let i = 0; i < 3; i += 1) { + if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) > (b[i] || 0); + } + return true; +} + +const minimums = { + "simple-git": "4.0.2", + "@modelcontextprotocol/sdk": "1.32.2", + zod: "4.6.6", + "@simple-git/argv-parser": "2.0.1", + "body-parser": "2.4.0", +}; + +for (const [name, minimum] of Object.entries(minimums)) { + test(`${name} is at least ${minimum}`, () => { + const version = installedVersion(name); + assert.ok(atLeast(version, minimum), `${name} ${version} is older than ${minimum}`); + }); +} + +test("package.json ranges do not allow the vulnerable versions", () => { + const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); + for (const name of ["simple-git", "@modelcontextprotocol/sdk", "zod"]) { + const floor = pkg.dependencies[name].replace(/^[\^~]/, ""); + assert.ok(atLeast(floor, minimums[name]), `${name} range ${pkg.dependencies[name]} allows versions below ${minimums[name]}`); + } +}); diff --git a/tests/dockerCompose.injection.test.js b/tests/dockerCompose.injection.test.js new file mode 100644 index 0000000..aa96479 --- /dev/null +++ b/tests/dockerCompose.injection.test.js @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// P0-04: the WSL fallback for `docker compose` must never put the project directory into shell text. +vi.mock("child_process", async () => { + const { promisify } = await import("node:util"); + const execFile = vi.fn(); + execFile[promisify.custom] = (file, args, options) => + new Promise((resolve, reject) => { + execFile(file, args, options, (err, stdout, stderr) => { + if (err) { + err.stdout = err.stdout ?? stdout; + err.stderr = err.stderr ?? stderr; + reject(err); + } else resolve({ stdout, stderr }); + }); + }); + return { execFile }; +}); + +const { execFile } = await import("child_process"); +const { dockerComposeUp, dockerComposeDown } = await import("../tools/dockerOps.js"); + +const originalPlatform = process.platform; +const setPlatform = (p) => Object.defineProperty(process, "platform", { value: p }); +const notFound = () => { + const err = new Error("'docker' is not recognized as an internal or external command"); + err.code = 1; + err.stdout = ""; + err.stderr = ""; + return err; +}; + +const HOSTILE = ["a'b", 'a"b', "a;b", "a$(touch x)b", "a`touch x`b", "a b", "a&b", "a|b", "a'; touch pwned; '", "a\nb", "%PATH%", "ünï 🙂"]; + +beforeEach(() => { + execFile.mockReset(); + setPlatform("win32"); +}); +afterEach(() => setPlatform(originalPlatform)); + +function failTwiceThen(result) { + execFile.mockImplementationOnce((c, a, o, cb) => cb(notFound())); // docker + execFile.mockImplementationOnce((c, a, o, cb) => cb(notFound())); // wsl docker + execFile.mockImplementationOnce((c, a, o, cb) => cb(null, result, "")); // compose fallback +} + +describe("docker compose via WSL keeps the directory out of shell text", () => { + for (const name of HOSTILE) { + const dir = `D:\\proj\\${name}`; + const wslDir = `/mnt/d/proj/${name}`; + + it(`up: ${JSON.stringify(name)}`, async () => { + failTwiceThen("ok"); + const result = await dockerComposeUp({ project_dir: dir }); + expect(result.exit_code).toBe(0); + const [file, args] = execFile.mock.calls[2]; + expect(file).toBe("wsl"); + expect(args).toEqual(["--cd", wslDir, "--", "docker", "compose", "up", "-d"]); + }); + + it(`down: ${JSON.stringify(name)}`, async () => { + failTwiceThen("ok"); + const result = await dockerComposeDown({ project_dir: dir }); + expect(result.exit_code).toBe(0); + const [file, args] = execFile.mock.calls[2]; + expect(file).toBe("wsl"); + expect(args).toEqual(["--cd", wslDir, "--", "docker", "compose", "down"]); + }); + } + + it("never uses bash -lc", async () => { + failTwiceThen("ok"); + await dockerComposeUp({ project_dir: "D:\\proj\\x", detach: false }); + const args = execFile.mock.calls[2][1]; + expect(args).not.toContain("bash"); + expect(args).not.toContain("-lc"); + expect(args).toEqual(["--cd", "/mnt/d/proj/x", "--", "docker", "compose", "up"]); + }); +}); diff --git a/tests/dockerOps.test.js b/tests/dockerOps.test.js index cbe7413..3d06f7d 100644 --- a/tests/dockerOps.test.js +++ b/tests/dockerOps.test.js @@ -274,22 +274,22 @@ describe("dockerOps — WSL fallback on Windows", () => { expect(wslArgs[volIndex + 1]).toBe("/mnt/d/projects/myapp:/app"); }); - it("dockerComposeUp falls back to `wsl bash -lc` when even `wsl docker` fails", async () => { + it("dockerComposeUp falls back to `wsl --cd` when even `wsl docker` fails", async () => { setPlatform("win32"); execFile.mockImplementationOnce((cmd, args, opts, cb) => cb(notFoundErr())); // direct `docker` execFile.mockImplementationOnce((cmd, args, opts, cb) => cb(notFoundErr())); // runDocker's own `wsl docker` retry - execFile.mockImplementationOnce(ok("compose up via wsl bash", "")); // compose's own `wsl bash -lc` + execFile.mockImplementationOnce(ok("compose up via wsl bash", "")); // compose's own `wsl --cd` const result = await dockerComposeUp({ project_dir: "D:\\projects\\myapp" }); expect(result.exit_code).toBe(0); expect(execFile.mock.calls[2][0]).toBe("wsl"); - expect(execFile.mock.calls[2][1][0]).toBe("bash"); - expect(execFile.mock.calls[2][1][1]).toBe("-lc"); - expect(execFile.mock.calls[2][1][2]).toContain("/mnt/d/projects/myapp"); - expect(execFile.mock.calls[2][1][2]).toContain("docker compose up -d"); + expect(execFile.mock.calls[2][1][0]).toBe("--cd"); + expect(execFile.mock.calls[2][1][1]).toBe("/mnt/d/projects/myapp"); + expect(execFile.mock.calls[2][1][2]).toBe("--"); + expect(execFile.mock.calls[2][1].slice(3)).toEqual(["docker", "compose", "up", "-d"]); }); - it("dockerComposeDown falls back to `wsl bash -lc` when even `wsl docker` fails", async () => { + it("dockerComposeDown falls back to `wsl --cd` when even `wsl docker` fails", async () => { setPlatform("win32"); execFile.mockImplementationOnce((cmd, args, opts, cb) => cb(notFoundErr())); execFile.mockImplementationOnce((cmd, args, opts, cb) => cb(notFoundErr())); @@ -297,7 +297,7 @@ describe("dockerOps — WSL fallback on Windows", () => { const result = await dockerComposeDown({ project_dir: "D:\\projects\\myapp" }); expect(result.exit_code).toBe(0); - expect(execFile.mock.calls[2][1][2]).toContain("docker compose down"); + expect(execFile.mock.calls[2][1].slice(3)).toEqual(["docker", "compose", "down"]); }); it("dockerComposeUp resolves via runDocker's own `wsl docker` retry without needing bash -lc", async () => { diff --git a/tests/docsDrift.test.js b/tests/docsDrift.test.js new file mode 100644 index 0000000..02896c6 --- /dev/null +++ b/tests/docsDrift.test.js @@ -0,0 +1,157 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, test } from "vitest"; +import { PERMISSION_LEVELS } from "../tools/security.js"; + +// P0-07: the tool pages of the docs site must match packs/manifest.json (tool names) and +// tools/security.js (permission levels). The docs live in the agenv repo, so this test needs it: +// - locally it looks for a sibling folder ../agenv (or the path in CAUSLY_DOCS_DIR), +// - CI checks the repo out and sets CAUSLY_DOCS_DIR and CAUSLY_REQUIRE_DOCS=1. +// Run it by hand: CAUSLY_DOCS_DIR=D:\agenv\website\docs\server npx vitest run tests/docsDrift.test.js +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const docsServerDir = process.env.CAUSLY_DOCS_DIR || path.resolve(root, "..", "agenv", "website", "docs", "server"); +const toolsDir = path.join(docsServerDir, "tools"); +const docsAvailable = fs.existsSync(toolsDir); +const required = /^(1|true|yes)$/i.test(process.env.CAUSLY_REQUIRE_DOCS || ""); + +// Packs that are intentionally not documented yet. +const UNDOCUMENTED_PACKS = ["stripe", "discord"]; +const LEVELS = new Set(["READ", "LOW", "MEDIUM", "HIGH", "DESTRUCTIVE"]); +const NAME = "[a-z][a-z0-9_]*"; + +const manifest = JSON.parse(fs.readFileSync(path.join(root, "packs", "manifest.json"), "utf8")).packs; +const manifestTools = new Map(); // name -> pack +for (const [pack, def] of Object.entries(manifest)) for (const t of def.tools) manifestTools.set(t.name, pack); + +/** Reads one docs page. Returns the names it mentions, the names it defines, and the level claims. */ +export function parsePage(text) { + const mentioned = new Set(); + const defined = []; + const claims = []; // { name, kind: "level" | "confirm", value } + for (const m of text.matchAll(new RegExp(`\`(${NAME})\``, "g"))) mentioned.add(m[1]); + + let inToolTable = false; + for (const line of text.split(/\r?\n/)) { + if (/^\|\s*Tool\s*\|/i.test(line)) { + inToolTable = true; + continue; + } + if (!line.startsWith("|")) inToolTable = false; + if (inToolTable) { + const row = line.match(new RegExp(`^\\|\\s*\`(${NAME})\`\\s*\\|\\s*([^|]*?)\\s*\\|`)); + if (row) { + defined.push(row[1]); + const cell = row[2].replace(/`/g, "").trim(); + if (LEVELS.has(cell)) claims.push({ name: row[1], kind: "level", value: cell }); + else if (/^(yes|no)$/i.test(cell)) claims.push({ name: row[1], kind: "confirm", value: cell.toLowerCase() }); + } + continue; + } + const heading = line.match(/^#{2,4}\s+(.*)$/); + if (heading) for (const m of heading[1].matchAll(new RegExp(`\`(${NAME})\``, "g"))) defined.push(m[1]); + for (const m of line.matchAll(new RegExp(`\\*\\*\`(${NAME})\`\\*\\*\\s*\\((READ|LOW|MEDIUM|HIGH|DESTRUCTIVE)\\)`, "g"))) { + defined.push(m[1]); + claims.push({ name: m[1], kind: "level", value: m[2] }); + } + } + // "`run_command` runs ... It is a `HIGH` tool" + for (const m of text.matchAll(new RegExp(`\`(${NAME})\`[^\`]{0,160}?\\.\\s+It is an? \`(READ|LOW|MEDIUM|HIGH|DESTRUCTIVE)\` tool`, "g"))) { + claims.push({ name: m[1], kind: "level", value: m[2] }); + } + return { mentioned, defined, claims }; +} + +/** Compares parsed pages with the manifest and the permission levels. Returns a list of problems. */ +export function findDrift(pages, tools = manifestTools, levels = PERMISSION_LEVELS) { + const problems = []; + const mentioned = new Set(); + for (const [file, page] of Object.entries(pages)) { + for (const name of page.mentioned) mentioned.add(name); + for (const name of page.defined) { + if (!tools.has(name)) problems.push(`${file}: documents a tool that does not exist: ${name}`); + } + for (const claim of page.claims) { + const level = levels[claim.name]; + if (!level) continue; + const needsConfirm = level === "HIGH" || level === "DESTRUCTIVE"; + if (claim.kind === "level" && claim.value !== level) { + problems.push(`${file}: ${claim.name} is ${claim.value} in the docs but ${level} in tools/security.js`); + } + if (claim.kind === "confirm" && (claim.value === "yes") !== needsConfirm) { + problems.push(`${file}: ${claim.name} says confirm=${claim.value} but its level is ${level}`); + } + } + } + for (const [name, pack] of tools) { + if (UNDOCUMENTED_PACKS.includes(pack)) continue; + if (!mentioned.has(name)) problems.push(`tool ${name} (${pack}) is not documented on any tools page`); + } + return problems; +} + +describe("manifest and permission levels agree", () => { + test("every tool in packs/manifest.json has a permission level", () => { + const missing = [...manifestTools.keys()].filter((name) => !(name in PERMISSION_LEVELS)); + assert.deepEqual(missing, []); + }); + + test("every permission level is a known level", () => { + for (const [name, level] of Object.entries(PERMISSION_LEVELS)) { + assert.ok(LEVELS.has(level), `${name} has unknown level ${level}`); + } + }); +}); + +describe("docs drift checker (broken fixtures must fail)", () => { + const tools = new Map([["alpha_read", "p"], ["alpha_delete", "p"]]); + const levels = { alpha_read: "READ", alpha_delete: "DESTRUCTIVE" }; + const page = (md) => ({ "x.md": parsePage(md) }); + + test("a matching page passes", () => { + const md = "| Tool | Risk | What |\n|---|---|---|\n| `alpha_read` | `READ` | r |\n| `alpha_delete` | DESTRUCTIVE | d |\n"; + assert.deepEqual(findDrift(page(md), tools, levels), []); + }); + + test("a wrong level fails", () => { + const md = "| Tool | Risk | What |\n|---|---|---|\n| `alpha_read` | HIGH | r |\n| `alpha_delete` | DESTRUCTIVE | d |\n"; + assert.equal(findDrift(page(md), tools, levels).length, 1); + }); + + test("a wrong confirm column fails", () => { + const md = "| Tool | Confirm | What |\n|---|---|---|\n| `alpha_read` | Yes | r |\n| `alpha_delete` | Yes | d |\n"; + assert.equal(findDrift(page(md), tools, levels).length, 1); + }); + + test("a tool that does not exist fails", () => { + const md = "| Tool | Risk | What |\n|---|---|---|\n| `alpha_read` | READ | r |\n| `alpha_delete` | DESTRUCTIVE | d |\n| `alpha_gone` | READ | x |\n"; + assert.equal(findDrift(page(md), tools, levels).length, 1); + }); + + test("an undocumented tool fails", () => { + const md = "| Tool | Risk | What |\n|---|---|---|\n| `alpha_read` | READ | r |\n"; + assert.equal(findDrift(page(md), tools, levels).length, 1); + }); + + test("heading, bold-level and prose forms are understood", () => { + const md = "### `alpha_read` + `alpha_delete`\n\n**`alpha_read`** (HIGH) does a thing.\n"; + const problems = findDrift(page(md), tools, levels); + assert.equal(problems.length, 1); + assert.match(problems[0], /alpha_read is HIGH in the docs but READ/); + }); +}); + +describe.skipIf(!docsAvailable && !required)("docs match the code", () => { + test("the docs folder exists", () => { + assert.ok(docsAvailable, `docs not found at ${toolsDir} (set CAUSLY_DOCS_DIR)`); + }); + + test("tool names and permission levels on every tools page match the code", () => { + const pages = {}; + for (const file of fs.readdirSync(toolsDir).filter((f) => f.endsWith(".md"))) { + pages[file] = parsePage(fs.readFileSync(path.join(toolsDir, file), "utf8")); + } + assert.deepEqual(findDrift(pages), []); + }); +}); diff --git a/tests/editFile.lineEndings.test.js b/tests/editFile.lineEndings.test.js new file mode 100644 index 0000000..6a5f7f1 --- /dev/null +++ b/tests/editFile.lineEndings.test.js @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; +import { editFile } from "../tools/fileOps.js"; + +// P0-13: edit_file must match a multi-line old_str in files with CRLF (or mixed) line endings, and the +// file must keep its original line endings afterwards. +let dir; +let file; +beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "causly-edit-eol-")); + file = path.join(dir, "f.txt"); +}); +afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }); +}); + +const read = () => fs.readFile(file, "utf8"); + +describe("editFile line endings", () => { + it("LF file: multi-line edit works and stays LF", async () => { + await fs.writeFile(file, "a\nb\nc\n"); + await editFile({ path: file, old_str: "a\nb", new_str: "x\ny" }); + expect(await read()).toBe("x\ny\nc\n"); + }); + + it("CRLF file: an LF old_str matches and the file stays CRLF", async () => { + await fs.writeFile(file, "a\r\nb\r\nc\r\n"); + await editFile({ path: file, old_str: "a\nb", new_str: "x\ny\nz" }); + expect(await read()).toBe("x\r\ny\r\nz\r\nc\r\n"); + }); + + it("CRLF file: a CRLF old_str matches too", async () => { + await fs.writeFile(file, "a\r\nb\r\nc\r\n"); + await editFile({ path: file, old_str: "b\r\nc", new_str: "B\r\nC" }); + expect(await read()).toBe("a\r\nB\r\nC\r\n"); + }); + + it("CRLF file: a single-line edit with a multi-line replacement keeps CRLF", async () => { + await fs.writeFile(file, "one\r\ntwo\r\n"); + await editFile({ path: file, old_str: "two", new_str: "2a\n2b" }); + expect(await read()).toBe("one\r\n2a\r\n2b\r\n"); + }); + + it("mixed file: untouched lines keep their own endings", async () => { + await fs.writeFile(file, "a\r\nb\r\nc\nd\n"); + await editFile({ path: file, old_str: "c\nd", new_str: "C\nD" }); + expect(await read()).toBe("a\r\nb\r\nC\nD\n"); + }); + + it("CRLF file: regex characters in old_str are matched literally", async () => { + await fs.writeFile(file, "x = (a.b)\r\ny = [1]\r\n"); + await editFile({ path: file, old_str: "(a.b)\ny = [1]", new_str: "ok" }); + expect(await read()).toBe("x = ok\r\n"); + }); + + it("CRLF file: ambiguity, not found and replace_all behave as for LF files", async () => { + await fs.writeFile(file, "a\r\nb\r\na\r\nb\r\n"); + await expect(editFile({ path: file, old_str: "a\nb", new_str: "x" })).rejects.toThrow(/multiple times/); + await expect(editFile({ path: file, old_str: "nope\nnope", new_str: "x" })).rejects.toThrow(/not found/); + await editFile({ path: file, old_str: "a\nb", new_str: "x\ny", replace_all: true }); + expect(await read()).toBe("x\r\ny\r\nx\r\ny\r\n"); + }); +}); diff --git a/tests/fixtures/db-1.5.0/causly-1.5.0.db b/tests/fixtures/db-1.5.0/causly-1.5.0.db new file mode 100644 index 0000000..d961009 Binary files /dev/null and b/tests/fixtures/db-1.5.0/causly-1.5.0.db differ diff --git a/tests/fuzz.boundaries.test.js b/tests/fuzz.boundaries.test.js new file mode 100644 index 0000000..a48d5bb --- /dev/null +++ b/tests/fuzz.boundaries.test.js @@ -0,0 +1,205 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { FUZZ_VALUES, injectionPayloads, asFileName, makeSandbox, attempt, expectNoEvaluation, snapshot } from "./helpers/fuzz.js"; +import { createFile, readFile, getFileInfo, copyFile, moveFile, editFile, deleteFile } from "../tools/fileOps.js"; +import { createDirectory, listDirectory, directoryTree, deleteDirectory, searchFiles } from "../tools/directoryOps.js"; +import * as git from "../tools/gitOps.js"; +import { runCommand } from "../tools/commandOps.js"; +import { projectDetect, projectHealth } from "../tools/projectOps.js"; +import { terraformValidate, terraformFmt } from "../tools/terraformOps.js"; +import { dockerComposeUp, dockerComposeDown, dockerBuild } from "../tools/dockerOps.js"; +import { checkWorkspace } from "../tools/workspace.js"; + +// P0-10: hostile values in every path and shell argument. Helper: tests/helpers/fuzz.js. +let box; +let all; // every fuzz value plus the injection payloads +beforeAll(async () => { + box = await makeSandbox(); + all = [...FUZZ_VALUES, ...injectionPayloads(box.sentinel)]; +}); +afterAll(async () => { + await box.cleanup(); +}); + +describe("files and directories", () => { + it("handle every usable name literally: nothing is evaluated, nothing leaves the workspace", async () => { + for (const { label, value } of all) { + const name = asFileName(value); + if (!name) continue; + const file = path.join(box.ws, name); + const copy = path.join(box.ws, `${name}.copy`); + const moved = path.join(box.ws, `${name}.moved`); + const dir = path.join(box.ws, `${name}-dir`); + + expect((await attempt(() => createFile({ path: file, content: `body ${label}` }))).ok, `createFile ${label}`).toBe(true); + expect(await fs.readdir(box.ws), `${label}: the file is created under exactly this name`).toContain(name); + expect((await readFile({ path: file })).content).toBe(`body ${label}`); + await getFileInfo({ path: file }); + await editFile({ path: file, old_str: "body", new_str: "BODY" }); + await copyFile({ source: file, destination: copy }); + await moveFile({ source: copy, destination: moved }); + await createDirectory({ path: dir }); + await listDirectory({ path: box.ws }); + await directoryTree({ path: box.ws, max_depth: 2 }); + const found = await searchFiles({ root_path: box.ws, pattern: value }); + expect(found, `searchFiles ${label}`).toBeTruthy(); + await deleteFile({ path: file }); + await deleteFile({ path: moved }); + await deleteDirectory({ path: dir }); + } + expect(await fs.readdir(box.ws)).toEqual([]); + await expectNoEvaluation(box); + }, 120000); + + it("traversal and UNC values are stopped by the workspace jail; the rest fail cleanly or succeed", async () => { + // Backslash traversal and UNC paths only mean something on Windows. On Linux and macOS a backslash is an + // ordinary character in a file name, so those values are just odd names there. + const isWin = process.platform === "win32"; + const TRAVERSAL = new Set(["parent traversal (slash)", ...(isWin ? ["parent traversal", "UNC path"] : [])]); + for (const { label, value } of all) { + if (!isWin && label === "UNC path") continue; + // A UNC value is a whole path, not a name to join onto the workspace. + const target = label === "UNC path" ? value : path.join(box.ws, "nested", value); + // The wrapper runs this check before any tool. Without it a traversal path is a legitimate path. + const gate = checkWorkspace("write_file", { path: target }, box.ws); + if (TRAVERSAL.has(label)) { + expect(gate.allowed, `jail must block ${label}: ${target}`).toBe(false); + continue; + } + expect(gate.allowed, `jail must allow ${label}`).toBe(true); + for (const call of [ + () => createFile({ path: target, content: "x" }), + () => readFile({ path: target }), + () => getFileInfo({ path: target }), + () => deleteFile({ path: target }), + () => createDirectory({ path: target }), + () => listDirectory({ path: target }), + () => deleteDirectory({ path: target }), + () => searchFiles({ root_path: box.ws, pattern: value }), + ]) { + await attempt(call); + } + } + await fs.rm(path.join(box.ws, "nested"), { recursive: true, force: true }); + await expectNoEvaluation(box); + }, 120000); +}); + +describe("workspace jail", () => { + const field = (p) => ({ path: p }); + it("blocks traversal, UNC paths and a symlink that points outside", async () => { + const jail = box.ws; + const outsideFile = path.join(box.outside, "x.txt"); + const blocked = [ + path.join(jail, "..", "outside", "x.txt"), + path.join(jail, "..", "..", "x.txt"), + outsideFile, + "\\\\server\\share\\x", + ]; + for (const p of blocked) { + expect(checkWorkspace("write_file", field(p), jail).allowed, `should block ${p}`).toBe(false); + } + expect(checkWorkspace("write_file", field(path.join(jail, "inside.txt")), jail).allowed).toBe(true); + + const link = path.join(jail, "link"); + const linked = await fs.symlink(box.outside, link, "junction").then(() => true, () => false); + if (linked) { + expect(checkWorkspace("write_file", field(path.join(link, "x.txt")), jail).allowed, "symlink out of the workspace").toBe(false); + await fs.rm(link, { recursive: true, force: true }); + } + }); +}); + +describe("git tools", () => { + let repo; + beforeAll(async () => { + repo = path.join(box.ws, "repo"); + await fs.mkdir(repo); + await git.gitInit({ repo_path: repo }); + const { simpleGit } = await import("simple-git"); + const g = simpleGit(repo); + await g.addConfig("user.email", "fuzz@example.com"); + await g.addConfig("user.name", "Fuzz"); + await g.addConfig("commit.gpgsign", "false"); + await fs.writeFile(path.join(repo, "seed.txt"), "seed"); + await git.gitAdd({ repo_path: repo, files: "seed.txt" }); + await git.gitCommit({ repo_path: repo, message: "seed" }); + }); + + it("pass every value as an argument, never to a shell", async () => { + let n = 0; + for (const { value } of all) { + n++; + await attempt(() => git.gitCreateBranch({ repo_path: repo, branch_name: value, checkout: false })); + await attempt(() => git.gitCheckout({ repo_path: repo, branch: value })); + await attempt(() => git.gitAdd({ repo_path: repo, files: value })); + await attempt(() => git.gitTag({ repo_path: repo, action: "create", tag_name: value, message: value })); + await attempt(() => git.gitStash({ repo_path: repo, action: "save", message: value })); + await attempt(() => git.gitShow({ repo_path: repo, ref: value })); + await attempt(() => git.gitDiff({ repo_path: repo, file: value })); + await attempt(() => git.gitRemote({ repo_path: repo, action: "add", name: `r${n}`, url: value })); + + // A commit message is stored exactly as given (git trims surrounding whitespace). + if (value.trim()) { + await fs.writeFile(path.join(repo, `c${n}.txt`), String(n)); + await git.gitAdd({ repo_path: repo, files: `c${n}.txt` }); + const result = await attempt(() => git.gitCommit({ repo_path: repo, message: value })); + if (result.ok) { + const { simpleGit } = await import("simple-git"); + const stored = (await simpleGit(repo).raw(["log", "-1", "--format=%B"])).trim(); + expect(stored.replace(/\r\n/g, "\n"), "commit message stored verbatim").toBe(value.trim().replace(/\r\n/g, "\n")); + } + } + } + await expectNoEvaluation(box); + }, 240000); + + it("work in a repository whose folder name is hostile", async () => { + for (const { value } of all) { + const name = asFileName(value); + if (!name) continue; + const dir = path.join(box.ws, `repo-${name}`); + await fs.mkdir(dir); + await attempt(() => git.gitInit({ repo_path: dir })); + await attempt(() => git.gitStatus({ repo_path: dir })); + await attempt(() => git.gitLog({ repo_path: dir })); + } + await expectNoEvaluation(box); + }, 240000); +}); + +describe("command, project, terraform and docker tools", () => { + // Folder names that are legal on this OS, a few of each kind. + const names = () => + all + .map((v) => ({ ...v, name: asFileName(v.value) })) + .filter((v) => v.name && !["very long name", "newline injection", "newline"].includes(v.label)) + .slice(0, 12); + + it("run_command uses a hostile folder name as its working directory, not as shell text", async () => { + for (const { label, name } of names()) { + const cwd = path.join(box.ws, `cwd-${name}`); + await fs.mkdir(cwd, { recursive: true }); + const result = await attempt(() => runCommand({ command: "echo fuzz-ok", cwd, timeout_ms: 20000 })); + if (result.ok) expect(JSON.stringify(result.value), `run_command in ${label}`).toContain("fuzz-ok"); + } + await expectNoEvaluation(box); + }, 240000); + + it("project, terraform and docker tools take hostile folder names as plain arguments", async () => { + for (const { name } of names().slice(0, 6)) { + const dir = path.join(box.ws, `proj-${name}`); + await fs.mkdir(dir, { recursive: true }); + await attempt(() => projectDetect({ repo_path: dir })); + await attempt(() => projectHealth({ repo_path: dir })); + await attempt(() => terraformValidate({ dir })); + await attempt(() => terraformFmt({ dir, check: true })); + await attempt(() => dockerComposeUp({ project_dir: dir, confirm: true })); + await attempt(() => dockerComposeDown({ project_dir: dir, confirm: true })); + await attempt(() => dockerBuild({ context_dir: dir, tag: "fuzz:test", confirm: true })); + } + await expectNoEvaluation(box); + expect((await snapshot(box.outside)).length).toBe(box.before.length); + }, 300000); +}); diff --git a/tests/githubListIssues.state.test.js b/tests/githubListIssues.state.test.js new file mode 100644 index 0000000..ac51ebb --- /dev/null +++ b/tests/githubListIssues.state.test.js @@ -0,0 +1,44 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { githubListIssues } from "../tools/githubOps.js"; + +// P0-13: GitHub's issues endpoint also returns pull requests and counts them in the page size. A repo +// whose latest items are all PRs used to give an empty list for state "all" even with an open issue. +const jsonRes = (data) => ({ ok: true, status: 200, statusText: "OK", text: () => Promise.resolve(JSON.stringify(data)) }); +const pr = (n) => ({ number: n, title: `PR ${n}`, state: "closed", html_url: `u${n}`, pull_request: {} }); +const issue = (n, state = "open") => ({ number: n, title: `Issue ${n}`, state, html_url: `u${n}` }); + +beforeEach(() => { + process.env.GITHUB_TOKEN = "gh_test_token"; + global.fetch = vi.fn(); +}); +afterEach(() => { + vi.restoreAllMocks(); + delete process.env.GITHUB_TOKEN; +}); + +describe("githubListIssues state and paging", () => { + it("passes state=all through to the API", async () => { + global.fetch.mockResolvedValueOnce(jsonRes([issue(1), issue(2, "closed")])); + const r = await githubListIssues({ owner: "o", repo: "r", state: "all" }); + expect(global.fetch.mock.calls[0][0]).toContain("state=all"); + expect(r.issues.map((i) => i.number)).toEqual([1, 2]); + }); + + it("finds an issue that sits behind a full page of pull requests", async () => { + const firstPage = Array.from({ length: 100 }, (_, i) => pr(200 - i)); + global.fetch.mockResolvedValueOnce(jsonRes(firstPage)).mockResolvedValueOnce(jsonRes([issue(2), issue(1, "closed")])); + const r = await githubListIssues({ owner: "o", repo: "r", state: "all" }); + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch.mock.calls[1][0]).toContain("page=2"); + expect(r.issues.map((i) => i.number)).toEqual([2, 1]); + expect(r.issues.map((i) => i.state)).toEqual(["open", "closed"]); + }); + + it("stops once per_page issues are collected", async () => { + const page = Array.from({ length: 100 }, (_, i) => issue(i + 1)); + global.fetch.mockResolvedValueOnce(jsonRes(page)); + const r = await githubListIssues({ owner: "o", repo: "r", per_page: 5 }); + expect(r.issues).toHaveLength(5); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/helpers/fuzz.js b/tests/helpers/fuzz.js new file mode 100644 index 0000000..896be99 --- /dev/null +++ b/tests/helpers/fuzz.js @@ -0,0 +1,119 @@ +// P0-10: shared helper for feeding hostile values to every tool argument that reaches a path or a +// shell. Use it when you add a tool that takes a path, a name or a command argument: +// +// const box = await makeSandbox(); +// for (const { label, value } of FUZZ_VALUES) { ... call the tool with value ... } +// await expectNoEvaluation(box); +// +// Each tool must, for every value: not evaluate it (the sentinel file is never created), not touch +// anything outside the sandbox workspace, and either succeed or fail with an Error (never crash or +// throw a non-Error). +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { expect } from "vitest"; + +const isWin = process.platform === "win32"; + +/** The values every path and command argument is tested with. */ +export const FUZZ_VALUES = [ + { label: "single quote", value: "'" }, + { label: "double quote", value: '"' }, + { label: "semicolon", value: ";" }, + { label: "ampersand", value: "&" }, + { label: "pipe", value: "|" }, + { label: "command substitution", value: "$(x)" }, + { label: "backtick", value: "`x`" }, + { label: "percent variable", value: "%PATH%" }, + { label: "newline", value: "a\nb" }, + { label: "very long name", value: "x".repeat(300) }, + { label: "empty string", value: "" }, + { label: "Hindi", value: "नमस्ते" }, + { label: "emoji", value: "🙂" }, + { label: "space inside", value: "two words" }, + { label: "trailing space", value: "trail " }, + { label: "trailing dot", value: "trail." }, + { label: "parent traversal", value: "..\\.." }, + { label: "parent traversal (slash)", value: "../.." }, + { label: "UNC path", value: "\\\\server\\share\\x" }, +]; + +/** + * Strings that would create `sentinel` if any shell evaluated them (sh, PowerShell and cmd.exe forms). + * A tool that is safe never creates the file. + */ +export function injectionPayloads(sentinel) { + const make = `echo x>"${sentinel}"`; + return [ + { label: "semicolon injection", value: `;${make}` }, + { label: "ampersand injection", value: `&${make}` }, + { label: "pipe injection", value: `|${make}` }, + { label: "substitution injection", value: `$(${make})` }, + { label: "backtick injection", value: `\`${make}\`` }, + { label: "newline injection", value: `\n${make}` }, + { label: "quote break-out (single)", value: `' ; ${make} ; '` }, + { label: "quote break-out (double)", value: `" ; ${make} ; "` }, + ]; +} + +/** The value if it can be used as a single file name on this OS, otherwise null. */ +export function asFileName(value) { + if (!value || value.length > 200 || value.includes("\0") || /[\\/]/.test(value)) return null; + if (isWin && (/[<>:"|?*\x00-\x1f]/.test(value) || /[ .]$/.test(value))) return null; + return value; +} + +/** A sandbox: base/ws (the workspace), base/outside (must never change) and base/sentinel (must never appear). */ +export async function makeSandbox() { + const base = await fs.mkdtemp(path.join(os.tmpdir(), "causly-fuzz-")); + const ws = path.join(base, "ws"); + const outside = path.join(base, "outside"); + await fs.mkdir(ws); + await fs.mkdir(outside); + await fs.writeFile(path.join(outside, "keep.txt"), "keep"); + const sentinel = path.join(base, "SENTINEL-evaluated.txt"); + const before = await snapshot(outside); + return { + base, + ws, + outside, + sentinel, + before, + cleanup: () => fs.rm(base, { recursive: true, force: true }), + }; +} + +/** A sorted list of every path under dir, with file sizes. */ +export async function snapshot(dir) { + const out = []; + async function walk(d, rel) { + for (const entry of await fs.readdir(d, { withFileTypes: true })) { + const r = path.join(rel, entry.name); + if (entry.isDirectory()) { + out.push(`${r}/`); + await walk(path.join(d, entry.name), r); + } else { + out.push(`${r}:${(await fs.stat(path.join(d, entry.name))).size}`); + } + } + } + await walk(dir, ""); + return out.sort(); +} + +/** Runs fn. Returns { ok, value } or { ok: false, error }. Anything thrown that is not an Error fails the test. */ +export async function attempt(fn) { + try { + return { ok: true, value: await fn() }; + } catch (err) { + expect(err instanceof Error, `a non-Error was thrown: ${String(err)}`).toBe(true); + return { ok: false, error: err }; + } +} + +/** Nothing was evaluated and nothing outside the workspace changed. */ +export async function expectNoEvaluation(box) { + const evaluated = await fs.access(box.sentinel).then(() => true, () => false); + expect(evaluated, "a value was evaluated by a shell (sentinel file exists)").toBe(false); + expect(await snapshot(box.outside), "something outside the workspace changed").toEqual(box.before); +} diff --git a/tests/helpers/packCheck.js b/tests/helpers/packCheck.js new file mode 100644 index 0000000..fa205b5 --- /dev/null +++ b/tests/helpers/packCheck.js @@ -0,0 +1,44 @@ +// Checks the list of files npm would publish (P0-07). Used by tests/packageContents.test.js. +const ALWAYS_INCLUDED = [/^package\.json$/, /^(readme|license|licence)(\..*)?$/i]; + +// Things that must never be published, wherever they are, even inside an allowed folder. +const FORBIDDEN = [ + { re: /(^|\/)\.env(\..+)?$/, except: /(^|\/)\.env\.example$/, why: "environment file" }, + { re: /\.(db|sqlite|sqlite3)(-wal|-shm|-journal)?$/i, why: "database" }, + { re: /\.log$/i, why: "log file" }, + { re: /(^|\/)logs\//, why: "logs folder" }, + { re: /\.(pem|key|p12|pfx|jks|keystore)$/i, why: "private key or keystore" }, + { re: /(^|\/)id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/, why: "ssh key" }, + { re: /\.enc$/i, why: "encrypted secrets store" }, + { re: /(^|\/)\.npmrc$/, why: "npm credentials" }, + { re: /(^|\/)credentials(\.json)?$/i, why: "credentials file" }, + { re: /(^|\/)\.git(\/|$)/, why: "git data" }, + { re: /(^|\/)node_modules\//, why: "node_modules" }, + { re: /(^|\/)\.agent-tests\//, why: "local agent test folder" }, + { re: /\.(tgz|zip)$/i, why: "archive" }, +]; + +function matchesEntry(file, entry) { + const clean = entry.replace(/^\.\//, "").replace(/\/$/, ""); + if (clean.includes("*")) { + const re = new RegExp(`^${clean.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join("[^/]*")}$`); + return re.test(file); + } + return file === clean || file.startsWith(`${clean}/`); +} + +/** Returns a list of problems; empty means the package contents are fine. */ +export function checkPackedFiles(paths, pkg) { + const allow = [...(pkg.files || [])]; + if (pkg.main) allow.push(pkg.main); + for (const target of Object.values(typeof pkg.bin === "string" ? { bin: pkg.bin } : pkg.bin || {})) allow.push(target); + + const problems = []; + for (const file of paths) { + const forbidden = FORBIDDEN.find((rule) => rule.re.test(file) && !(rule.except && rule.except.test(file))); + if (forbidden) problems.push(`forbidden (${forbidden.why}): ${file}`); + const allowed = ALWAYS_INCLUDED.some((re) => re.test(file)) || allow.some((entry) => matchesEntry(file, entry)); + if (!allowed) problems.push(`not in the package.json "files" allowlist: ${file}`); + } + return problems; +} diff --git a/tests/helpers/shellScan.js b/tests/helpers/shellScan.js new file mode 100644 index 0000000..f67dd5d --- /dev/null +++ b/tests/helpers/shellScan.js @@ -0,0 +1,28 @@ +// Finds places where a shell command is built as text. Used by tests/noShellStrings.test.js (P0-04). +// Safe pattern: execFile(file, [argv...]). Unsafe: exec(`...${x}...`) or execFile("bash", ["-lc", cmdText]). +const RULES = [ + { name: "template literal passed to exec", re: /(? { + server = http.createServer((req, res) => { + hits += 1; + if (req.url === "/redirect") { + res.writeHead(302, { location: `http://127.0.0.1:${port}/ok` }); + res.end(); + } else if (req.url === "/slow") { + // never answers + } else if (req.url === "/down") { + res.writeHead(503); + res.end("down"); + } else if (req.url === "/moved") { + res.writeHead(301); + res.end(); + } else { + res.writeHead(200, { "content-type": "text/plain" }); + res.end("ok"); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + port = server.address().port; +}); + +afterAll(async () => { + server.closeAllConnections?.(); + await new Promise((resolve) => server.close(resolve)); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + hits = 0; +}); + +describe("http_check blocks internal targets", () => { + const targets = () => [ + `http://127.0.0.1:${port}/`, + `http://localhost:${port}/`, + `http://[::1]:${port}/`, + `http://[::ffff:127.0.0.1]:${port}/`, + `http://[::ffff:7f00:1]:${port}/`, + `http://2130706433:${port}/`, // decimal + `http://0177.0.0.1:${port}/`, // octal + `http://0x7f000001:${port}/`, // hex + `http://127.1:${port}/`, // short form + `http://0.0.0.0:${port}/`, + "http://10.0.0.1/", + "http://172.16.0.1/", + "http://192.168.1.1/", + "http://100.64.0.1/", + "http://169.254.169.254/latest/meta-data/", + "http://[fe80::1]/", + "http://[fd00::1]/", + ]; + + it.each(targets().map((_, i) => i))("target #%i is refused and never contacted", async (i) => { + const url = targets()[i]; + const result = await httpCheck({ url, timeout_ms: 1500 }); + expect(result.healthy).toBe(false); + expect(result.status).toBeNull(); + expect(result.error).toMatch(/Blocked/); + expect(hits).toBe(0); + }); + + it("refuses a DNS name that resolves to a private address", async () => { + vi.spyOn(dns, "lookup").mockImplementation((host, opts, cb) => cb(null, [{ address: "10.0.0.5", family: 4 }])); + const result = await httpCheck({ url: "http://evil.example.test/", timeout_ms: 1500 }); + expect(result.healthy).toBe(false); + expect(result.error).toMatch(/resolves to a private or reserved address/); + }); + + it("refuses a name when ANY of its addresses is private", async () => { + vi.spyOn(dns, "lookup").mockImplementation((host, opts, cb) => + cb(null, [ + { address: "93.184.216.34", family: 4 }, + { address: "127.0.0.1", family: 4 }, + ]) + ); + const result = await httpCheck({ url: "http://mixed.example.test/", timeout_ms: 1500 }); + expect(result.error).toMatch(/Blocked/); + }); + + it("refuses non-http schemes and embedded credentials", async () => { + expect((await httpCheck({ url: "file:///etc/passwd" })).error).toMatch(/http and https/); + expect((await httpCheck({ url: "ftp://example.com/" })).error).toMatch(/http and https/); + expect((await httpCheck({ url: "http://user:pw@example.com/" })).error).toMatch(/credentials/); + }); + + it("refuses a public URL that redirects to a private address", async () => { + const calls = []; + const requestOnce = async (url) => { + calls.push(url.toString()); + return { statusCode: 302, headers: { location: "http://169.254.169.254/latest/meta-data/" }, resume() {} }; + }; + await expect(netGuard.guardedRequest("http://93.184.216.34/start", { requestOnce })).rejects.toThrow(/Blocked/); + expect(calls).toEqual(["http://93.184.216.34/start"]); + }); + + it("stops after too many redirects", async () => { + const requestOnce = async () => ({ statusCode: 302, headers: { location: "http://93.184.216.34/again" }, resume() {} }); + await expect(netGuard.guardedRequest("http://93.184.216.34/", { requestOnce })).rejects.toThrow(/Too many redirects/); + }); +}); + +describe("http_check still works for real targets", () => { + it("reports healthy for 2xx, unhealthy for 5xx, healthy for a bare 3xx", async () => { + vi.stubEnv("CAUSLY_ALLOW_PRIVATE_HOSTS", "true"); + const ok = await httpCheck({ url: `http://127.0.0.1:${port}/` }); + expect(ok).toMatchObject({ healthy: true, status: 200 }); + expect(typeof ok.response_time_ms).toBe("number"); + expect(await httpCheck({ url: `http://127.0.0.1:${port}/down` })).toMatchObject({ healthy: false, status: 503 }); + expect(await httpCheck({ url: `http://127.0.0.1:${port}/moved` })).toMatchObject({ healthy: true, status: 301 }); + }); + + it("follows redirects", async () => { + vi.stubEnv("CAUSLY_ALLOW_PRIVATE_HOSTS", "true"); + expect(await httpCheck({ url: `http://127.0.0.1:${port}/redirect` })).toMatchObject({ healthy: true, status: 200 }); + }); + + it("reports a connection error as data", async () => { + vi.stubEnv("CAUSLY_ALLOW_PRIVATE_HOSTS", "true"); + const result = await httpCheck({ url: "http://127.0.0.1:1/", timeout_ms: 2000 }); + expect(result).toMatchObject({ healthy: false, status: null }); + expect(result.error).toBeTruthy(); + }); + + it("reports 'timeout' when the server never answers", async () => { + vi.stubEnv("CAUSLY_ALLOW_PRIVATE_HOSTS", "true"); + const result = await httpCheck({ url: `http://127.0.0.1:${port}/slow`, timeout_ms: 300 }); + expect(result).toMatchObject({ healthy: false, status: null, error: "timeout" }); + }); +}); + +describe("one shared guard", () => { + it("web_fetch and http_check use the same address check", () => { + expect(webOps.isPublicAddress).toBe(netGuard.isPublicAddress); + expect(webOps.parseHttpUrl).toBe(netGuard.parseHttpUrl); + }); +}); diff --git a/tests/netGuard.test.js b/tests/netGuard.test.js new file mode 100644 index 0000000..a248713 --- /dev/null +++ b/tests/netGuard.test.js @@ -0,0 +1,66 @@ +import http from "node:http"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { isPublicAddress } from "../tools/netGuard.js"; +import { httpCheck } from "../tools/vercelOps.js"; + +// P0-05 follow-up: IPv6 transition forms that carry an IPv4 address must be judged by that address. +describe("isPublicAddress: IPv6 forms that embed IPv4", () => { + it.each([ + ["6to4 of 127.0.0.1", "2002:7f00:1::1"], + ["6to4 of 10.0.0.1", "2002:0a00:0001::"], + ["6to4 of 192.168.1.1", "2002:c0a8:0101::1"], + ["6to4 of 169.254.169.254", "2002:a9fe:a9fe::"], + ["6to4 of 172.16.0.1", "2002:ac10:1::"], + ["Teredo", "2001:0:4136:e378:8000:63bf:3fff:fdd2"], + ["Teredo short", "2001::1"], + ["ISATAP of 127.0.0.1", "2a00:1450::5efe:7f00:1"], + ["ISATAP of 10.0.0.1 (dotted)", "2a00:1450::5efe:10.0.0.1"], + ["ISATAP local flag, 192.168.0.1", "2a00:1450::200:5efe:c0a8:1"], + ["IPv4-compatible", "::7f00:1"], + ["NAT64 of 127.0.0.1", "64:ff9b::7f00:1"], + ])("blocks %s (%s)", (_name, ip) => { + expect(isPublicAddress(ip)).toBe(false); + }); + + it.each([ + ["6to4 of 8.8.8.8", "2002:0808:0808::1"], + ["ISATAP of 8.8.8.8", "2a00:1450::5efe:808:808"], + ["ordinary global address", "2606:4700:4700::1111"], + ["google v6", "2a00:1450:4009:81f::200e"], + ])("still allows %s (%s)", (_name, ip) => { + expect(isPublicAddress(ip)).toBe(true); + }); + + it("treats malformed IPv6 as not public", () => { + expect(isPublicAddress("2002:::1")).toBe(false); + expect(isPublicAddress("not-an-ip")).toBe(false); + }); +}); + +describe("http_check refuses IPv6 transition addresses without connecting", () => { + let server; + let hits = 0; + let port; + beforeAll(async () => { + server = http.createServer((req, res) => { + hits += 1; + res.end("ok"); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + port = server.address().port; + }); + afterAll(async () => { + server.closeAllConnections?.(); + await new Promise((r) => server.close(r)); + }); + + it.each(["[2002:7f00:1::]", "[2002:0a00:0001::1]", "[2001:0:4136:e378:8000:63bf:3fff:fdd2]", "[2a00:1450::5efe:7f00:1]"])( + "%s", + async (host) => { + const result = await httpCheck({ url: `http://${host}:${port}/`, timeout_ms: 1500 }); + expect(result.healthy).toBe(false); + expect(result.error).toMatch(/Blocked/); + expect(hits).toBe(0); + } + ); +}); diff --git a/tests/noShellStrings.test.js b/tests/noShellStrings.test.js new file mode 100644 index 0000000..97d91d7 --- /dev/null +++ b/tests/noShellStrings.test.js @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, test } from "vitest"; +import { findShellStringBuilding } from "./helpers/shellScan.js"; + +// P0-04: no code may build a shell command from text. Use execFile(file, [args]) instead. +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function sourceFiles() { + const dirs = ["tools", "bin", "packs"]; + const files = [path.join(root, "index.js")]; + for (const dir of dirs) { + for (const name of fs.readdirSync(path.join(root, dir))) { + if (name.endsWith(".js")) files.push(path.join(root, dir, name)); + } + } + return files; +} + +describe("shell string scanner", () => { + const bad = [ + "await exec(`cd '${dir}' && docker compose up`);", + "execAsync(`git -C ${dir} status`)", + "execSync('echo ' + name)", + 'execFileAsync("wsl", ["bash", "-lc", `cd \'${dir}\' && ls`])', + 'execFile("cmd", ["/c", "echo " + name])', + 'await execFileAsync("wsl", ["bash", "-lc", composeCmd])', + ]; + const good = [ + 'await execFileAsync("wsl", ["--cd", dir, "--", "docker", "compose", "up"])', + "db.exec(`VACUUM INTO '${target}'`)", + "const m = /x/.exec(text)", + "execAsync(command, runOpts)", + 'execFileAsync(CMD_PATH, ["/d", "/s", "/c", command], runOpts)', + ]; + for (const snippet of bad) { + test(`flags: ${snippet}`, () => { + assert.ok(findShellStringBuilding(snippet).length > 0); + }); + } + for (const snippet of good) { + test(`allows: ${snippet}`, () => { + assert.deepEqual(findShellStringBuilding(snippet, { allowShellVariable: true }), []); + }); + } +}); + +test("no source file builds a shell command from text", () => { + const problems = []; + for (const file of sourceFiles()) { + // run_command is the one tool whose job is to run a shell command the user gave it. + const found = findShellStringBuilding(fs.readFileSync(file, "utf8"), { allowShellVariable: path.basename(file) === "commandOps.js" }); + for (const f of found) problems.push(`${path.relative(root, file)}:${f.line} ${f.rule}: ${f.text}`); + } + assert.deepEqual(problems, []); +}); diff --git a/tests/packageContents.test.js b/tests/packageContents.test.js new file mode 100644 index 0000000..f863515 --- /dev/null +++ b/tests/packageContents.test.js @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, test } from "vitest"; +import { checkPackedFiles } from "./helpers/packCheck.js"; + +// P0-07: the published tarball may only contain the package.json "files" allowlist, and never +// secrets, databases, logs or keys. +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); + +describe("checkPackedFiles (broken fixtures must fail)", () => { + test.each([ + [".env"], + ["tools/.env"], + [".env.production"], + ["tools/causly.db"], + ["tools/causly.db-wal"], + ["logs/activity.log"], + ["tools/activity.log"], + ["packs/server.pem"], + ["bin/id_rsa"], + ["tools/secrets.enc"], + [".npmrc"], + ["tools/credentials.json"], + [".agent-tests/x.js"], + ["node_modules/zod/index.js"], + ["causly-server-1.5.4.tgz"], + ])("rejects %s", (file) => { + assert.ok(checkPackedFiles([file], pkg).length > 0, `${file} was not rejected`); + }); + + test("rejects files outside the allowlist", () => { + const problems = checkPackedFiles(["scripts/agent-smoke.mjs", "tests/a.test.js", "docs/P0.md"], pkg); + assert.equal(problems.length, 3); + }); + + test("accepts the normal package", () => { + const good = ["package.json", "README.md", "LICENSE", "CHANGELOG.md", ".env.example", "index.js", "bin/causly.js", "tools/netGuard.js", "packs/manifest.json", "prompts/a.md"]; + assert.deepEqual(checkPackedFiles(good, pkg), []); + }); +}); + +test("npm pack lists only allowed files", () => { + const out = execSync("npm pack --dry-run --json --ignore-scripts", { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 120_000, + }); + const files = JSON.parse(out)[0].files.map((f) => f.path.replace(/\\/g, "/")); + assert.ok(files.includes("index.js") && files.includes("bin/causly.js") && files.includes("packs/manifest.json"), "core files missing from the package"); + assert.deepEqual(checkPackedFiles(files, pkg), []); + assert.ok(!files.some((f) => f.startsWith("tests/") || f.startsWith("docs/") || f.startsWith("scripts/"))); +}, 130_000); diff --git a/tests/pathGuard.test.js b/tests/pathGuard.test.js new file mode 100644 index 0000000..3dd9531 --- /dev/null +++ b/tests/pathGuard.test.js @@ -0,0 +1,273 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as directoryOps from "../tools/directoryOps.js"; +import * as fileOps from "../tools/fileOps.js"; +import * as secretsOps from "../tools/secretsOps.js"; + +// P0-06: every file and directory tool must refuse protected paths, resolved on the real filesystem. +const isWin = process.platform === "win32"; +const SYSTEM_DIR = isWin ? "C:\\Windows" : "/etc"; +const EXTRA_CLEANUP = []; + +let root; +let home; +let ssh; +let work; +let PROT; // a file inside a credentials folder +let NEW_PROT; // a new file in a credentials folder +let SAFE_FILE; +let SAFE_NEW; + +beforeEach(() => { + root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), "causly-p006-"))); + home = path.join(root, "home"); + ssh = path.join(home, ".ssh"); + work = path.join(root, "work"); + fs.mkdirSync(ssh, { recursive: true }); + fs.mkdirSync(work); + PROT = path.join(ssh, "id_test"); + NEW_PROT = path.join(ssh, "new.txt"); + SAFE_FILE = path.join(work, "safe.txt"); + SAFE_NEW = path.join(work, "safe-new.txt"); + fs.writeFileSync(PROT, "PRIVATE-KEY"); + fs.writeFileSync(SAFE_FILE, "hello"); + vi.stubEnv("HOME", home); + vi.stubEnv("USERPROFILE", home); + vi.stubEnv("SECRETS_MASTER_KEY", crypto.randomBytes(32).toString("hex")); + vi.stubEnv("SECRETS_MASTER_KEY_NEW", crypto.randomBytes(32).toString("hex")); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + for (const p of EXTRA_CLEANUP.splice(0)) fs.rmSync(p, { recursive: true, force: true }); + fs.rmSync(root, { recursive: true, force: true }); +}); + +const reject = (fn) => expect(Promise.resolve().then(fn)).rejects.toThrow(/protected/i); + +// One entry per exported function. A new export without an entry fails the "covers every export" tests below. +const FILE_OPS_CASES = () => ({ + readFile: [() => fileOps.readFile({ path: PROT })], + readMultipleFiles: [ + async () => { + const { results } = await fileOps.readMultipleFiles({ paths: [PROT] }); + if (results[0].success) return results; // would be a leak + throw new Error(results[0].error); + }, + ], + createFile: [() => fileOps.createFile({ path: NEW_PROT, content: "x" })], + writeFile: [() => fileOps.writeFile({ path: NEW_PROT, content: "x" })], + editFile: [() => fileOps.editFile({ path: PROT, old_str: "PRIVATE", new_str: "PUBLIC" })], + deleteFile: [() => fileOps.deleteFile({ path: PROT })], + moveFile: [ + () => fileOps.moveFile({ source: PROT, destination: SAFE_NEW }), + () => fileOps.moveFile({ source: SAFE_FILE, destination: NEW_PROT }), + ], + copyFile: [ + () => fileOps.copyFile({ source: PROT, destination: SAFE_NEW }), + () => fileOps.copyFile({ source: SAFE_FILE, destination: NEW_PROT }), + ], + getFileInfo: [() => fileOps.getFileInfo({ path: PROT })], +}); + +const DIRECTORY_OPS_CASES = () => ({ + listDirectory: [() => directoryOps.listDirectory({ path: ssh })], + directoryTree: [() => directoryOps.directoryTree({ path: ssh })], + createDirectory: [() => directoryOps.createDirectory({ path: path.join(ssh, "sub") })], + deleteDirectory: [() => directoryOps.deleteDirectory({ path: ssh })], + searchFiles: [() => directoryOps.searchFiles({ root_path: ssh, pattern: "id" })], +}); + +function expectUntouched() { + expect(fs.readFileSync(PROT, "utf8")).toBe("PRIVATE-KEY"); + expect(fs.readdirSync(ssh)).toEqual(["id_test"]); + expect(fs.readdirSync(work).sort()).toEqual(["safe.txt"]); + expect(fs.readFileSync(SAFE_FILE, "utf8")).toBe("hello"); +} + +describe("every exported file and directory tool calls the guard", () => { + it("has a case for every export of fileOps.js", () => { + expect(Object.keys(FILE_OPS_CASES()).sort()).toEqual(Object.keys(fileOps).sort()); + }); + + it("has a case for every export of directoryOps.js", () => { + expect(Object.keys(DIRECTORY_OPS_CASES()).sort()).toEqual(Object.keys(directoryOps).sort()); + }); + + for (const [group, build] of [ + ["fileOps", FILE_OPS_CASES], + ["directoryOps", DIRECTORY_OPS_CASES], + ]) { + for (const name of Object.keys(build())) { + it(`${group}.${name} refuses a credentials folder`, async () => { + for (let i = 0; i < build()[name].length; i++) { + await reject(build()[name][i]); + } + expectUntouched(); + }); + } + } +}); + +describe("system directories", () => { + const target = (name) => { + const p = path.join(SYSTEM_DIR, name); + EXTRA_CLEANUP.push(p); + return p; + }; + + it("refuses writes, copies in, moves in and new folders", async () => { + const system = /protected system directory/; + await expect(fileOps.writeFile({ path: target("causly-p0-06.txt"), content: "x" })).rejects.toThrow(system); + await expect(fileOps.createFile({ path: target("causly-p0-06-b.txt"), content: "x" })).rejects.toThrow(system); + await expect(fileOps.copyFile({ source: SAFE_FILE, destination: target("causly-p0-06-c.txt") })).rejects.toThrow(system); + await expect(fileOps.moveFile({ source: SAFE_FILE, destination: target("causly-p0-06-d.txt") })).rejects.toThrow(system); + await expect(directoryOps.createDirectory({ path: target("causly-p0-06-dir") })).rejects.toThrow(system); + expect(fs.readFileSync(SAFE_FILE, "utf8")).toBe("hello"); + }); + + it("still allows reading and listing them", async () => { + const listing = await directoryOps.listDirectory({ path: SYSTEM_DIR }); + expect(listing.entries.length).toBeGreaterThan(0); + }); +}); + +describe("resolving the real path", () => { + const linkType = isWin ? "junction" : "dir"; + + it("refuses a write through a link that points into a system directory", async () => { + const link = path.join(work, "sneaky"); + fs.symlinkSync(SYSTEM_DIR, link, linkType); + const leaf = "causly-p0-06-link.txt"; + EXTRA_CLEANUP.push(path.join(SYSTEM_DIR, leaf)); + await expect(fileOps.writeFile({ path: path.join(link, leaf), content: "x" })).rejects.toThrow(/protected/); + await expect(fileOps.copyFile({ source: SAFE_FILE, destination: path.join(link, leaf) })).rejects.toThrow(/protected/); + expect(fs.existsSync(path.join(SYSTEM_DIR, leaf))).toBe(false); + }); + + it("refuses a read through a link that points into a credentials folder", async () => { + const link = path.join(work, "to-ssh"); + fs.symlinkSync(ssh, link, linkType); + await expect(fileOps.readFile({ path: path.join(link, "id_test") })).rejects.toThrow(/protected/); + await expect(directoryOps.listDirectory({ path: link })).rejects.toThrow(/protected/); + }); + + it("refuses a link that points at a single protected file", async () => { + if (isWin) return; // file symlinks need extra privileges on Windows + const link = path.join(work, "key-link"); + fs.symlinkSync(PROT, link, "file"); + await expect(fileOps.readFile({ path: link })).rejects.toThrow(/protected/); + }); + + it("refuses .. traversal that ends in a protected folder", async () => { + const sneaky = `${work}${path.sep}..${path.sep}home${path.sep}.ssh${path.sep}id_test`; + await expect(fileOps.readFile({ path: sneaky })).rejects.toThrow(/protected/); + }); + + it("refuses a relative path that resolves into a protected folder", async () => { + const cwd = process.cwd(); + process.chdir(work); + try { + await expect(fileOps.readFile({ path: path.join("..", "home", ".ssh", "id_test") })).rejects.toThrow(/protected/); + } finally { + process.chdir(cwd); + } + }); + + it("still works for ordinary paths, including new files in new folders", async () => { + expect((await fileOps.readFile({ path: SAFE_FILE })).content).toBe("hello"); + await fileOps.writeFile({ path: SAFE_NEW, content: "ok" }); + expect(fs.readFileSync(SAFE_NEW, "utf8")).toBe("ok"); + await fileOps.copyFile({ source: SAFE_FILE, destination: path.join(work, "deep", "copy.txt") }); + expect(fs.readFileSync(path.join(work, "deep", "copy.txt"), "utf8")).toBe("hello"); + const found = await directoryOps.searchFiles({ root_path: work, pattern: "safe" }); + expect(found.matches.length).toBeGreaterThan(0); + }); + + it("a sibling folder that only shares a name prefix is not protected", async () => { + const sibling = path.join(home, ".ssh-notes"); + fs.mkdirSync(sibling); + await fileOps.writeFile({ path: path.join(sibling, "a.txt"), content: "fine" }); + expect((await fileOps.readFile({ path: path.join(sibling, "a.txt") })).content).toBe("fine"); + }); +}); + +describe("causly's own folder (API keys, secrets store, database)", () => { + it("CAUSLY_HOME cannot be read or written through the file tools", async () => { + const causlyHome = path.join(root, "causly-home"); + fs.mkdirSync(causlyHome); + fs.writeFileSync(path.join(causlyHome, ".env"), "GROQ_API_KEY=secret"); + vi.stubEnv("CAUSLY_HOME", causlyHome); + await expect(fileOps.readFile({ path: path.join(causlyHome, ".env") })).rejects.toThrow(/protected/); + await expect(fileOps.writeFile({ path: path.join(causlyHome, ".env"), content: "x" })).rejects.toThrow(/protected/); + await expect(directoryOps.listDirectory({ path: causlyHome })).rejects.toThrow(/protected/); + const found = await directoryOps.searchFiles({ root_path: root, pattern: ".env" }); + expect(found.matches.map((m) => m.path)).not.toContain(path.join(causlyHome, ".env")); + expect(fs.readFileSync(path.join(causlyHome, ".env"), "utf8")).toBe("GROQ_API_KEY=secret"); + }); + + it("the secrets tools can still use their default store (it is not a file-tool argument)", async () => { + const store = path.join(work, "secrets.enc"); + await secretsOps.secretsSet({ name: "K", value: "v", store_path: store, confirm: true }); + expect((await secretsOps.secretsGet({ name: "K", store_path: store })).value).toBe("v"); + }); +}); + +describe("walkers skip protected folders instead of listing them", () => { + it("searchFiles from a parent folder does not descend into .ssh", async () => { + const found = await directoryOps.searchFiles({ root_path: home, pattern: "id_test" }); + expect(found.matches).toEqual([]); + }); + + it("directoryTree from a parent folder does not include .ssh contents", async () => { + const tree = await directoryOps.directoryTree({ path: home }); + expect(JSON.stringify(tree)).not.toContain("id_test"); + }); +}); + +describe("secrets store_path is guarded like any other path", () => { + it("refuses a store inside a credentials folder or a system directory", async () => { + const inSsh = path.join(ssh, "store.enc"); + const inSystem = path.join(SYSTEM_DIR, "causly-p0-06.enc"); + EXTRA_CLEANUP.push(inSystem); + await expect(secretsOps.secretsSet({ name: "A", value: "b", store_path: inSsh, confirm: true })).rejects.toThrow(/protected/); + await expect(secretsOps.secretsSet({ name: "A", value: "b", store_path: inSystem, confirm: true })).rejects.toThrow(/protected/); + await expect(secretsOps.secretsGet({ name: "A", store_path: PROT })).rejects.toThrow(/protected/); + await expect(secretsOps.secretsList({ store_path: PROT })).rejects.toThrow(/protected/); + await expect(secretsOps.secretsDelete({ name: "A", store_path: PROT, confirm: true })).rejects.toThrow(/protected/); + await expect(secretsOps.secretsRotateKey({ store_path: PROT, confirm: true })).rejects.toThrow(/protected/); + expect(fs.existsSync(inSsh)).toBe(false); + expect(fs.existsSync(inSystem)).toBe(false); + }); + + it("still works with an ordinary custom store_path", async () => { + const store = path.join(work, "secrets.enc"); + await secretsOps.secretsSet({ name: "A", value: "b", store_path: store, confirm: true }); + expect((await secretsOps.secretsGet({ name: "A", store_path: store })).value).toBe("b"); + }); +}); + +describe.skipIf(!isWin)("Windows path forms on the real filesystem", () => { + it("refuses the admin share of this machine", async () => { + const unc = "\\\\localhost\\C$\\Windows\\causly-p0-06-unc.txt"; + EXTRA_CLEANUP.push("C:\\Windows\\causly-p0-06-unc.txt"); + await expect(fileOps.writeFile({ path: unc, content: "x" })).rejects.toThrow(/protected/); + }); + + it("refuses reserved device names and alternate data streams", async () => { + await expect(fileOps.readFile({ path: path.join(work, "CON") })).rejects.toThrow(/protected|reserved/i); + await expect(fileOps.writeFile({ path: path.join(work, "NUL"), content: "x" })).rejects.toThrow(/reserved/i); + await expect(fileOps.writeFile({ path: `${SAFE_FILE}::$DATA`, content: "x" })).rejects.toThrow(/alternate data stream/i); + expect(fs.readFileSync(SAFE_FILE, "utf8")).toBe("hello"); + }); + + it("refuses an 8.3 short name for Program Files", async () => { + if (!fs.existsSync("C:\\PROGRA~1")) return; // short names are switched off on this volume + const leaf = "causly-p0-06-short.txt"; + EXTRA_CLEANUP.push(path.join("C:\\Program Files", leaf)); + await expect(fileOps.writeFile({ path: path.join("C:\\PROGRA~1", leaf), content: "x" })).rejects.toThrow(/protected/); + }); +}); diff --git a/tests/pathRules.test.js b/tests/pathRules.test.js new file mode 100644 index 0000000..d35005a --- /dev/null +++ b/tests/pathRules.test.js @@ -0,0 +1,236 @@ +import { describe, expect, it } from "vitest"; +import path from "node:path"; +import { isPathDenied } from "../tools/pathRules.js"; +import { PACKAGE_ROOT } from "../tools/paths.js"; + +// P0-06: protected-path rules (pure string rules, run on every OS). The real-filesystem part is in pathGuard.test.js. +const HOME = "/home/me"; +const WIN_HOME = "C:\\Users\\me"; +const denied = (p, opts = {}) => isPathDenied(p, { home: HOME, ...opts }).denied; + +describe("system directories (write is refused, read is allowed)", () => { + it.each([ + "C:\\Windows", + "C:\\Windows\\System32\\x.dll", + "C:/Windows/System32", + "c:\\WINDOWS\\system32", + "C:\\Program Files (x86)\\App\\a.exe", + "C:\\Users\\Public\\x.txt", + "C:\\ProgramData\\x", + "C:\\proj\\..\\..\\Windows\\x", // .. traversal + "C:\\Windows.\\x", // trailing dot is ignored by Windows + "C:\\Windows \\x", // trailing space is ignored by Windows + "C:\\Windows...\\x", + "\\\\?\\C:\\Windows\\x", // extended-length prefix + "\\\\.\\C:\\Windows\\x", // device prefix + "\\\\?\\UNC\\localhost\\C$\\Windows\\x", + "\\\\localhost\\C$\\Windows\\System32", // admin share of this machine + "\\\\127.0.0.1\\c$\\Windows", + "/etc", + "/etc/passwd", + "/usr/local/bin/tool", + "/bin/sh", + "/sbin/init", + "/boot/vmlinuz", + "/System/Library/CoreServices/x", + "/Library/Preferences/x", + "/private/etc/hosts", + "/home/me/../../etc/hosts", + "/tmp/../etc/passwd", + ])("refuses to write %s", (p) => { + const result = isPathDenied(p, { home: HOME }); + expect(result.denied).toBe(true); + expect(result.reason).toContain("protected system directory"); + }); + + it.each([ + "C:\\Windows.old\\x", // sibling that only shares a prefix + "C:\\WindowsApps\\x", + "C:\\Program Files Extra\\x", + "C:\\ProgramData2\\x", + "C:\\Recovery2\\x", + "D:\\causly-server\\tools\\fileOps.js", + "D:\\Windows\\x", + "/etcetera/x", + "/usr2/x", + "/Library2/x", + "/home/me/project/x", + "/tmp/x", + "/var/tmp/x", + "relative/path.txt", + ])("allows writing %s", (p) => { + expect(denied(p)).toBe(false); + }); + + it.each(["/etc/hosts", "/usr/share/doc/x", "C:\\Windows\\System32\\drivers\\etc\\hosts", "C:\\Program Files\\App\\readme.txt"])( + "allows reading %s", + (p) => { + expect(denied(p, { access: "read" })).toBe(false); + } + ); + + it("follows a Windows installed on another drive (SystemRoot, ProgramFiles)", () => { + const env = { SystemRoot: "D:\\WinNT", ProgramFiles: "E:\\Apps" }; + expect(denied("D:\\WinNT\\system32\\x", { env })).toBe(true); + expect(denied("E:\\Apps\\tool\\x", { env })).toBe(true); + expect(denied("D:\\WinNT.bak\\x", { env })).toBe(false); + }); +}); + +describe("credential locations (read AND write are refused)", () => { + it.each([ + "/home/me/.ssh", + "/home/me/.ssh/id_rsa", + "/home/me/.aws/credentials", + "/home/me/.config/gcloud/credentials.db", + "/home/me/Library/Keychains/login.keychain-db", + "/home/me/.local/share/keyrings/login.keyring", + "/Library/Keychains/System.keychain", + "/System/Library/Keychains/x", + "/Users/bob/.ssh/id_rsa", // another user's home + "/home/alice/.aws/config", + "/root/.ssh/authorized_keys", + "/home/me/project/../.ssh/id_rsa", + "C:\\Users\\me\\.ssh\\id_rsa", + "C:\\Users\\me\\.aws\\credentials", + "C:\\Users\\me\\.config\\gcloud\\x", + "C:\\Users\\me\\AppData\\Roaming\\gcloud\\x", + "C:\\Users\\me\\AppData\\Roaming\\Microsoft\\Credentials\\x", + "C:\\Users\\me\\AppData\\Local\\Microsoft\\Vault\\x", + "C:\\Users\\me\\AppData\\Roaming\\Microsoft\\Protect\\S-1-5-21\\key", + ])("refuses %s for read and write", (p) => { + for (const access of ["read", "write"]) { + const result = isPathDenied(p, { home: HOME, access }); + expect(result.denied, `${access} ${p}`).toBe(true); + expect(result.reason).toContain("protected"); + } + }); + + it("uses the real home directory it is given", () => { + expect(denied("D:\\profiles\\me\\.ssh\\id_rsa", { home: "D:\\profiles\\me", access: "read" })).toBe(true); + expect(denied("/srv/people/me/.aws/credentials", { home: "/srv/people/me", access: "read" })).toBe(true); + }); + + it.each([ + "/home/me/.sshx/x", + "/home/me/ssh/x", + "/home/me/.aws-notes/x", + "/home/me/.config/other/x", + "C:\\Users\\me\\.sshkeys\\x", + "C:\\Users\\me\\Documents\\x", + ])("does not touch the look-alike %s", (p) => { + expect(denied(p, { access: "read" })).toBe(false); + expect(denied(p, { access: "write" })).toBe(false); + }); +}); + +describe("Windows-only name tricks (refused for read and write)", () => { + it.each([ + "D:\\work\\CON", + "D:\\work\\nul", + "D:\\work\\NUL.txt", + "D:\\work\\com1", + "D:\\work\\LPT9.log", + "D:\\work\\aux", + "D:\\work\\prn", + "D:\\work\\CONIN$", + "D:\\con\\file.txt", + ])("reserved device name: %s", (p) => { + for (const access of ["read", "write"]) { + const result = isPathDenied(p, { access }); + expect(result.denied, `${access} ${p}`).toBe(true); + expect(result.reason).toContain("reserved Windows device name"); + } + }); + + it.each(["D:\\work\\console.txt", "D:\\work\\connect", "D:\\work\\com10", "D:\\work\\nullable.js", "D:\\work\\auxiliary", "D:\\work\\scon"])( + "ordinary name that only looks similar: %s", + (p) => { + expect(denied(p, { access: "write" })).toBe(false); + } + ); + + it.each(["D:\\work\\file.txt::$DATA", "D:\\work\\file.txt:hidden", "D:\\work\\dir::$INDEX_ALLOCATION", "C:\\Windows\\x::$DATA"])( + "NTFS alternate data stream: %s", + (p) => { + for (const access of ["read", "write"]) { + expect(denied(p, { access }), `${access} ${p}`).toBe(true); + } + } + ); + + it.each(["\\\\.\\PhysicalDrive0", "\\\\.\\COM1", "\\\\?\\GLOBALROOT\\Device\\HarddiskVolume1\\x", "\\\\?\\Volume{1234}\\x"])( + "device namespace: %s", + (p) => { + for (const access of ["read", "write"]) { + expect(denied(p, { access }), `${access} ${p}`).toBe(true); + } + } + ); + + it("a colon is only allowed after the drive letter", () => { + expect(denied("D:\\work\\file.txt")).toBe(false); + expect(denied("d:/work/file.txt")).toBe(false); + }); +}); + +describe("more credential stores: causly's own folder, kube, gpg, azure", () => { + it.each([ + "/home/me/.causly/.env", + "/home/me/.causly/causly.db", + "/home/me/.kube/config", + "/home/me/.gnupg/private-keys-v1.d/key.key", + "/home/me/.azure/accessTokens.json", + "/Users/bob/.kube/config", + "C:\\Users\\me\\.causly\\.env", + "C:\\Users\\me\\.kube\\config", + "C:\\Users\\me\\.gnupg\\x", + "C:\\Users\\me\\.azure\\azureProfile.json", + "C:\\Users\\me\\AppData\\Roaming\\gnupg\\secring.gpg", + ])("refuses %s for read and write", (p) => { + for (const access of ["read", "write"]) { + expect(denied(p, { access }), `${access} ${p}`).toBe(true); + } + }); + + it.each(["/home/me/.causly-notes/x", "/home/me/.kubernetes/x", "/home/me/.gnupgx/x", "C:\\Users\\me\\.azure-notes\\x"])( + "does not touch the look-alike %s", + (p) => { + expect(denied(p, { access: "read" })).toBe(false); + } + ); + + it("protects CAUSLY_HOME wherever it points", () => { + const env = { CAUSLY_HOME: "D:\\data\\causly" }; + for (const access of ["read", "write"]) { + expect(denied("D:\\data\\causly\\.env", { env, access })).toBe(true); + expect(denied("D:\\data\\causly\\causly.db", { env, access })).toBe(true); + } + expect(denied("D:\\data\\causly-other\\x", { env, access: "read" })).toBe(false); + expect(denied("/srv/causly-home/.env", { env: { CAUSLY_HOME: "/srv/causly-home" }, access: "read" })).toBe(true); + }); + + it("protects the file named by CAUSLY_ENV_FILE", () => { + const env = { CAUSLY_ENV_FILE: "E:\\keys\\prod.env" }; + expect(denied("E:\\keys\\prod.env", { env, access: "read" })).toBe(true); + expect(denied("E:\\keys\\prod.env", { env, access: "write" })).toBe(true); + expect(denied("E:\\keys\\notes.txt", { env, access: "read" })).toBe(false); + }); + + it("protects the .env next to the code (a git checkout), but not the rest of the folder", () => { + for (const access of ["read", "write"]) { + expect(denied(path.join(PACKAGE_ROOT, ".env"), { access }), access).toBe(true); + } + expect(denied(path.join(PACKAGE_ROOT, ".env.example"), { access: "read" })).toBe(false); + expect(denied(path.join(PACKAGE_ROOT, "package.json"), { access: "write" })).toBe(false); + }); +}); + +describe("input handling", () => { + it("returns not denied for null and non-string input", () => { + expect(isPathDenied(null)).toEqual({ denied: false }); + expect(isPathDenied(undefined)).toEqual({ denied: false }); + expect(isPathDenied(42)).toEqual({ denied: false }); + expect(isPathDenied("")).toEqual({ denied: false }); + }); +}); diff --git a/tests/permissionLevels.test.js b/tests/permissionLevels.test.js new file mode 100644 index 0000000..230e0db --- /dev/null +++ b/tests/permissionLevels.test.js @@ -0,0 +1,130 @@ +import fs from "node:fs"; +import { describe, expect, it } from "vitest"; +import { PACKS } from "../packs/registry.js"; +import { PERMISSION_LEVELS, checkApproval, getPermissionLevel } from "../tools/security.js"; + +// P0-08: every tool has an explicit permission level, risky tools are gated, and a gated tool can +// actually be confirmed (its input schema accepts `confirm`). +const manifest = JSON.parse(fs.readFileSync(new URL("../packs/manifest.json", import.meta.url), "utf8")).packs; +const manifestTools = Object.values(manifest).flatMap((p) => p.tools.map((t) => t.name)); +const META_TOOLS = ["search_tools", "load_toolset", "use_tool"]; // always registered, not in a pack +const GATED = new Set(["HIGH", "DESTRUCTIVE"]); + +describe("explicit levels", () => { + it("every tool in the manifest has an explicit level (the MEDIUM fallback must never be used)", () => { + const missing = manifestTools.filter((name) => !Object.hasOwn(PERMISSION_LEVELS, name)); + expect(missing).toEqual([]); + }); + + it("every meta tool has an explicit level", () => { + for (const name of META_TOOLS) expect(Object.hasOwn(PERMISSION_LEVELS, name), name).toBe(true); + }); + + it("there is no level for a tool that does not exist", () => { + const known = new Set([...manifestTools, ...META_TOOLS]); + expect(Object.keys(PERMISSION_LEVELS).filter((name) => !known.has(name))).toEqual([]); + }); + + it("every level is a known level", () => { + for (const [name, level] of Object.entries(PERMISSION_LEVELS)) { + expect(["READ", "LOW", "MEDIUM", "HIGH", "DESTRUCTIVE"], name).toContain(level); + } + }); + + it("an unknown tool still falls back to MEDIUM (safe default), which is why the test above exists", () => { + expect(getPermissionLevel("some_future_tool", {})).toBe("MEDIUM"); + }); +}); + +describe("levels raised in P0-08", () => { + it.each([ + ["git_push", "HIGH"], + ["git_merge", "HIGH"], + ["secrets_get", "HIGH"], + ["git_stash", "MEDIUM"], + ["git_tag", "MEDIUM"], + ["supabase_create_project", "HIGH"], + ["vercel_cancel_deployment", "HIGH"], + ])("%s is %s", (tool, level) => { + expect(PERMISSION_LEVELS[tool]).toBe(level); + }); + + it.each(["git_push", "git_merge", "secrets_get", "supabase_create_project", "vercel_cancel_deployment"])( + "%s needs confirm: true", + (tool) => { + expect(checkApproval(tool, {}).allowed).toBe(false); + expect(checkApproval(tool, { confirm: false }).allowed).toBe(false); + expect(checkApproval(tool, { confirm: "true" }).allowed).toBe(false); + expect(checkApproval(tool, { confirm: true }).allowed).toBe(true); + } + ); +}); + +describe("per-action levels: confirm only for the destructive action", () => { + it.each([ + ["git_stash", { action: "drop" }], + ["git_stash", { action: "DROP" }], + ["git_stash", { action: " drop " }], + ["git_tag", { action: "delete" }], + ["git_tag", { action: "Delete" }], + ])("%s %j needs confirm: true", (tool, input) => { + expect(getPermissionLevel(tool, input)).toBe("HIGH"); + expect(checkApproval(tool, input).allowed).toBe(false); + expect(checkApproval(tool, { ...input, confirm: true }).allowed).toBe(true); + }); + + it.each([ + ["git_stash", {}], + ["git_stash", { action: "save" }], + ["git_stash", { action: "pop" }], + ["git_stash", { action: "apply" }], + ["git_stash", { action: "list" }], + ["git_tag", {}], + ["git_tag", { action: "list" }], + ["git_tag", { action: "create", tag_name: "v1" }], + ])("%s %j runs without confirm", (tool, input) => { + expect(checkApproval(tool, input).allowed).toBe(true); + expect(checkApproval(tool, input).level).toBe("MEDIUM"); + }); + + it("a missing or odd input does not crash", () => { + expect(getPermissionLevel("git_stash", undefined)).toBe("MEDIUM"); + expect(getPermissionLevel("git_stash", null)).toBe("MEDIUM"); + expect(getPermissionLevel("git_stash", { action: 5 })).toBe("MEDIUM"); + }); +}); + +describe("a gated tool can be confirmed", () => { + // Registers every pack against a stub server and reads each tool's input schema. + async function collectSchemas() { + const schemas = new Map(); + for (const pack of Object.values(PACKS)) { + const stub = { + registerTool(name, config) { + schemas.set(name, config); + }, + }; + (await pack.load()).default(stub); + } + return schemas; + } + + it("every HIGH or DESTRUCTIVE tool accepts `confirm` in its input schema", async () => { + const schemas = await collectSchemas(); + const unconfirmable = []; + for (const [name, config] of schemas) { + const needsConfirm = GATED.has(PERMISSION_LEVELS[name]) || name === "git_stash" || name === "git_tag"; + if (needsConfirm && !(config.inputSchema && "confirm" in config.inputSchema)) unconfirmable.push(name); + } + expect(unconfirmable).toEqual([]); + }); + + it("every HIGH or DESTRUCTIVE tool tells the agent about confirm in its description", async () => { + const schemas = await collectSchemas(); + const silent = []; + for (const [name, config] of schemas) { + if (GATED.has(PERMISSION_LEVELS[name]) && !/confirm/i.test(String(config.description))) silent.push(name); + } + expect(silent).toEqual([]); + }); +}); diff --git a/tests/redaction.log.test.js b/tests/redaction.log.test.js new file mode 100644 index 0000000..d28e01e --- /dev/null +++ b/tests/redaction.log.test.js @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { afterEach, beforeEach, test, vi } from "vitest"; +import { createToolWrapper } from "../tools/toolWrapper.js"; +import * as dbOps from "../tools/dbOps.js"; + +// P0-03: a Postgres call with a password in the connection string must not leak into the log or the returned error. +const PASSWORD = "Sup3rS3cretPw"; +const CONN = `postgres://admin:${PASSWORD}@127.0.0.1:1/appdb`; + +let written; + +beforeEach(() => { + written = []; + vi.spyOn(fs, "appendFileSync").mockImplementation((_file, line) => { + written.push(String(line)); + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function makeWrap() { + return createToolWrapper({ + approvalCheck: () => ({ allowed: true }), + workspaceCheck: () => ({ allowed: true }), + receiptEmitter: () => {}, + envRefresher: () => {}, + errorHinter: () => null, + }); +} + +test("real postgres_query call: password is absent from the log line", async () => { + const tool = makeWrap()("postgres_query", dbOps.postgresQuery); + const result = await tool({ connection_string: CONN, sql: "select 1" }); + assert.equal(result.isError, true); + assert.ok(written.length > 0, "nothing was logged"); + assert.ok(!written.join("").includes(PASSWORD), written.join("")); + assert.ok(!result.content[0].text.includes(PASSWORD)); +}); + +test("an error that echoes the connection string is redacted in the log and in the response", async () => { + const tool = makeWrap()("postgres_query", async ({ connection_string }) => { + throw new Error(`could not connect using ${connection_string}`); + }); + const result = await tool({ connection_string: CONN, sql: "select 1" }); + assert.equal(result.isError, true); + assert.ok(!result.content[0].text.includes(PASSWORD), result.content[0].text); + assert.ok(!written.join("").includes(PASSWORD), written.join("")); +}); diff --git a/tests/redaction.test.js b/tests/redaction.test.js new file mode 100644 index 0000000..8f79881 --- /dev/null +++ b/tests/redaction.test.js @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { describe, test } from "vitest"; +import { redactSecrets, redactSecretsInString } from "../tools/security.js"; + +// P0-03: credentials inside URLs, tokens and connection strings must never reach logs or error text. +const LEAKS = [ + ["postgres URL", "connect failed: postgres://admin:S3cretPw@db.example.com:5432/app", "S3cretPw"], + ["mysql URL", "mysql://root:Tr0ub4dor@localhost:3306/shop timed out", "Tr0ub4dor"], + ["password with @", "postgres://user:p@ss@host/db", "p@ss"], + ["empty user", "redis://:RedisPw99@cache:6379", "RedisPw99"], + ["token as user", "clone https://ghp_abcdefghijklmnopqrstuvwxyz0123456789@github.com/o/r.git", "ghp_abcdefghijklmnopqrstuvwxyz0123456789"], + ["bearer", "request failed, Authorization header was Bearer abcDEF123456xyz", "abcDEF123456xyz"], + ["github token", "bad credentials ghp_abcdefghijklmnopqrstuvwxyz0123456789", "ghp_abcdefghijklmnopqrstuvwxyz0123456789"], + ["github fine-grained", "github_pat_11ABCDEFG0abcdefghijklmnop_qrstuvwxyz", "github_pat_11ABCDEFG0abcdefghijklmnop_qrstuvwxyz"], + ["slack token", "slack said xoxb-1234567890-abcdefghijkl", "xoxb-1234567890-abcdefghijkl"], + ["sk key", "invalid key sk-abcdefghijklmnopqrstuvwx", "sk-abcdefghijklmnopqrstuvwx"], + ["aws key", "denied for AKIAABCDEFGHIJKLMNOP", "AKIAABCDEFGHIJKLMNOP"], + ["jwt", "token eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dBjftJeZ4CVPmB92K27uhbUJU1p1r", "eyJhbGciOiJIUzI1NiJ9"], + ["json password", '{"user":"a","password": "hunter2"}', "hunter2"], + ["json db password", '{"db_password":"hunter2"}', "hunter2"], + ["json connection string", '{"connection_string": "postgres://u:pw1234@h/db"}', "pw1234"], + ["connection_string pair", "connection_string=postgres://u:pw5678@h/db", "pw5678"], +]; + +const UNCHANGED = [ + "https://example.com/path?x=1", + "https://example.com/a@b", + "postgres://localhost:5432/app", + "contact me at user@example.com", + "plain error: file not found", + "see the task-list and desk-lamp docs", +]; + +describe("redactSecretsInString", () => { + for (const [name, input, secret] of LEAKS) { + test(`removes ${name}`, () => { + const out = redactSecretsInString(input); + assert.ok(!out.includes(secret), `leaked: ${out}`); + assert.ok(out.includes("[REDACTED]")); + }); + } + + for (const input of UNCHANGED) { + test(`leaves alone: ${input}`, () => { + assert.equal(redactSecretsInString(input), input); + }); + } + + test("keeps the non-secret parts of a URL", () => { + const out = redactSecretsInString("postgres://admin:S3cretPw@db.example.com:5432/app"); + assert.ok(out.includes("db.example.com:5432/app")); + }); +}); + +describe("redactSecrets (structured)", () => { + test("connection-style keys are redacted by name", () => { + const out = redactSecrets({ + connection_string: "x", + connectionString: "x", + dsn: "x", + database_url: "x", + note: "fine", + }); + assert.deepEqual(out, { + connection_string: "[REDACTED]", + connectionString: "[REDACTED]", + dsn: "[REDACTED]", + database_url: "[REDACTED]", + note: "fine", + }); + }); + + test("credentials inside any string value are removed, even under a harmless key", () => { + const out = redactSecrets({ url: "https://u:pw9999@host/x", args: ["--db", "postgres://u:pw8888@h/d"], note: "fine" }); + const text = JSON.stringify(out); + assert.ok(!text.includes("pw9999") && !text.includes("pw8888"), text); + assert.equal(out.note, "fine"); + }); + + test("a plain string is unchanged", () => { + assert.equal(redactSecrets("plain string"), "plain string"); + }); +}); diff --git a/tests/releaseMetadata.test.js b/tests/releaseMetadata.test.js new file mode 100644 index 0000000..68bd25b --- /dev/null +++ b/tests/releaseMetadata.test.js @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import fs from "node:fs"; + +// P0-12: the package metadata that npm, the MCP Registry and Glama show must be complete and consistent. +const read = (f) => JSON.parse(fs.readFileSync(new URL(`../${f}`, import.meta.url), "utf8")); +const pkg = read("package.json"); +const server = read("server.json"); + +describe("release metadata", () => { + it("package.json points to the website, the repository and the issue tracker", () => { + expect(pkg.homepage).toBe("https://server.causly.in"); + expect(pkg.repository.url).toContain("github.com/KNIHAL/causly-server"); + expect(pkg.bugs.url).toBe("https://github.com/KNIHAL/causly-server/issues"); + expect(pkg.license).toBe("MIT"); + }); + + it("server.json (MCP Registry) has the same version as package.json", () => { + expect(server.version).toBe(pkg.version); + for (const p of server.packages ?? []) { + expect(p.version).toBe(pkg.version); + expect(p.identifier).toBe(pkg.name); + } + expect(server.name).toBe(pkg.mcpName); + }); + + it("the changelog has a section for the current version", () => { + const changelog = fs.readFileSync(new URL("../CHANGELOG.md", import.meta.url), "utf8"); + expect(changelog).toContain(`## [${pkg.version}]`); + }); + + it("a publish workflow with provenance exists", () => { + const wf = fs.readFileSync(new URL("../.github/workflows/publish.yml", import.meta.url), "utf8"); + expect(wf).toContain("npm publish --provenance"); + expect(wf).toContain("id-token: write"); + }); +}); diff --git a/tests/security.test.js b/tests/security.test.js index e88d0ae..cf7ee8b 100644 --- a/tests/security.test.js +++ b/tests/security.test.js @@ -71,11 +71,11 @@ describe("redactSecretsInString", () => { expect(result).toBe("password: [REDACTED]"); }); - it("does not redact JSON-style quoted keys (key wrapped in quotes) — known limitation", () => { - // The regex expects `key:` or `key=` immediately after a word boundary; - // a closing quote right after the key (`"password":`) breaks that match. + it("redacts JSON-style quoted keys (fixed in P0-03) — previously a known limitation", () => { + // Quoted JSON keys are handled by their own pattern; + // the plain key=value pattern alone would miss `"password":`. const result = redactSecretsInString('{"password": "hunter2"}'); - expect(result).toBe('{"password": "hunter2"}'); + expect(result).toBe('{"password": "[REDACTED]"}'); }); it("is case-insensitive", () => { @@ -101,7 +101,7 @@ describe("checkApproval", () => { }); it("allows LOW-level tools without confirm", () => { - const result = checkApproval("git_stash", {}); + const result = checkApproval("git_add", {}); expect(result.allowed).toBe(true); expect(result.level).toBe("LOW"); }); diff --git a/tests/stdout.e2e.test.js b/tests/stdout.e2e.test.js new file mode 100644 index 0000000..c30731a --- /dev/null +++ b/tests/stdout.e2e.test.js @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { spawn } from "node:child_process"; + +// P0-09: stdout carries only JSON-RPC messages. Anything else (a stray console.log, a banner) would +// corrupt the stdio protocol, so log lines must go to stderr. +function runSession(messages, env = {}) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, ["index.js"], { + cwd: process.cwd(), + env: { ...process.env, CAUSLY_TOOLSETS: "all", ...env }, + stdio: ["pipe", "pipe", "pipe"], + }); + let out = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (d) => (out += d)); + child.stderr.resume(); + const expectedResponses = messages.filter((m) => m.id !== undefined).length; + const timer = setTimeout(() => { + child.kill(); + reject(new Error(`timed out; stdout so far: ${out.slice(0, 500)}`)); + }, 40000); + const check = setInterval(() => { + const n = out.split("\n").filter((l) => l.trim().startsWith("{") && /"id"/.test(l)).length; + if (n >= expectedResponses) { + clearInterval(check); + clearTimeout(timer); + child.stdin.end(); + setTimeout(() => { + child.kill(); + resolve(out); + }, 300); + } + }, 100); + for (const m of messages) child.stdin.write(JSON.stringify(m) + "\n"); + }); +} + +describe("stdout hygiene (stdio)", () => { + it("prints only valid JSON-RPC lines, including after a tool error", async () => { + const out = await runSession([ + { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "stdout-test", version: "0" } } }, + { jsonrpc: "2.0", method: "notifications/initialized" }, + { jsonrpc: "2.0", id: 2, method: "tools/list" }, + { jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "read_file", arguments: { path: "Z:\\definitely\\missing\\file.txt" } } }, + { jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: "no_such_tool", arguments: {} } }, + ]); + const lines = out.split(/\r?\n/).filter((l) => l.trim() !== ""); + expect(lines.length).toBeGreaterThanOrEqual(4); + for (const line of lines) { + let msg; + expect(() => (msg = JSON.parse(line)), `not JSON: ${line.slice(0, 120)}`).not.toThrow(); + expect(msg.jsonrpc).toBe("2.0"); + } + }, 60000); +}); diff --git a/tests/upgrade.migration.test.js b/tests/upgrade.migration.test.js new file mode 100644 index 0000000..9b89f9a --- /dev/null +++ b/tests/upgrade.migration.test.js @@ -0,0 +1,148 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { getDb, closeDb } from "../tools/store.js"; +import { listDeals, getDeal, addLead } from "../tools/pipelineOps.js"; +import { listMeetings, listActions } from "../tools/meetingOps.js"; +import { reminderList } from "../tools/reminderOps.js"; +import { secretsGet, secretsSet } from "../tools/secretsOps.js"; +import { readEnvValues } from "../tools/envFile.js"; +import { loadEnv } from "../tools/envLoader.js"; +import { upsertServerEntry, readJson } from "../tools/cliOps.js"; + +// P0-11: upgrade from 1.5.0 and corrupt local files. The fixture database was written by the real +// causly-server 1.5.0 (pipeline deal, meeting with an action item, reminder). Never edit it: add a +// new fixture for a new release instead. +const FIXTURE = new URL("./fixtures/db-1.5.0/causly-1.5.0.db", import.meta.url); +const hasSqlite = await import("node:sqlite").then(() => true, () => false); + +let dir; +beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "causly-upgrade-")); +}); +afterEach(() => { + closeDb(); + delete process.env.CAUSLY_DB_PATH; + delete process.env.CAUSLY_HOME; + delete process.env.SECRETS_MASTER_KEY; + fs.rmSync(dir, { recursive: true, force: true }); +}); + +function tableCounts(file) { + const { DatabaseSync } = require_sqlite(); + const h = new DatabaseSync(file, { readOnly: true }); + try { + const tables = h.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all().map((r) => r.name); + return Object.fromEntries(tables.map((t) => [t, h.prepare(`SELECT COUNT(*) AS n FROM "${t}"`).get().n])); + } finally { + h.close(); + } +} +let sqliteModule; +const require_sqlite = () => sqliteModule; +if (hasSqlite) sqliteModule = await import("node:sqlite"); + +describe.skipIf(!hasSqlite)("upgrade from 1.5.0", () => { + it("keeps pipeline, meetings, actions and reminders intact and keeps working", async () => { + const file = path.join(dir, "causly.db"); + fs.copyFileSync(FIXTURE, file); + const before = tableCounts(file); + expect(before.deals).toBeGreaterThanOrEqual(1); + process.env.CAUSLY_DB_PATH = file; + + const deals = await listDeals({}); + expect(JSON.stringify(deals)).toContain("Fixture deal"); + const first = deals.deals?.[0] ?? deals[0]; + const deal = await getDeal({ deal_id: first.id }); + expect(JSON.stringify(deal)).toContain("Acme Fixture Ltd"); + expect(JSON.stringify(deal)).toContain("proposal"); + + expect(JSON.stringify(await listMeetings({}))).toContain("Kickoff 1.5.0"); + expect(JSON.stringify(await listActions({}))).toContain("Send contract"); + expect(JSON.stringify(await reminderList({}))).toContain("Follow up with Acme"); + + // New code can write next to the old data. + await addLead({ company: "New Co", deal_title: "After upgrade" }); + expect(JSON.stringify(await listDeals({}))).toContain("After upgrade"); + closeDb(); + + const after = tableCounts(file); + for (const [table, n] of Object.entries(before)) expect(after[table], `table ${table} lost rows`).toBeGreaterThanOrEqual(n); + }); + + it("does not re-run migrations that 1.5.0 already applied", async () => { + const file = path.join(dir, "causly.db"); + fs.copyFileSync(FIXTURE, file); + const read = () => { + const h = new sqliteModule.DatabaseSync(file, { readOnly: true }); + try { + return h.prepare("SELECT namespace, version, applied_at FROM schema_migrations ORDER BY namespace, version").all().map((r) => ({ ...r })); + } finally { + h.close(); + } + }; + const old = read(); + process.env.CAUSLY_DB_PATH = file; + await getDb(); + closeDb(); + const now = read(); + for (const row of old) expect(now).toContainEqual(row); + }); +}); + +describe.skipIf(!hasSqlite)("corrupt files give a clear error, not a crash", () => { + it("a corrupt database", async () => { + const file = path.join(dir, "causly.db"); + fs.writeFileSync(file, "this is not a sqlite database ".repeat(200)); + process.env.CAUSLY_DB_PATH = file; + const err = await getDb().then(() => null, (e) => e); + expect(err).toBeInstanceOf(Error); + expect(err.message).toContain(file); + expect(err.message).toMatch(/not a valid|corrupt|damaged/i); + expect(err.message).toMatch(/restore|move|delete|backup/i); + }); + + it("a corrupt .env is read without throwing", () => { + process.env.CAUSLY_HOME = dir; + const garbage = Buffer.concat([Buffer.from([0xff, 0xfe, 0x00, 0x80]), Buffer.from('\nKEY="unterminated\n=novalue\n\u0000\nOK=1\n')]); + fs.writeFileSync(path.join(dir, ".env"), garbage); + expect(() => readEnvValues(path.join(dir, ".env"))).not.toThrow(); + expect(() => loadEnv()).not.toThrow(); + }); + + it("a corrupt secrets file", async () => { + process.env.SECRETS_MASTER_KEY = "a".repeat(64); + const store = path.join(dir, "secrets.enc"); + fs.writeFileSync(store, "garbage, not an encrypted store"); + const err = await secretsGet({ name: "X", store_path: store }).then(() => null, (e) => e); + expect(err).toBeInstanceOf(Error); + expect(err.message.length).toBeGreaterThan(10); + await expect(secretsSet({ name: "X", value: "y", store_path: store, confirm: true })).rejects.toBeInstanceOf(Error); + // The damaged file is left for the user to inspect, not silently replaced. + expect(fs.readFileSync(store, "utf8")).toBe("garbage, not an encrypted store"); + }); +}); + +describe("client config (Claude Desktop, Cursor, Windsurf)", () => { + const entry = { command: "node", args: ["index.js"] }; + + it("adds the server and keeps the other servers", () => { + const file = path.join(dir, "claude_desktop_config.json"); + fs.writeFileSync(file, JSON.stringify({ theme: "dark", mcpServers: { other: { command: "x" } } }, null, 2)); + upsertServerEntry(file, entry); + const json = readJson(file).config; + expect(json.theme).toBe("dark"); + expect(json.mcpServers.other).toEqual({ command: "x" }); + expect(json.mcpServers["causly-server"] ?? Object.values(json.mcpServers).find((s) => s.args?.[0] === "index.js")).toBeTruthy(); + }); + + it("a config that is not valid JSON is never lost: a byte-for-byte backup is kept and the user is told", () => { + const file = path.join(dir, "claude_desktop_config.json"); + const broken = '{ "mcpServers": { "other": { "command": "x" }, '; + fs.writeFileSync(file, broken); + const result = upsertServerEntry(file, entry); + expect(result.replacedBrokenFile).toBe(true); + expect(fs.readFileSync(result.backup, "utf8")).toBe(broken); + expect(() => JSON.parse(fs.readFileSync(file, "utf8"))).not.toThrow(); + });}); diff --git a/tests/vercelOps.test.js b/tests/vercelOps.test.js index 06a7aab..d2b0a41 100644 --- a/tests/vercelOps.test.js +++ b/tests/vercelOps.test.js @@ -10,7 +10,6 @@ import { vercelGetDeploymentLogs, vercelGetDeploymentEvents, vercelCancelDeployment, - httpCheck, } from "../tools/vercelOps.js"; const jsonRes = (data, ok = true, status = 200) => ({ @@ -206,43 +205,3 @@ describe("vercelOps — mutations & misc", () => { expect(result).toEqual({ deployment_id: "d1", state: "CANCELED" }); }); }); - -describe("httpCheck", () => { - it("reports healthy: true for a 2xx response", async () => { - global.fetch.mockResolvedValueOnce({ status: 200 }); - const result = await httpCheck({ url: "https://example.com" }); - expect(result.healthy).toBe(true); - expect(result.status).toBe(200); - expect(typeof result.response_time_ms).toBe("number"); - }); - - it("reports healthy: false for a 5xx response", async () => { - global.fetch.mockResolvedValueOnce({ status: 503 }); - const result = await httpCheck({ url: "https://example.com" }); - expect(result.healthy).toBe(false); - expect(result.status).toBe(503); - }); - - it("reports healthy: true for a 3xx redirect (still < 400)", async () => { - global.fetch.mockResolvedValueOnce({ status: 301 }); - const result = await httpCheck({ url: "https://example.com" }); - expect(result.healthy).toBe(true); - }); - - it("reports a network error via err.message", async () => { - global.fetch.mockRejectedValueOnce(new Error("getaddrinfo ENOTFOUND")); - const result = await httpCheck({ url: "https://nowhere.invalid" }); - expect(result.healthy).toBe(false); - expect(result.status).toBeNull(); - expect(result.error).toBe("getaddrinfo ENOTFOUND"); - }); - - it("reports 'timeout' on an AbortError", async () => { - const abortErr = new Error("The operation was aborted"); - abortErr.name = "AbortError"; - global.fetch.mockRejectedValueOnce(abortErr); - const result = await httpCheck({ url: "https://example.com", timeout_ms: 5000 }); - expect(result.error).toBe("timeout"); - expect(result.healthy).toBe(false); - }); -}); diff --git a/tools/annotations.js b/tools/annotations.js new file mode 100644 index 0000000..0271bc8 --- /dev/null +++ b/tools/annotations.js @@ -0,0 +1,78 @@ +import fs from "fs"; +import { PERMISSION_LEVELS, ACTION_LEVELS } from "./security.js"; + +// MCP tool annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint), derived in one +// place from the permission table. Clients and directories use them to decide how to present and +// approve a tool. They are hints, not enforcement: the approval gate in security.js is what blocks. + +// Packs whose tools talk to a service outside this machine. +const OPEN_WORLD_PACKS = new Set([ + "github", "vercel", "supabase", "database", "sentry", "slack", "gmail", "notion", "terraform", + "research", "leads", "seo", "calendar", "telegram", "linear", "stripe", "discord", "workflow", +]); + +// Individual tools that reach outside, in packs that are otherwise local. +const OPEN_WORLD_TOOLS = new Set([ + "run_command", "use_tool", "git_push", "git_pull", "docker_pull", "docker_push", "docker_build", "meeting_transcribe", +]); + +// Not DESTRUCTIVE in the permission table, but they overwrite or remove existing state. +const OVERWRITING_TOOLS = new Set([ + "write_file", "edit_file", "move_file", "git_reset", "git_merge", "git_push", "run_command", + "terraform_apply", "terraform_state_mv", "terraform_state_rm", "secrets_set", "secrets_rotate_key", + "notion_update_page", "notion_update_block", "supabase_run_sql", "postgres_query", "mysql_query", + "github_merge_pull_request", "docker_exec", +]); + +// Calling twice with the same arguments has the same effect as calling once (only meaningful for +// tools that are not read-only; read-only tools are always reported idempotent). +const IDEMPOTENT_TOOLS = new Set([ + "write_file", "copy_file", "create_directory", "git_init", "git_add", "git_checkout", "secrets_set", + "docker_start", "docker_stop", "docker_compose_up", "pipeline_update", "github_update_issue", + "github_update_pull_request", "calendar_update_event", "linear_update_issue", "notion_update_page", + "notion_update_block", "reminder_update", "meeting_complete_action", "terraform_init", "terraform_fmt", + "terraform_apply", "terraform_untaint", "load_toolset", +]); + +const REMOVES = /(^|_)(delete|remove|destroy|drop)(_|$)/; + +let packByTool = null; +function packOf(toolName) { + if (!packByTool) { + packByTool = new Map(); + try { + const manifest = JSON.parse(fs.readFileSync(new URL("../packs/manifest.json", import.meta.url), "utf8")); + for (const [pack, def] of Object.entries(manifest.packs || {})) for (const t of def.tools) packByTool.set(t.name, pack); + } catch { + // No manifest: openWorldHint falls back to the conservative answer below. + } + } + return packByTool.get(toolName) ?? null; +} + +// use_tool is rated READ because it only forwards, but it can reach any tool, so it must never +// be presented to a client as read-only. +const FORWARDING_TOOLS = new Set(["use_tool"]); + +/** The four annotations for one tool. Every tool gets all four, as booleans. */ +export function toolAnnotations(toolName) { + if (FORWARDING_TOOLS.has(toolName)) { + return { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }; + } + const level = PERMISSION_LEVELS[toolName] || "MEDIUM"; + const readOnly = level === "READ"; + const removes = REMOVES.test(toolName); + const pack = packOf(toolName); + const openWorld = OPEN_WORLD_TOOLS.has(toolName) || (pack ? OPEN_WORLD_PACKS.has(pack) : !PERMISSION_LEVELS[toolName]); + return { + readOnlyHint: readOnly, + destructiveHint: !readOnly && (level === "DESTRUCTIVE" || removes || Object.hasOwn(ACTION_LEVELS, toolName) || OVERWRITING_TOOLS.has(toolName)), + idempotentHint: readOnly || removes || IDEMPOTENT_TOOLS.has(toolName), + openWorldHint: openWorld, + }; +} + +/** A tool config with the derived annotations added. Annotations a pack sets itself win. */ +export function withAnnotations(toolName, config) { + return { ...config, annotations: { ...toolAnnotations(toolName), ...(config?.annotations ?? {}) } }; +} diff --git a/tools/boundaryAttest.js b/tools/boundaryAttest.js index 5723346..1e1b78c 100644 --- a/tools/boundaryAttest.js +++ b/tools/boundaryAttest.js @@ -1,7 +1,7 @@ import crypto from "crypto"; import fs from "fs"; import path from "path"; -import { redactSecrets, redactSecretsInString, PERMISSION_LEVELS } from "./security.js"; +import { redactSecrets, redactSecretsInString, getPermissionLevel } from "./security.js"; export const BOUNDARYATTEST_TOOLS = new Set(["ship_change", "verify_ci_fix", "deploy_project"]); const REQUIRED_CLAIM_FIELDS = ["receipt_version", "receipt_role", "event_id", "timestamp", "action_type", "status"]; @@ -138,7 +138,7 @@ export function emitBoundaryAttestReceipt({ toolName, input, result, error, stat status: status.toLowerCase(), operation_id: operationId, tool_name: toolName, - risk_level: PERMISSION_LEVELS[toolName] || "MEDIUM", + risk_level: getPermissionLevel(toolName, input), input_hash: hashRedacted(input), input_representation: "causly.redacted.jcs.v1", }; diff --git a/tools/commandOps.js b/tools/commandOps.js index 6f56a4b..3ca2d5e 100644 --- a/tools/commandOps.js +++ b/tools/commandOps.js @@ -1,6 +1,7 @@ import { exec, execFile } from "child_process"; import { promisify } from "util"; import os from "os"; +import path from "path"; import { classifyCommandRisk } from "./security.js"; const execAsync = promisify(exec); @@ -42,9 +43,9 @@ function findKey(env, name) { return Object.keys(env).find((k) => k.toUpperCase() === name.toUpperCase()); } -function buildEnv() { - if (!IS_WINDOWS) return process.env; - const env = { ...process.env }; +// Pure and platform-independent (uses path.win32) so it can be tested on any OS. +export function buildWindowsEnv(baseEnv, { execPath = process.execPath, homedir = () => os.homedir() } = {}) { + const env = { ...baseEnv }; // IMPORTANT: always read/write through the env's *actual* existing key // casing (e.g. "Path" vs "PATH"). Setting env.PATH when the real key is @@ -77,22 +78,44 @@ function buildEnv() { // `npm run