diff --git a/integrations/vscode/.vscode-test.js b/integrations/vscode/.vscode-test.cjs similarity index 73% rename from integrations/vscode/.vscode-test.js rename to integrations/vscode/.vscode-test.cjs index 708771a..984e497 100644 --- a/integrations/vscode/.vscode-test.js +++ b/integrations/vscode/.vscode-test.cjs @@ -1,10 +1,10 @@ const { defineConfig } = require("@vscode/test-cli"); module.exports = defineConfig({ - files: "out/test/**/*.test.js", + files: "out/test/**/*.test.cjs", version: "1.121.0", workspaceFolder: "./src/test/fixtures", mocha: { - timeout: 10000, - }, + timeout: 10000 + } }); diff --git a/integrations/vscode/.vscodeignore b/integrations/vscode/.vscodeignore index 43ed241..daa1f24 100644 --- a/integrations/vscode/.vscodeignore +++ b/integrations/vscode/.vscodeignore @@ -4,4 +4,7 @@ tsconfig.json *.map node_modules/ tsup.config.ts -*.patch \ No newline at end of file +*.patch +.vscode-test.cjs +tsconfig.test.json +tsdown.config.ts \ No newline at end of file diff --git a/integrations/vscode/CHANGELOG.md b/integrations/vscode/CHANGELOG.md index b64a7eb..0a394ae 100644 --- a/integrations/vscode/CHANGELOG.md +++ b/integrations/vscode/CHANGELOG.md @@ -6,6 +6,33 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [1.0.25] + +### Added + +- **`.tenure.json` configuration format** (`tenureConfig.ts`): Introduced a new JSON-based project configuration file supporting `projectId`, `ignore`, and `noiseIgnores` fields alongside the legacy plain-text `.tenure` file. The JSON format is preferred when both files exist, with automatic fallback to `.tenure`. +- **`generateDefaultTenureConfig()`** (`tenureConfig.ts`): Added a utility function that produces a pretty-printed `.tenure.json` template with empty `ignore` and `noiseIgnores` arrays, used when prompting users to create a new config file. +- **File policy module** (`filePolicy.ts`): Added `getTenureFilePolicy()` and `getTenureFilePolicyForPath()` to support per-file metadata and content suppression rules, preventing sensitive files from leaking active file paths, language IDs, edit events, or content to the Tenure proxy. +- **`.tenure.json` file watcher** (`extension.ts`): Added a dedicated filesystem watcher for `.tenure.json` changes that invalidates the sync cache and triggers a re-sync, alongside the existing `.tenure` watcher. + +### Changed + +- **Version and display name** (`package.json`, `package-lock.json`): Bumped extension version to `1.0.25`, updated the display name to "Tenure", added `language-models` to keywords, set `"type": "module"`, and added `bugs` and `homepage` fields. +- **Build configuration** (`package.json`, `tsdown.config.ts`): Replaced the separate `build:test` npm script with a unified tsdown config that handles both extension and test builds. The test command now references `.vscode-test.cjs` instead of the removed `.vscode-test.js`. +- **Onboarding prompt wording** (`workspaceSync.ts`): Updated the missing config file prompt to reference `.tenure.json` instead of `.tenure`, now creating a JSON document with `generateDefaultTenureConfig()` rather than a plain-text file. +- **File metadata suppression in workspace sync** (`workspaceSync.ts`): Integrated file policy checks into `buildWorkspaceState()` and `sendActiveFileUpdate()` so that files matching suppression rules omit their path and language from workspace state payloads. +- **File metadata suppression in beliefs provider** (`beliefsViewProvider.ts`): Added file policy checks to `sendFileMeta()`, `sendFileEdited()`, and the belief record submission flow, suppressing metadata and content for files covered by suppression rules. +- **Active editor change handling** (`extension.ts`): Updated the `onDidChangeActiveTextEditor` handler to respect file policy suppression, skipping active file propagation for suppressed files. +- **README update** (`README.md`): Revised the project description, feature descriptions, and drift explanation to emphasize Tenure as a governed context layer rather than just persistent memory. + +### Removed + +- **`.vscode-test.js`** (`integrations/vscode/.vscode-test.js`): Deleted the legacy test runner configuration file, replaced by `.vscode-test.cjs`. +- **Unused `@emnapi/core` and `@emnapi/runtime` packages** (`package-lock.json`): Removed these optional dependencies from the lockfile. +- **Redundant platform metadata** (`package-lock.json`): Stripped `libc` annotations from optional native dependency entries (`@emnapi/wasi-threads` platform variants). + +--- + ## [1.0.24] ### Added diff --git a/integrations/vscode/README.md b/integrations/vscode/README.md index d2cbad4..d24ae82 100644 --- a/integrations/vscode/README.md +++ b/integrations/vscode/README.md @@ -1,8 +1,8 @@ # Tenure -> Persistent, governable state for AI systems. Fully local, fully private, fully automatic. Bring your own key and use Tenure directly inside VS Code's native chat interface. No Copilot subscription required. +> The governed context layer for AI coding agents. Tenure preserves project decisions, constraints, and team conventions across tools, sessions, and interfaces, locally, privately, and automatically. -## BYOK. Your models. Your memory. +## BYOK. Your models. Governed project context. Tenure registers as a native language model provider in VS Code. Connect your own API key during setup, pick from any model your provider offers, and those models appear directly in the Copilot Chat picker. No subscription to GitHub Copilot. No third-party billing. Just your key, your models, and your context. @@ -12,7 +12,7 @@ Supported providers include OpenAI, Anthropic, AWS Bedrock, local models, and an You spend an hour in OpenWebUI thinking through an architecture problem. You explore options, rule some out, and land on a direction. Then you open VS Code to start building. -Tenure is already there. It knows what you decided, what you rejected, and why. You do not re-explain anything. You just build. +Tenure is already there. It carries forward the decision state: what you chose, what you rejected, why it mattered, and which project it belongs to. You do not re-explain anything. You just build. This works because Tenure runs as a local proxy outside any single tool. OpenWebUI, LibreChat, Cline, Continue, Windsurf, and any OpenAI-compatible client connect through `localhost:5757`. The VS Code extension brings your IDE into the same memory layer, and because it registers as a native language model provider, Tenure appears directly in Copilot Chat with no manual configuration. @@ -35,13 +35,13 @@ If Docker Desktop is not running, the extension will prompt you to start it. If - **Pushes workspace context** on every file switch so the proxy resolves the right project scope before your first message. - **Auto-configures other extensions** when possible (for example, Continue) and shows copy-paste instructions for the rest. -## How it solves drift +## Why governed context beats copied instructions Beyond cross-interface continuity, Tenure fixes the way AI coding sessions break through drift. A script gets renamed in `package.json`. A config file moves. A rule you wrote for Cline never makes it into the equivalent for Windsurf. The agent works from whatever it was last told, and what it was last told is increasingly wrong. The deeper issue is duplication. Anything that copies information already in your code or config will go stale. Anything that points to that information tends to stay correct. Most `AGENTS.md` files are full of copies. -Tenure does not duplicate. It learns. +Tenure does not treat project context as another file to copy. It keeps decision state in the proxy layer and resolves the right scope before the model responds. ## Which clients work with Tenure? diff --git a/integrations/vscode/package-lock.json b/integrations/vscode/package-lock.json index a25f831..ad39b49 100644 --- a/integrations/vscode/package-lock.json +++ b/integrations/vscode/package-lock.json @@ -1,12 +1,12 @@ { "name": "tenure-vscode", - "version": "1.0.24", + "version": "1.0.25", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tenure-vscode", - "version": "1.0.24", + "version": "1.0.25", "license": "MIT", "devDependencies": { "@types/jest": "^30.0.0", @@ -310,29 +310,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@emnapi/core": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz", - "integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz", - "integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", @@ -656,9 +633,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -676,9 +650,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -696,9 +667,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -716,9 +684,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -736,9 +701,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -756,9 +718,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1254,6 +1213,7 @@ "integrity": "sha512-VVER7vFUDdfm5k3jbH5765tVEJa7+0rTUkFeXyGYrXPxpw9BIjA0QDxdtdlRyaU8MCZV9IKZUo6doxeAQRAjPg==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsgo": "bin/tsgo.js" }, @@ -5670,6 +5630,7 @@ "integrity": "sha512-IN750c0p+s3jqJIsFLRZrQazmbAB1kkQDTtQjSt/gbS2ywLhlv4R5Shazer0FZKmuo/BsO3/w2UoYnUjuOZqHg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@oxc-project/types": "=0.135.0", "@rolldown/pluginutils": "^1.0.0" @@ -6526,6 +6487,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6734,6 +6696,7 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6771,9 +6734,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { diff --git a/integrations/vscode/package.json b/integrations/vscode/package.json index ed4e0f8..587f51e 100644 --- a/integrations/vscode/package.json +++ b/integrations/vscode/package.json @@ -1,8 +1,8 @@ { "name": "tenure-vscode", - "displayName": "Tenure for VSCode", + "displayName": "Tenure", "description": "Persistent AI memory that follows you across every tool, session, and interface. Fully local.", - "version": "1.0.24", + "version": "1.0.25", "publisher": "tenureai", "license": "MIT", "icon": "resources/tenure-logo-icon.png", @@ -39,8 +39,13 @@ "world model", "local ai", "openai", - "byok" + "byok", + "language-models" ], + "bugs": { + "url": "https://github.com/tenurehq/tenure/issues" + }, + "homepage": "https://tenureai.dev", "activationEvents": [ "onStartupFinished" ], @@ -157,11 +162,11 @@ ], "scripts": { "build": "tsdown", - "build:test": "tsdown 'src/test/**/*.ts' --out-dir out/test --format cjs --out-extension .js --no-splitting --deps.neverBundle vscode", + "build:test": "tsdown --config tsdown.config.ts", "watch": "tsc -watch -p tsconfig.json", "package": "npm run build && vsce package", "publish": "vsce publish", - "test": "npm run build && npm run build:test && vscode-test" + "test": "npm run build && vscode-test --config .vscode-test.cjs" }, "devDependencies": { "@types/jest": "^30.0.0", diff --git a/integrations/vscode/src/beliefsViewProvider.ts b/integrations/vscode/src/beliefsViewProvider.ts index d45b257..7c39b60 100644 --- a/integrations/vscode/src/beliefsViewProvider.ts +++ b/integrations/vscode/src/beliefsViewProvider.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode"; import type { TokenStore } from "./tokenStore.js"; import WebSocket from "ws"; +import { getTenureFilePolicyForPath } from "./filePolicy.js"; interface BeliefSummary { id: string; @@ -255,11 +256,23 @@ export class TenureBeliefsViewProvider implements vscode.WebviewViewProvider { .slice(0, 5) .join("_"); - const activeFile = + const rawActiveFile = activeFileOverride !== undefined ? activeFileOverride : this.currentActiveFile; + let safeActiveFile: string | null = rawActiveFile ?? null; + let safeActiveLanguage: string | null = + this.pendingWorkspaceState?.active_language ?? null; + + if (rawActiveFile) { + const policy = getTenureFilePolicyForPath(rawActiveFile); + if (policy.suppressMetadata) { + safeActiveFile = null; + safeActiveLanguage = null; + } + } + this.ensureConnected() .then(() => { this.send({ @@ -269,8 +282,8 @@ export class TenureBeliefsViewProvider implements vscode.WebviewViewProvider { why_it_matters: whyItMatters, scope, canonical_name: canonicalName, - active_file: activeFile, - active_language: this.pendingWorkspaceState?.active_language ?? null, + active_file: safeActiveFile, + active_language: safeActiveLanguage, project_scope: this.currentScope }); }) @@ -297,10 +310,14 @@ export class TenureBeliefsViewProvider implements vscode.WebviewViewProvider { } sendFileMeta(path: string, sizeBytes: number): void { + const policy = getTenureFilePolicyForPath(path); + if (policy.suppressContent) return; this.send({ type: "file_meta", path, size_bytes: sizeBytes }); } sendFileEdited(path: string, projectScope: string): void { + const policy = getTenureFilePolicyForPath(path); + if (policy.suppressContent) return; this.send({ type: "file_edited", path, project_scope: projectScope }); } diff --git a/integrations/vscode/src/extension.ts b/integrations/vscode/src/extension.ts index 932b187..143f53d 100644 --- a/integrations/vscode/src/extension.ts +++ b/integrations/vscode/src/extension.ts @@ -19,6 +19,7 @@ import { } from "./tenureInstaller.js"; import { injectContinueConfig } from "./clientIntegrations.js"; import { detectHostApp } from "./hostEnvironment.js"; +import { getTenureFilePolicy } from "./filePolicy.js"; export async function activate( context: vscode.ExtensionContext @@ -345,24 +346,25 @@ export async function activate( } const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - const tenureWatcher = workspaceFolder - ? vscode.workspace.createFileSystemWatcher( - new vscode.RelativePattern(workspaceFolder, ".tenure") - ) - : null; - - if (tenureWatcher) { + const setupConfigWatcher = (pattern: string) => { + if (!workspaceFolder) return; + const watcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(workspaceFolder, pattern) + ); const handleConfigMutation = () => { sync.invalidateCache(); sync.scheduleSync(); }; context.subscriptions.push( - tenureWatcher.onDidChange(handleConfigMutation), - tenureWatcher.onDidCreate(handleConfigMutation), - tenureWatcher.onDidDelete(handleConfigMutation), - tenureWatcher + watcher.onDidChange(handleConfigMutation), + watcher.onDidCreate(handleConfigMutation), + watcher.onDidDelete(handleConfigMutation), + watcher ); - } + }; + + setupConfigWatcher(".tenure.json"); + setupConfigWatcher(".tenure"); context.subscriptions.push( vscode.commands.registerCommand("tenure.startInstall", async () => { @@ -448,7 +450,10 @@ export async function activate( }), vscode.window.onDidChangeActiveTextEditor((editor) => { - if (editor?.document.uri && editor.document.uri.scheme === "file") { + if (editor?.document.uri) { + const policy = getTenureFilePolicy(editor.document.uri); + if (policy.suppressMetadata) return; + const relativePath = vscode.workspace.asRelativePath( editor.document.uri, true diff --git a/integrations/vscode/src/filePolicy.ts b/integrations/vscode/src/filePolicy.ts new file mode 100644 index 0000000..627d262 --- /dev/null +++ b/integrations/vscode/src/filePolicy.ts @@ -0,0 +1,617 @@ +import * as vscode from "vscode"; +import * as fs from "node:fs"; +import path from "node:path"; + +/** + * Security deny patterns suppress both content and metadata. + * These are intentionally hard defaults: user/project config can add to this + * list, but should not casually remove these protections. + */ +const SECURITY_DENY_PATTERNS = [ + // Environment and generic secret files + "**/.env", + "**/.env.*", + "**/.envrc", + "**/.secrets", + "**/.secrets.*", + "**/credentials", + "**/credentials.*", + "**/secrets/**", + "**/secret/**", + + // Private keys, certificates, keystores + "**/private.key", + "**/*.key", + "**/*.p8", + "**/*.p12", + "**/*.pfx", + "**/*.pem", + "**/*.crt", + "**/*.cer", + "**/*.der", + "**/*.jks", + "**/*.keystore", + "**/id_rsa", + "**/id_dsa", + "**/id_ecdsa", + "**/id_ed25519", + "**/*_rsa", + "**/*_dsa", + "**/*_ecdsa", + "**/*_ed25519", + + // Cloud, package, registry, deploy, and local auth config + "**/*-key.json", + "**/service-account.json", + "**/serviceAccountKey.json", + "**/.npmrc", + "**/.yarnrc", + "**/.pypirc", + "**/.netrc", + "**/.docker/config.json", + "**/.git-credentials", + "**/.gitconfig", + "**/.ssh/**", + "**/.gnupg/**", + "**/.aws/**", + "**/.azure/**", + "**/.config/gcloud/**", + "**/.kube/**", + "**/kubeconfig", + "**/.sentryclirc", + "**/.sentryclirc.*", + "**/.vercel/**", + "**/.netlify/**", + "**/.railway/**", + "**/.supabase/**", + "**/google-services.json", + "**/GoogleService-Info.plist", + + // Infrastructure state and variable files + "**/*.tfstate", + "**/*.tfstate.*", + "**/*.tfvars", + "**/*.tfvars.json", + "**/.terraform.lock.hcl", + + // Local docker overrides frequently contain credentials + "**/docker-compose.override.yml", + "**/docker-compose.*.yml" +]; + +/** + * Noise deny patterns suppress content but allow coarse metadata by default. + * These reduce payload size and prevent generated/dependency artifacts from + * being treated as authored project context. + */ +const NOISE_DENY_PATTERNS = [ + // Dependency and generated folders + "**/node_modules/**", + "**/vendor/**", + "**/.git/**", + "**/dist/**", + "**/build/**", + "**/.next/**", + "**/out/**", + "**/target/**", + "**/coverage/**", + "**/.turbo/**", + "**/.parcel-cache/**", + "**/.cache/**", + + // Python and general language caches + "**/__pycache__/**", + "**/*.pyc", + "**/.venv/**", + "**/venv/**", + "**/env/**", + "**/.mypy_cache/**", + "**/.pytest_cache/**", + "**/.ruff_cache/**", + + // JVM, mobile, and platform caches + "**/.gradle/**", + "**/.m2/**", + "**/Pods/**", + "**/DerivedData/**", + + // Terraform plugin/cache directory + "**/.terraform/**", + + // Logs and local data artifacts + "**/*.log", + "**/logs/**", + "**/*.sqlite", + "**/*.sqlite3", + "**/*.db", + "**/*.dump", + "**/*.dump.sql", + "**/dump/**/*.sql", + "**/dumps/**/*.sql", + "**/backup/**/*.sql", + "**/backups/**/*.sql", + "**/*.bak", + "**/*.backup", + + // Lock files and local IDE/system files + "**/package-lock.json", + "**/yarn.lock", + "**/pnpm-lock.yaml", + "**/Gemfile.lock", + "**/Cargo.lock", + "**/.vscode/settings.json", + "**/.idea/**", + "**/.DS_Store", + "**/Thumbs.db" +]; + +const SECURITY_BASENAME_DENY_PATTERNS = [ + ".env", + ".env.*", + ".envrc", + ".npmrc", + ".yarnrc", + ".pypirc", + ".netrc", + ".git-credentials", + ".sentryclirc", + "credentials", + "credentials.*", + "private.key", + "id_rsa", + "id_dsa", + "id_ecdsa", + "id_ed25519", + "service-account.json", + "serviceAccountKey.json", + "kubeconfig" +]; + +const SUPPORTED_URI_SCHEMES = new Set(["file", "vscode-remote"]); + +type PolicyDecision = "allow" | "suppress_content" | "suppress_all"; +type PolicyCategory = + | "allowed" + | "security" + | "noise" + | "unsupported_scheme" + | "outside_workspace" + | "no_workspace"; + +export interface TenureFilePolicy { + decision: PolicyDecision; + category: PolicyCategory; + reason?: string; + suppressContent: boolean; + suppressMetadata: boolean; + + /** + * Backwards-compatible field for existing callers. Prefer decision/category + * in new code because noise files may suppress content while still allowing + * metadata. + */ + ignored: boolean; +} + +export type IgnoreDecision = TenureFilePolicy; + +interface TenureFileConfig { + projectId?: string; + ignore?: unknown; + noiseIgnores?: unknown; +} + +interface WorkspacePatternSet { + security: string[]; + noise: string[]; +} + +interface CompiledPatternSet { + key: string; + regexes: RegExp[]; +} + +const workspacePatternCache = new Map(); +const compiledPatternCache = new Map(); + +function allow(reason?: string): TenureFilePolicy { + return { + decision: "allow", + category: "allowed", + reason, + suppressContent: false, + suppressMetadata: false, + ignored: false + }; +} + +function suppressAll( + category: Exclude, + reason: string +): TenureFilePolicy { + return { + decision: "suppress_all", + category, + reason, + suppressContent: true, + suppressMetadata: true, + ignored: true + }; +} + +function suppressContent(reason: string): TenureFilePolicy { + return { + decision: "suppress_content", + category: "noise", + reason, + suppressContent: true, + suppressMetadata: false, + ignored: true + }; +} + +function normalizePath(raw: string): string { + return raw.replace(/\\/g, "/").replace(/\/+/g, "/"); +} + +function normalizePattern(pattern: string): string | null { + const trimmed = pattern.trim(); + if (!trimmed || trimmed.startsWith("#")) return null; + return normalizePath(trimmed); +} + +function uniquePatterns(patterns: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const raw of patterns) { + const normalized = normalizePattern(raw); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + out.push(normalized); + } + return out; +} + +function globToRegex(glob: string): RegExp { + let out = ""; + let i = 0; + + while (i < glob.length) { + const ch = glob[i]; + const next = glob[i + 1]; + const afterNext = glob[i + 2]; + + if (ch === "*" && next === "*") { + if (afterNext === "/") { + out += "(?:.*/)?"; + i += 3; + } else { + out += ".*"; + i += 2; + } + continue; + } + + if (ch === "*") { + out += "[^/]*"; + i++; + continue; + } + + if (ch === "?") { + out += "[^/]"; + i++; + continue; + } + + if (/[.+^${}()|[\]\\]/.test(ch)) { + out += "\\" + ch; + i++; + continue; + } + + out += ch; + i++; + } + + return new RegExp(`^${out}$`, "i"); +} + +function getCompiledPatterns(cacheKey: string, patterns: string[]): RegExp[] { + const patternKey = patterns.join("\n"); + const cached = compiledPatternCache.get(cacheKey); + if (cached?.key === patternKey) return cached.regexes; + + const regexes = patterns.map(globToRegex); + compiledPatternCache.set(cacheKey, { key: patternKey, regexes }); + return regexes; +} + +function matchesCompiled(value: string, regexes: RegExp[]): boolean { + for (const re of regexes) { + if (re.test(value)) return true; + } + return false; +} + +function getConfigPatterns(name: string): string[] { + const value = vscode.workspace.getConfiguration("tenure").get(name); + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === "string"); +} + +function getEffectiveSecurityPatterns(): string[] { + return uniquePatterns([ + ...SECURITY_DENY_PATTERNS, + ...SECURITY_BASENAME_DENY_PATTERNS, + ...getConfigPatterns("securityIgnorePatterns"), + ...getConfigPatterns("ignorePatterns") + ]); +} + +function getEffectiveNoisePatterns(): string[] { + return uniquePatterns([ + ...NOISE_DENY_PATTERNS, + ...getConfigPatterns("noiseIgnorePatterns") + ]); +} + +function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === "string"); +} + +function parseTenureConfig(text: string): WorkspacePatternSet { + const trimmed = text.trim(); + if (!trimmed) return { security: [], noise: [] }; + + try { + const parsed = JSON.parse(trimmed) as TenureFileConfig; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { security: [], noise: [] }; + } + + return { + security: uniquePatterns(asStringArray(parsed.ignore)), + noise: uniquePatterns(asStringArray(parsed.noiseIgnores)) + }; + } catch { + return { security: [], noise: [] }; + } +} + +function readTextFileSync(filePath: string): string | null { + try { + return fs.readFileSync(filePath, "utf8"); + } catch { + return null; + } +} + +function loadWorkspacePatternsSync( + workspaceRootUri: vscode.Uri +): WorkspacePatternSet { + const rootPath = workspaceRootUri.fsPath; + const cached = workspacePatternCache.get(rootPath); + if (cached) return cached; + + const tenureJsonText = readTextFileSync(path.join(rootPath, ".tenure.json")); + const tenurePatterns = tenureJsonText + ? parseTenureConfig(tenureJsonText) + : { security: [], noise: [] }; + + const loaded = { + security: uniquePatterns([...tenurePatterns.security]), + noise: uniquePatterns([...tenurePatterns.noise]) + }; + + workspacePatternCache.set(rootPath, loaded); + return loaded; +} + +export function invalidateTenureFilePolicyCache( + workspaceRootUri?: vscode.Uri +): void { + if (workspaceRootUri) { + workspacePatternCache.delete(workspaceRootUri.fsPath); + } else { + workspacePatternCache.clear(); + } + compiledPatternCache.clear(); +} + +function resolveWorkspaceFolder( + uri: vscode.Uri +): vscode.WorkspaceFolder | null { + return vscode.workspace.getWorkspaceFolder(uri) ?? null; +} + +function resolveWorkspaceRoot( + uri?: vscode.Uri, + workspaceRootUri?: vscode.Uri +): vscode.Uri | null { + if (workspaceRootUri) return workspaceRootUri; + if (uri) { + const folder = resolveWorkspaceFolder(uri); + if (folder) return folder.uri; + } + return vscode.workspace.workspaceFolders?.[0]?.uri ?? null; +} + +function relativePathForUri( + uri: vscode.Uri, + workspaceRootUri?: vscode.Uri +): string | null { + const root = resolveWorkspaceRoot(uri, workspaceRootUri); + if (!root) return null; + + if (uri.fsPath && root.fsPath) { + const relative = path.relative(root.fsPath, uri.fsPath); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + return null; + } + return normalizePath(relative); + } + + const relative = vscode.workspace.asRelativePath(uri, false); + if (!relative || path.isAbsolute(relative)) return null; + return normalizePath(relative); +} + +function buildPatternSets(workspaceRootUri?: vscode.Uri): { + security: RegExp[]; + securityBasename: RegExp[]; + noise: RegExp[]; +} { + const workspacePatterns = workspaceRootUri + ? loadWorkspacePatternsSync(workspaceRootUri) + : { security: [], noise: [] }; + + const securityPatterns = uniquePatterns([ + ...getEffectiveSecurityPatterns(), + ...workspacePatterns.security + ]); + const noisePatterns = uniquePatterns([ + ...getEffectiveNoisePatterns(), + ...workspacePatterns.noise + ]); + + return { + security: getCompiledPatterns("security", securityPatterns), + securityBasename: getCompiledPatterns( + "security:basename", + uniquePatterns(SECURITY_BASENAME_DENY_PATTERNS) + ), + noise: getCompiledPatterns("noise", noisePatterns) + }; +} + +function evaluateRelativePath( + relativePath: string, + workspaceRootUri?: vscode.Uri +): TenureFilePolicy { + const normalized = normalizePath(relativePath); + if (!normalized) return allow("No path provided"); + + const basename = normalizePath(path.basename(normalized)); + const segments = normalized.split("/").filter(Boolean); + const patternSets = buildPatternSets(workspaceRootUri); + + if (matchesCompiled(normalized, patternSets.security)) { + return suppressAll("security", "Matched security deny pattern"); + } + + if (matchesCompiled(basename, patternSets.securityBasename)) { + return suppressAll("security", "Matched security basename deny pattern"); + } + + if (matchesCompiled(normalized, patternSets.noise)) { + return suppressContent("Matched noise deny pattern"); + } + + // Segment guards are cheap defense-in-depth in case a glob is accidentally + // removed or changed later. + if (segments.includes("node_modules") || segments.includes(".git")) { + return suppressContent("Matched generated/dependency path segment"); + } + + if ( + segments.includes(".ssh") || + segments.includes(".aws") || + segments.includes(".azure") || + segments.includes(".kube") || + segments.includes(".gnupg") + ) { + return suppressAll("security", "Matched sensitive config path segment"); + } + + return allow(); +} + +export function getTenureFilePolicy( + uri: vscode.Uri, + workspaceRootUri?: vscode.Uri +): TenureFilePolicy { + if (!SUPPORTED_URI_SCHEMES.has(uri.scheme)) { + return suppressAll( + "unsupported_scheme", + `Unsupported URI scheme: ${uri.scheme}` + ); + } + + const root = resolveWorkspaceRoot(uri, workspaceRootUri); + if (!root) { + return suppressAll("no_workspace", "No workspace folder"); + } + + const relativePath = relativePathForUri(uri, root); + if (!relativePath) { + return suppressAll("outside_workspace", "Outside workspace"); + } + + return evaluateRelativePath(relativePath, root); +} + +export function getTenureFilePolicyForPath( + relativePath: string, + workspaceRootUri?: vscode.Uri +): TenureFilePolicy { + return evaluateRelativePath(relativePath, workspaceRootUri); +} + +const SECRET_REDACTIONS: Array<{ re: RegExp; replacement: string }> = [ + { + re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, + replacement: "[REDACTED_PRIVATE_KEY]" + }, + { re: /\bAKIA[0-9A-Z]{16}\b/g, replacement: "[REDACTED_AWS_ACCESS_KEY]" }, + { + re: /\bASIA[0-9A-Z]{16}\b/g, + replacement: "[REDACTED_AWS_TEMP_ACCESS_KEY]" + }, + { + re: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g, + replacement: "[REDACTED_GITHUB_TOKEN]" + }, + { re: /\bsk-[A-Za-z0-9_-]{20,}\b/g, replacement: "[REDACTED_API_KEY]" }, + { + re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g, + replacement: "[REDACTED_ANTHROPIC_KEY]" + }, + { + re: /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/g, + replacement: "[REDACTED_SLACK_TOKEN]" + }, + { + re: /\b(?:pk|sk)_(?:live|test)_[A-Za-z0-9]{20,}\b/g, + replacement: "[REDACTED_STRIPE_KEY]" + }, + { + re: /\bpostgres(?:ql)?:\/\/[^\s:@]+:[^\s@]+@[^\s]+/gi, + replacement: "postgres://[REDACTED_CREDENTIALS]@[REDACTED_HOST]" + }, + { + re: /\bmysql:\/\/[^\s:@]+:[^\s@]+@[^\s]+/gi, + replacement: "mysql://[REDACTED_CREDENTIALS]@[REDACTED_HOST]" + }, + { + re: /\bmongodb(?:\+srv)?:\/\/[^\s:@]+:[^\s@]+@[^\s]+/gi, + replacement: "mongodb://[REDACTED_CREDENTIALS]@[REDACTED_HOST]" + }, + { + re: /\bBearer\s+[A-Za-z0-9._~+/=-]{24,}\b/g, + replacement: "Bearer [REDACTED_TOKEN]" + }, + { + re: /\b[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{20,}\b/g, + replacement: "[REDACTED_JWT]" + } +]; + +export function redactSensitiveText(text: string): string { + let redacted = text; + for (const { re, replacement } of SECRET_REDACTIONS) { + redacted = redacted.replace(re, replacement); + } + return redacted; +} diff --git a/integrations/vscode/src/tenureConfig.ts b/integrations/vscode/src/tenureConfig.ts index ca116fe..6b86f7a 100644 --- a/integrations/vscode/src/tenureConfig.ts +++ b/integrations/vscode/src/tenureConfig.ts @@ -1,15 +1,74 @@ import * as vscode from "vscode"; +export interface TenureFileConfig { + projectId: string; + ignore?: string[]; + noiseIgnores?: string[]; +} + export async function readTenureConfig( - workspaceRootUri: vscode.Uri, -): Promise<{ projectId: string } | null> { - const fileUri = vscode.Uri.joinPath(workspaceRootUri, ".tenure"); + workspaceRootUri: vscode.Uri +): Promise { + const jsonConfig = await readTenureJsonConfig(workspaceRootUri); + if (jsonConfig) return jsonConfig; + + const projectId = await readLegacyTenureFile(workspaceRootUri); + if (projectId) return { projectId }; + + return null; +} + +async function readTenureJsonConfig( + workspaceRootUri: vscode.Uri +): Promise { + const fileUri = vscode.Uri.joinPath(workspaceRootUri, ".tenure.json"); try { const rawContent = await vscode.workspace.fs.readFile(fileUri); const text = new TextDecoder().decode(rawContent).trim(); - return text ? { projectId: text } : null; + if (!text) return null; + + const parsed = JSON.parse(text); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + + const projectId = + typeof parsed.projectId === "string" ? parsed.projectId.trim() : null; + if (!projectId) return null; + + return { + projectId, + ignore: Array.isArray(parsed.ignore) + ? parsed.ignore.filter((i): i is string => typeof i === "string") + : undefined, + noiseIgnores: Array.isArray(parsed.noiseIgnores) + ? parsed.noiseIgnores.filter((i): i is string => typeof i === "string") + : undefined + }; } catch { return null; } } + +async function readLegacyTenureFile( + workspaceRootUri: vscode.Uri +): Promise { + const fileUri = vscode.Uri.joinPath(workspaceRootUri, ".tenure"); + try { + const rawContent = await vscode.workspace.fs.readFile(fileUri); + const text = new TextDecoder().decode(rawContent).trim(); + return text || null; + } catch { + return null; + } +} + +export function generateDefaultTenureConfig(projectName: string): string { + const config: TenureFileConfig = { + projectId: projectName, + ignore: [], + noiseIgnores: [] + }; + return JSON.stringify(config, null, 2) + "\n"; +} diff --git a/integrations/vscode/src/test/suite/filePolicy.test.ts b/integrations/vscode/src/test/suite/filePolicy.test.ts new file mode 100644 index 0000000..4990e51 --- /dev/null +++ b/integrations/vscode/src/test/suite/filePolicy.test.ts @@ -0,0 +1,586 @@ +import * as assert from "assert"; +import * as path from "path"; +import * as fs from "fs"; +import * as os from "os"; +import { + getTenureFilePolicyForPath, + redactSensitiveText +} from "../../filePolicy.js"; + +function normalizePath(raw: string): string { + return raw.replace(/\\/g, "/").replace(/\/+/g, "/"); +} + +function globToRegex(glob: string): RegExp { + let out = ""; + let i = 0; + + while (i < glob.length) { + const ch = glob[i]; + const next = glob[i + 1]; + const afterNext = glob[i + 2]; + + if (ch === "*" && next === "*") { + if (afterNext === "/") { + out += "(?:.*/)?"; + i += 3; + } else { + out += ".*"; + i += 2; + } + continue; + } + + if (ch === "*") { + out += "[^/]*"; + i++; + continue; + } + + if (ch === "?") { + out += "[^/]"; + i++; + continue; + } + + if (/[.+^${}()|[\]\\]/.test(ch)) { + out += "\\" + ch; + i++; + continue; + } + + out += ch; + i++; + } + + return new RegExp(`^${out}$`, "i"); +} + +function uniquePatterns(patterns: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const raw of patterns) { + const trimmed = raw.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const normalized = normalizePath(trimmed); + if (seen.has(normalized)) continue; + seen.add(normalized); + out.push(normalized); + } + return out; +} + +function parseTenureConfig(text: string): { + security: string[]; + noise: string[]; +} { + const trimmed = text.trim(); + if (!trimmed) return { security: [], noise: [] }; + + try { + const parsed = JSON.parse(trimmed) as Record; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { security: [], noise: [] }; + } + + const asStrings = (value: unknown): string[] => { + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === "string"); + }; + + return { + security: uniquePatterns(asStrings(parsed.ignore)), + noise: uniquePatterns(asStrings(parsed.noiseIgnores)) + }; + } catch { + return { security: [], noise: [] }; + } +} + +suite("globToRegex", () => { + test("matches root .env", () => { + const re = globToRegex("**/.env"); + assert.ok(re.test(".env")); + }); + + test("matches nested .env", () => { + const re = globToRegex("**/.env"); + assert.ok(re.test("apps/api/.env")); + }); + + test("matches .env.* variants", () => { + const re = globToRegex("**/.env.*"); + assert.ok(re.test(".env.production")); + assert.ok(re.test(".env.local")); + assert.ok(re.test("packages/foo/.env.test")); + }); + + test("does not false-match .env in middle of path", () => { + const re = globToRegex("**/.env"); + assert.ok(!re.test("src/env.ts")); + }); + + test("matches secret folder contents with **", () => { + const re = globToRegex("**/secrets/**"); + assert.ok(re.test("secrets/stripe.key")); + assert.ok(re.test("infra/secrets/prod.json")); + assert.ok(!re.test("src/secrets.ts")); + }); + + test("matches private.key anywhere", () => { + const re = globToRegex("**/private.key"); + assert.ok(re.test("private.key")); + assert.ok(re.test("config/private.key")); + }); + + test("matches *.pem anywhere", () => { + const re = globToRegex("**/*.pem"); + assert.ok(re.test("cert.pem")); + assert.ok(re.test("certs/client.pem")); + }); + + test("matches node_modules deep paths", () => { + const re = globToRegex("**/node_modules/**"); + assert.ok(re.test("node_modules/react/index.js")); + assert.ok(re.test("packages/a/node_modules/lodash/index.js")); + }); + + test("does not false-match node_modules as filename", () => { + const re = globToRegex("**/node_modules/**"); + assert.ok(!re.test("src/node_modules.ts")); + }); + + test("matches terraform state files", () => { + const re = globToRegex("**/*.tfstate"); + assert.ok(re.test("terraform.tfstate")); + assert.ok(re.test("prod.terraform.tfstate")); + }); + + test("matches .git/**", () => { + const re = globToRegex("**/.git/**"); + assert.ok(re.test(".git/config")); + assert.ok(re.test(".git/objects/ab/cd123")); + assert.ok(!re.test("src/git.ts")); + }); + + test("matches .aws credentials", () => { + const re = globToRegex("**/.aws/**"); + assert.ok(re.test(".aws/credentials")); + assert.ok(re.test("home/.aws/config")); + }); + + test("matches kubeconfig bare file", () => { + const re = globToRegex("**/kubeconfig"); + assert.ok(re.test("kubeconfig")); + assert.ok(re.test("clusters/kubeconfig")); + }); + + test("matches lock files", () => { + const re = globToRegex("**/package-lock.json"); + assert.ok(re.test("package-lock.json")); + assert.ok(re.test("sub/package-lock.json")); + }); + + test("matches id_rsa and id_ed25519", () => { + const reRsa = globToRegex("**/id_rsa"); + const reEd = globToRegex("**/id_ed25519"); + assert.ok(reRsa.test(".ssh/id_rsa")); + assert.ok(reEd.test(".ssh/id_ed25519")); + assert.ok(!reRsa.test("src/id_rsa.ts")); + }); + + test("case insensitive matching", () => { + const re = globToRegex("**/.env"); + assert.ok(re.test(".ENV")); + assert.ok(re.test("Apps/Api/.Env")); + }); + + test("matches exact basename patterns (no ** needed)", () => { + const re = globToRegex("id_rsa"); + assert.ok(re.test("id_rsa")); + assert.ok(!re.test("foo/id_rsa")); + }); + + test("? matches single non-slash character", () => { + const re = globToRegex("???.env"); + assert.ok(re.test("foo.env")); + assert.ok(!re.test("fo.env")); + assert.ok(!re.test("foo/bar.env")); + }); + + test("handles regex special characters in pattern", () => { + const re = globToRegex("**/google-services.json"); + assert.ok(re.test("google-services.json")); + assert.ok(re.test("app/google-services.json")); + }); +}); + +suite("uniquePatterns", () => { + test("deduplicates identical patterns", () => { + const result = uniquePatterns(["**/.env", "**/.env", "**/secrets/**"]); + assert.strictEqual(result.length, 2); + assert.deepStrictEqual(result, ["**/.env", "**/secrets/**"]); + }); + + test("filters empty and comment lines", () => { + const result = uniquePatterns(["", " ", "# comment", "**/.env"]); + assert.deepStrictEqual(result, ["**/.env"]); + }); + + test("normalizes backslashes to forward slashes", () => { + const result = uniquePatterns(["apps\\api\\.env", "apps/api/.env"]); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0], "apps/api/.env"); + }); + + test("collapses double slashes", () => { + const result = uniquePatterns(["foo//bar"]); + assert.strictEqual(result[0], "foo/bar"); + }); +}); + +suite("parseTenureConfig", () => { + test("parses ignore and noiseIgnores from JSON", () => { + const json = JSON.stringify({ + projectId: "my-project", + ignore: ["customers/**", "contracts/**"], + noiseIgnores: ["*.sql", "infra/prod/**"] + }); + const result = parseTenureConfig(json); + assert.deepStrictEqual(result.security, ["customers/**", "contracts/**"]); + assert.deepStrictEqual(result.noise, ["*.sql", "infra/prod/**"]); + }); + + test("handles empty ignore arrays", () => { + const json = JSON.stringify({ + projectId: "my-project", + ignore: [], + noiseIgnores: [] + }); + const result = parseTenureConfig(json); + assert.deepStrictEqual(result.security, []); + assert.deepStrictEqual(result.noise, []); + }); + + test("returns empty for plain text (legacy .tenure)", () => { + const result = parseTenureConfig("my-project"); + assert.deepStrictEqual(result.security, []); + assert.deepStrictEqual(result.noise, []); + }); + + test("returns empty for empty string", () => { + const result = parseTenureConfig(""); + assert.deepStrictEqual(result.security, []); + assert.deepStrictEqual(result.noise, []); + }); + + test("filters non-string items from arrays", () => { + const json = JSON.stringify({ + projectId: "p", + ignore: ["valid", null, 123, true, "also-valid"] + }); + const result = parseTenureConfig(json); + assert.deepStrictEqual(result.security, ["valid", "also-valid"]); + }); + + test("returns empty for malformed JSON", () => { + const result = parseTenureConfig("{ broken"); + assert.deepStrictEqual(result.security, []); + assert.deepStrictEqual(result.noise, []); + }); +}); + +suite("redactSensitiveText", () => { + test("redacts AWS access key", () => { + const result = redactSensitiveText("key=AKIAIOSFODNN7EXAMPLE here"); + assert.ok(!result.includes("AKIAIOSFODNN7EXAMPLE")); + assert.ok(result.includes("[REDACTED_AWS_ACCESS_KEY]")); + }); + + test("redacts AWS temp access key", () => { + const result = redactSensitiveText("ASIA1234567890ABCDEF"); + assert.ok(result.includes("[REDACTED_AWS_TEMP_ACCESS_KEY]")); + }); + + test("redacts GitHub token", () => { + const result = redactSensitiveText("token: ghp_abcdefghijklmnopqrstuvwx"); + assert.ok(result.includes("[REDACTED_GITHUB_TOKEN]")); + }); + + test("redacts OpenAI-style API key", () => { + const result = redactSensitiveText( + "Authorization: sk-proj-abcdefghijklmnopqrstuvwxyz" + ); + assert.ok(result.includes("[REDACTED_API_KEY]")); + }); + + test("redacts Anthropic API key (matched by generic sk- pattern first)", () => { + const result = redactSensitiveText( + "x-api-key: sk-ant-api03-abcdefghijklmnopqrstuvwxyz" + ); + assert.ok(result.includes("[REDACTED_API_KEY]")); + }); + + test("redacts Slack token", () => { + const result = redactSensitiveText("xoxb-1234567890-abcdefghijklmnop"); + assert.ok(result.includes("[REDACTED_SLACK_TOKEN]")); + }); + + test("redacts Stripe live key", () => { + const result = redactSensitiveText("sk_live_abcdefghijklmnopqrstuvwx"); + assert.ok(result.includes("[REDACTED_STRIPE_KEY]")); + }); + + test("redacts Stripe test key", () => { + const result = redactSensitiveText("pk_test_abcdefghijklmnopqrstuvwx"); + assert.ok(result.includes("[REDACTED_STRIPE_KEY]")); + }); + + test("redacts PostgreSQL connection URL", () => { + const result = redactSensitiveText( + "DATABASE_URL=postgres://user:password123@localhost:5432/db" + ); + assert.ok(result.includes("[REDACTED_CREDENTIALS]")); + assert.ok(result.includes("[REDACTED_HOST]")); + }); + + test("redacts MySQL connection URL", () => { + const result = redactSensitiveText( + "mysql://admin:secret@db.internal:3306/app" + ); + assert.ok(result.includes("[REDACTED_CREDENTIALS]")); + assert.ok(result.includes("[REDACTED_HOST]")); + }); + + test("redacts MongoDB connection URL", () => { + const result = redactSensitiveText( + "mongodb+srv://root:hunter2@cluster0.example.com/db" + ); + assert.ok(result.includes("[REDACTED_CREDENTIALS]")); + assert.ok(result.includes("[REDACTED_HOST]")); + }); + + test("redacts Bearer token", () => { + const result = redactSensitiveText( + "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOnRydWV9.abcdefghijklmnopqrstu" + ); + assert.ok(result.includes("[REDACTED_TOKEN]")); + }); + + test("redacts JWT", () => { + const result = redactSensitiveText( + "token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" + ); + assert.ok(result.includes("[REDACTED_JWT]")); + }); + + test("redacts PEM private key block", () => { + const input = `-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA0Z3VS0Z3VS0Z3VS0Z3VS0Z3V +S0Z3VS0Z3VS0Z3VS0Z3VS0Z3VS0Z3VS0Z3VS0Z3V +-----END RSA PRIVATE KEY-----`; + const result = redactSensitiveText(input); + assert.ok(result.includes("[REDACTED_PRIVATE_KEY]")); + assert.ok(!result.includes("BEGIN RSA PRIVATE KEY")); + }); + + test("does not redact benign text", () => { + const input = "This is a normal comment about AWS and GitHub integration."; + const result = redactSensitiveText(input); + assert.strictEqual(result, input); + }); + + test("handles empty string", () => { + const result = redactSensitiveText(""); + assert.strictEqual(result, ""); + }); + + test("redacts multiple patterns in same text", () => { + const input = + "AKIAIOSFODNN7EXAMPLE and token: ghp_example1234567890abcdefgh"; + const result = redactSensitiveText(input); + assert.ok(result.includes("[REDACTED_AWS_ACCESS_KEY]")); + assert.ok(result.includes("[REDACTED_GITHUB_TOKEN]")); + }); +}); + +suite("getTenureFilePolicyForPath (pure path decisions)", () => { + test("security deny: .env file", () => { + const result = getTenureFilePolicyForPath(".env"); + assert.strictEqual(result.decision, "suppress_all"); + assert.strictEqual(result.category, "security"); + assert.strictEqual(result.suppressContent, true); + assert.strictEqual(result.suppressMetadata, true); + assert.strictEqual(result.ignored, true); + }); + + test("security deny: nested .env.production", () => { + const result = getTenureFilePolicyForPath("apps/api/.env.production"); + assert.strictEqual(result.decision, "suppress_all"); + }); + + test("security deny: .ssh/id_rsa", () => { + const result = getTenureFilePolicyForPath(".ssh/id_rsa"); + assert.strictEqual(result.decision, "suppress_all"); + assert.strictEqual(result.category, "security"); + }); + + test("security deny: .aws/credentials", () => { + const result = getTenureFilePolicyForPath(".aws/credentials"); + assert.strictEqual(result.decision, "suppress_all"); + }); + + test("security deny: terraform.tfstate", () => { + const result = getTenureFilePolicyForPath("infra/terraform.tfstate"); + assert.strictEqual(result.decision, "suppress_all"); + }); + + test("security deny: kubeconfig", () => { + const result = getTenureFilePolicyForPath("kubeconfig"); + assert.strictEqual(result.decision, "suppress_all"); + }); + + test("security deny: private.key", () => { + const result = getTenureFilePolicyForPath("certs/private.key"); + assert.strictEqual(result.decision, "suppress_all"); + }); + + test("security deny: .npmrc", () => { + const result = getTenureFilePolicyForPath(".npmrc"); + assert.strictEqual(result.decision, "suppress_all"); + }); + + test("security deny: .netrc", () => { + const result = getTenureFilePolicyForPath(".netrc"); + assert.strictEqual(result.decision, "suppress_all"); + }); + + test("security deny: service-account.json", () => { + const result = getTenureFilePolicyForPath("gcp/service-account.json"); + assert.strictEqual(result.decision, "suppress_all"); + }); + + test("security deny: .pypirc anywhere", () => { + const result = getTenureFilePolicyForPath("home/.pypirc"); + assert.strictEqual(result.decision, "suppress_all"); + }); + + test("security deny: docker-compose.override.yml", () => { + const result = getTenureFilePolicyForPath("docker-compose.override.yml"); + assert.strictEqual(result.decision, "suppress_all"); + }); + + test("noise deny: node_modules deep path", () => { + const result = getTenureFilePolicyForPath("node_modules/react/index.js"); + assert.strictEqual(result.decision, "suppress_content"); + assert.strictEqual(result.category, "noise"); + assert.strictEqual(result.suppressContent, true); + assert.strictEqual(result.suppressMetadata, false); + assert.strictEqual(result.ignored, true); + }); + + test("noise deny: dist bundle", () => { + const result = getTenureFilePolicyForPath("dist/bundle.js"); + assert.strictEqual(result.decision, "suppress_content"); + }); + + test("noise deny: yarn.lock", () => { + const result = getTenureFilePolicyForPath("yarn.lock"); + assert.strictEqual(result.decision, "suppress_content"); + }); + + test("noise deny: package-lock.json", () => { + const result = getTenureFilePolicyForPath("package-lock.json"); + assert.strictEqual(result.decision, "suppress_content"); + }); + + test("noise deny: .git/config", () => { + const result = getTenureFilePolicyForPath(".git/config"); + assert.strictEqual(result.decision, "suppress_content"); + }); + + test("noise deny: build output", () => { + const result = getTenureFilePolicyForPath("build/static/js/main.js"); + assert.strictEqual(result.decision, "suppress_content"); + }); + + test("noise deny: coverage report", () => { + const result = getTenureFilePolicyForPath( + "coverage/lcov-report/index.html" + ); + assert.strictEqual(result.decision, "suppress_content"); + }); + + test("noise deny: __pycache__", () => { + const result = getTenureFilePolicyForPath( + "src/__pycache__/module.cpython-39.pyc" + ); + assert.strictEqual(result.decision, "suppress_content"); + }); + + test("noise deny: .vscode/settings.json", () => { + const result = getTenureFilePolicyForPath(".vscode/settings.json"); + assert.strictEqual(result.decision, "suppress_content"); + }); + + test("allow: normal source file", () => { + const result = getTenureFilePolicyForPath("src/config.ts"); + assert.strictEqual(result.decision, "allow"); + assert.strictEqual(result.category, "allowed"); + assert.strictEqual(result.suppressContent, false); + assert.strictEqual(result.suppressMetadata, false); + assert.strictEqual(result.ignored, false); + }); + + test("allow: README", () => { + const result = getTenureFilePolicyForPath("README.md"); + assert.strictEqual(result.decision, "allow"); + }); + + test("allow: docker-compose.yml (non-override)", () => { + const result = getTenureFilePolicyForPath("docker-compose.yml"); + assert.strictEqual(result.decision, "allow"); + }); + + test("allow: package.json (not lock)", () => { + const result = getTenureFilePolicyForPath("package.json"); + assert.strictEqual(result.decision, "allow"); + }); + + test("empty path returns allow", () => { + const result = getTenureFilePolicyForPath(""); + assert.strictEqual(result.decision, "allow"); + }); + + test("segment guard: node_modules in middle catches regardless of pattern", () => { + const result = getTenureFilePolicyForPath( + "weird/node_modules/pkg/index.js" + ); + assert.strictEqual(result.decision, "suppress_content"); + }); + + test("segment guard: .ssh in path triggers security", () => { + const result = getTenureFilePolicyForPath("home/user/.ssh/authorized_keys"); + assert.strictEqual(result.decision, "suppress_all"); + }); + + test("segment guard: .aws triggers security", () => { + const result = getTenureFilePolicyForPath("home/user/.aws/credentials"); + assert.strictEqual(result.decision, "suppress_all"); + }); + + test("segment guard: .kube triggers security", () => { + const result = getTenureFilePolicyForPath("home/user/.kube/config"); + assert.strictEqual(result.decision, "suppress_all"); + }); + + test("basename security: .env even without path segments", () => { + const result = getTenureFilePolicyForPath(".env"); + assert.strictEqual(result.decision, "suppress_all"); + }); + + test("basename security: id_ed25519 matches", () => { + const result = getTenureFilePolicyForPath("id_ed25519"); + assert.strictEqual(result.decision, "suppress_all"); + }); +}); diff --git a/integrations/vscode/src/test/suite/hostEnvironment.ts b/integrations/vscode/src/test/suite/hostEnvironment.ts new file mode 100644 index 0000000..40f5039 --- /dev/null +++ b/integrations/vscode/src/test/suite/hostEnvironment.ts @@ -0,0 +1,38 @@ +import * as assert from "assert"; +import { resolveHostApp } from "../../hostEnvironment.js"; + +suite("resolveHostApp", () => { + test("detects VS Code", () => { + assert.strictEqual(resolveHostApp("Visual Studio Code"), "vscode"); + assert.strictEqual( + resolveHostApp("Visual Studio Code - Insiders"), + "vscode" + ); + assert.strictEqual(resolveHostApp("Code"), "vscode"); + }); + + test("detects Cursor", () => { + assert.strictEqual(resolveHostApp("Cursor"), "cursor"); + assert.strictEqual(resolveHostApp("Cursor Nightly"), "cursor"); + }); + + test("detects Windsurf", () => { + assert.strictEqual(resolveHostApp("Windsurf"), "windsurf"); + assert.strictEqual(resolveHostApp("Windsurf Next"), "windsurf"); + }); + + test("detects VSCodium", () => { + assert.strictEqual(resolveHostApp("VSCodium"), "vscodium"); + }); + + test("returns unknown for unrecognized", () => { + assert.strictEqual(resolveHostApp("Emacs"), "unknown"); + assert.strictEqual(resolveHostApp(""), "unknown"); + }); + + test("case insensitive", () => { + assert.strictEqual(resolveHostApp("visual studio code"), "vscode"); + assert.strictEqual(resolveHostApp("CURSOR"), "cursor"); + assert.strictEqual(resolveHostApp("Windsurf"), "windsurf"); + }); +}); diff --git a/integrations/vscode/src/test/suite/integration.test.ts b/integrations/vscode/src/test/suite/integration.test.ts new file mode 100644 index 0000000..463e7d7 --- /dev/null +++ b/integrations/vscode/src/test/suite/integration.test.ts @@ -0,0 +1,1367 @@ +import * as assert from "assert"; +import * as vscode from "vscode"; +import * as path from "path"; +import * as fs from "fs"; +import * as os from "os"; +import test from "node:test"; + +import { + getTenureFilePolicyForPath, + invalidateTenureFilePolicyCache +} from "../../filePolicy.js"; + +suite("Integration: File Policy Cache Invalidation", () => { + test("evaluateRelativePath picks up hardcoded security deny before any cache", () => { + const result = getTenureFilePolicyForPath(".env"); + assert.strictEqual(result.decision, "suppress_all"); + assert.strictEqual(result.category, "security"); + }); + + test("evaluateRelativePath allows normal source files by default", () => { + const result = getTenureFilePolicyForPath("src/index.ts"); + assert.strictEqual(result.decision, "allow"); + }); + + test("cache key uses workspace root fsPath", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tenure-int-")); + try { + const uriA = vscode.Uri.file(tmpDir); + const uriB = vscode.Uri.file(tmpDir); + + const tenureJsonPath = path.join(tmpDir, ".tenure.json"); + fs.writeFileSync( + tenureJsonPath, + JSON.stringify({ + projectId: "test-project", + ignore: [], + noiseIgnores: ["src/secrets.env"] + }) + ); + + const result = getTenureFilePolicyForPath("src/secrets.env", uriA); + const resultB = getTenureFilePolicyForPath("src/secrets.env", uriB); + assert.strictEqual(result.decision, resultB.decision); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + +suite("Integration: Workspace State Lifecycle", () => { + let tmpDir: string; + + setup(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tenure-int-")); + }); + + teardown(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("state key handles null vs undefined vs empty string for active_file", () => { + const makeKey = (af: string | null) => + JSON.stringify({ + workspace_root: "/home/test", + project_name: "test", + git_remote: null, + active_file: af, + active_language: null + }); + + const nullKey = makeKey(null); + const emptyStringKey = makeKey(""); + const definedKey = makeKey("src/index.ts"); + + assert.notStrictEqual(nullKey, emptyStringKey); + assert.notStrictEqual(nullKey, definedKey); + assert.notStrictEqual(emptyStringKey, definedKey); + }); + + test("state key changes when git_remote goes from null to empty string", () => { + const stateA = JSON.stringify({ + workspace_root: "/home/test", + project_name: "test", + git_remote: null, + active_file: null, + active_language: null + }); + + const stateB = JSON.stringify({ + workspace_root: "/home/test", + project_name: "test", + git_remote: "", + active_file: null, + active_language: null + }); + + assert.notStrictEqual(stateA, stateB); + }); + + test("sync is skipped when state is byte-identical", () => { + const state = { + workspace_root: "/home/test", + project_name: "test", + git_remote: "git@github.com:org/repo.git", + active_file: "src/a.ts", + active_language: "typescript" + }; + + const key1 = JSON.stringify(state); + const key2 = JSON.stringify({ ...state }); + + assert.strictEqual(key1, key2); + }); + + test("sync fires when active_language changes alone", () => { + const a = JSON.stringify({ + workspace_root: "/home/test", + project_name: "test", + git_remote: null, + active_file: null, + active_language: "typescript" + }); + + const b = JSON.stringify({ + workspace_root: "/home/test", + project_name: "test", + git_remote: null, + active_file: null, + active_language: "javascript" + }); + + assert.notStrictEqual(a, b); + }); + + test("sync fires when workspace_root changes", () => { + const a = JSON.stringify({ + workspace_root: "/home/alice/project", + project_name: "test", + git_remote: null, + active_file: null, + active_language: null + }); + + const b = JSON.stringify({ + workspace_root: "/home/bob/project", + project_name: "test", + git_remote: null, + active_file: null, + active_language: null + }); + + assert.notStrictEqual(a, b); + }); +}); + +suite("Integration: Belief List Mutations Under Churn", () => { + interface BeliefSummary { + id: string; + canonical_name: string; + content: string; + pinned: boolean; + epistemic_status: string; + confidence: number; + type: string; + why_it_matters: string; + scope: string[]; + aliases: string[]; + } + + function makeBelief(id: string): BeliefSummary { + return { + id, + canonical_name: id, + content: `content of ${id}`, + pinned: false, + epistemic_status: "active", + confidence: 0.9, + type: "preference", + why_it_matters: "matters", + scope: ["project:test"], + aliases: [] + }; + } + + test("rapid upsert and supersede interleaving produces consistent state", () => { + let beliefs: BeliefSummary[] = [makeBelief("b1"), makeBelief("b2")]; + + const upsert = (b: BeliefSummary) => { + const idx = beliefs.findIndex((x) => x.id === b.id); + if (idx !== -1) beliefs[idx] = b; + else beliefs.unshift(b); + }; + + const supersede = (id: string) => { + beliefs = beliefs.filter((b) => b.id !== id); + }; + + upsert(makeBelief("b3")); + upsert({ ...makeBelief("b1"), content: "updated b1" }); + supersede("b2"); + upsert(makeBelief("b4")); + supersede("b3"); + upsert({ ...makeBelief("b4"), content: "updated b4" }); + + assert.strictEqual(beliefs.length, 2); + assert.strictEqual(beliefs[0].id, "b4"); + assert.strictEqual(beliefs[0].content, "updated b4"); + assert.strictEqual(beliefs[1].id, "b1"); + assert.strictEqual(beliefs[1].content, "updated b1"); + }); + + test("beliefs_snapshot replaces entire list correctly", () => { + let beliefs: BeliefSummary[] = [ + makeBelief("old1"), + makeBelief("old2"), + makeBelief("old3") + ]; + + const snapshot = [makeBelief("new1"), makeBelief("new2")]; + + beliefs = snapshot; + + assert.strictEqual(beliefs.length, 2); + assert.ok(beliefs.find((b) => b.id === "new1")); + assert.ok(beliefs.find((b) => b.id === "new2")); + assert.ok(!beliefs.find((b) => b.id === "old1")); + }); + + test("duplicate upsert does not create duplicates", () => { + let beliefs: BeliefSummary[] = [makeBelief("b1")]; + + const upsert = (b: BeliefSummary) => { + const idx = beliefs.findIndex((x) => x.id === b.id); + if (idx !== -1) beliefs[idx] = b; + else beliefs.unshift(b); + }; + + upsert(makeBelief("b1")); + upsert(makeBelief("b1")); + + assert.strictEqual(beliefs.length, 1); + }); +}); + +suite("Integration: Client-Side Belief Categorization", () => { + interface BeliefSummary { + id: string; + canonical_name: string; + content: string; + pinned: boolean; + epistemic_status: string; + confidence: number; + type: string; + why_it_matters: string; + scope: string[]; + aliases: string[]; + origin_context?: { + active_file: string | null; + language: string | null; + project_scope: string | null; + } | null; + } + + function makeBelief( + id: string, + overrides: Partial = {} + ): BeliefSummary { + return { + id, + canonical_name: id, + content: `content of ${id}`, + pinned: false, + epistemic_status: "active", + confidence: 0.9, + type: "preference", + why_it_matters: "matters", + scope: ["project:test"], + aliases: [], + ...overrides + }; + } + + function categorize( + beliefs: BeliefSummary[], + currentActiveFile: string | null + ) { + const file: BeliefSummary[] = []; + const project: BeliefSummary[] = []; + const universal: BeliefSummary[] = []; + + for (const b of beliefs) { + if (b.scope.includes("user:universal")) { + universal.push(b); + } else if ( + b.origin_context?.active_file && + b.origin_context.active_file === currentActiveFile + ) { + file.push(b); + } else { + project.push(b); + } + } + + return { file, project, universal }; + } + + test("universal beliefs go to universal bucket regardless of file context", () => { + const beliefs = [ + makeBelief("u1", { + scope: ["user:universal"], + origin_context: { + active_file: "src/a.ts", + language: null, + project_scope: null + } + }) + ]; + + const result = categorize(beliefs, "src/a.ts"); + assert.strictEqual(result.universal.length, 1); + assert.strictEqual(result.file.length, 0); + assert.strictEqual(result.project.length, 0); + }); + + test("file beliefs only match when active_file exactly equals origin_context", () => { + const beliefs = [ + makeBelief("f1", { + origin_context: { + active_file: "src/a.ts", + language: null, + project_scope: null + } + }) + ]; + + const result = categorize(beliefs, "src/a.ts"); + assert.strictEqual(result.file.length, 1); + assert.strictEqual(result.project.length, 0); + }); + + test("file beliefs go to project when active_file differs", () => { + const beliefs = [ + makeBelief("f1", { + origin_context: { + active_file: "src/a.ts", + language: null, + project_scope: null + } + }) + ]; + + const result = categorize(beliefs, "src/b.ts"); + assert.strictEqual(result.file.length, 0); + assert.strictEqual(result.project.length, 1); + }); + + test("null origin_context categorizes as project", () => { + const beliefs = [makeBelief("p1", { origin_context: null })]; + + const result = categorize(beliefs, "src/a.ts"); + assert.strictEqual(result.project.length, 1); + }); + + test("mixed beliefs categorize correctly", () => { + const beliefs = [ + makeBelief("u1", { scope: ["user:universal"] }), + makeBelief("f1", { + origin_context: { + active_file: "src/a.ts", + language: null, + project_scope: null + } + }), + makeBelief("p1", { + origin_context: { + active_file: "src/b.ts", + language: null, + project_scope: null + } + }), + makeBelief("p2", { origin_context: null }) + ]; + + const result = categorize(beliefs, "src/a.ts"); + assert.strictEqual(result.universal.length, 1); + assert.strictEqual(result.file.length, 1); + assert.strictEqual(result.project.length, 2); + assert.strictEqual(result.file[0].id, "f1"); + }); +}); + +suite("Integration: Reconnect Backoff Under Realistic Scenarios", () => { + const BASE_RECONNECT_MS = 1_000; + const MAX_RECONNECT_MS = 30_000; + + function backOff(attempt: number): number { + return Math.min(BASE_RECONNECT_MS * Math.pow(2, attempt), MAX_RECONNECT_MS); + } + + test("backoff sequence matches expected values", () => { + const expected = [1000, 2000, 4000, 8000, 16000, 30000, 30000, 30000]; + for (let i = 0; i < expected.length; i++) { + assert.strictEqual(backOff(i), expected[i]); + } + }); + + test("backoff never exceeds MAX_RECONNECT_MS for extreme attempt counts", () => { + assert.strictEqual(backOff(50), MAX_RECONNECT_MS); + assert.strictEqual(backOff(1000), MAX_RECONNECT_MS); + }); + + test("backoff never goes below BASE_RECONNECT_MS", () => { + assert.strictEqual(backOff(0), BASE_RECONNECT_MS); + }); +}); + +suite("Integration: WebSocket Send Guard Across All States", () => { + const WS_CONNECTING = 0; + const WS_OPEN = 1; + const WS_CLOSING = 2; + const WS_CLOSED = 3; + + function canSend(readyState: number): boolean { + return readyState === WS_OPEN; + } + + test("only OPEN allows sending", () => { + assert.ok(canSend(WS_OPEN)); + assert.ok(!canSend(WS_CONNECTING)); + assert.ok(!canSend(WS_CLOSING)); + assert.ok(!canSend(WS_CLOSED)); + }); +}); + +suite("Integration: Token Store State Consistency", () => { + test("token set/get roundtrip through VS Code secrets", async function () { + const timeout = 5000; + this.timeout(timeout); + + const { secrets } = require("vscode") as typeof vscode; + const testToken = `mp_test_${Date.now()}`; + + await secrets.store("tenure.apiToken", testToken); + const retrieved = await secrets.get("tenure.apiToken"); + assert.strictEqual(retrieved, testToken); + + await secrets.delete("tenure.apiToken"); + const afterClear = await secrets.get("tenure.apiToken"); + assert.strictEqual(afterClear, undefined); + }); +}); + +suite("Integration: Scope Migration Detection", () => { + function slugify(name: string): string { + return name + .toLowerCase() + .replace(/^@[^/]+\//, "") + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + } + + test("detects rename when project name changes", () => { + const oldState = { + workspace_root: "/home/test", + project_name: "old-project", + git_remote: null, + active_file: null, + active_language: null + }; + + const newState = { ...oldState, project_name: "new-project" }; + + const renamed = oldState.project_name !== newState.project_name; + assert.ok(renamed); + }); + + test("does not detect rename when only active_file changes", () => { + const oldState = { + workspace_root: "/home/test", + project_name: "stable-project", + git_remote: null, + active_file: "src/a.ts", + active_language: "typescript" + }; + + const newState = { ...oldState, active_file: "src/b.ts" }; + + const renamed = oldState.project_name !== newState.project_name; + assert.ok(!renamed); + }); + + test("migrate scope body uses slugified names", () => { + const body = { + old_scope: `project:${slugify("My Old Project")}`, + new_scope: `project:${slugify("New Project!")}` + }; + + assert.strictEqual(body.old_scope, "project:my-old-project"); + assert.strictEqual(body.new_scope, "project:new-project"); + }); +}); + +suite("Integration: onDidRenameFiles Filtering With Edge Cases", () => { + function shouldSendRename(oldPath: string, newPath: string): boolean { + return !path.isAbsolute(oldPath) && !path.isAbsolute(newPath); + } + + test("sends for relative to relative", () => { + assert.ok(shouldSendRename("src/old.ts", "src/new.ts")); + }); + + test("skips when old is absolute", () => { + assert.ok(!shouldSendRename("/home/test/src/old.ts", "src/new.ts")); + }); + + test("skips when new is absolute", () => { + assert.ok(!shouldSendRename("src/old.ts", "/home/test/src/new.ts")); + }); + + test("skips when both are absolute", () => { + assert.ok( + !shouldSendRename("/home/test/src/old.ts", "/home/test/src/new.ts") + ); + }); + + test("handles OS-specific absolute paths correctly", () => { + const isWin = os.platform() === "win32"; + if (isWin) { + assert.ok(!shouldSendRename("C:\\Users\\test\\old.ts", "src/new.ts")); + assert.ok(!shouldSendRename("src/old.ts", "D:\\projects\\new.ts")); + } else { + assert.ok(shouldSendRename("C:\\Users\\test\\old.ts", "src/new.ts")); + assert.ok(shouldSendRename("src/old.ts", "D:\\projects\\new.ts")); + } + }); + + test("handles paths with .. segments as relative", () => { + assert.ok(shouldSendRename("../shared/old.ts", "../shared/new.ts")); + }); +}); + +suite("Integration: Redaction Does Not False-Positive Benign Content", () => { + function redactSensitiveText(text: string): string { + const rules: Array<{ re: RegExp; replacement: string }> = [ + { + re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, + replacement: "[REDACTED_PRIVATE_KEY]" + }, + { re: /\bAKIA[0-9A-Z]{16}\b/g, replacement: "[REDACTED_AWS_ACCESS_KEY]" }, + { + re: /\bASIA[0-9A-Z]{16}\b/g, + replacement: "[REDACTED_AWS_TEMP_ACCESS_KEY]" + }, + { + re: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g, + replacement: "[REDACTED_GITHUB_TOKEN]" + }, + { re: /\bsk-[A-Za-z0-9_-]{20,}\b/g, replacement: "[REDACTED_API_KEY]" }, + { + re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g, + replacement: "[REDACTED_ANTHROPIC_KEY]" + }, + { + re: /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/g, + replacement: "[REDACTED_SLACK_TOKEN]" + }, + { + re: /\b(?:pk|sk)_(?:live|test)_[A-Za-z0-9]{20,}\b/g, + replacement: "[REDACTED_STRIPE_KEY]" + }, + { + re: /\bpostgres(?:ql)?:\/\/[^\s:@]+:[^\s@]+@[^\s]+/gi, + replacement: "postgres://[REDACTED_CREDENTIALS]@[REDACTED_HOST]" + }, + { + re: /\bmysql:\/\/[^\s:@]+:[^\s@]+@[^\s]+/gi, + replacement: "mysql://[REDACTED_CREDENTIALS]@[REDACTED_HOST]" + }, + { + re: /\bmongodb(?:\+srv)?:\/\/[^\s:@]+:[^\s@]+@[^\s]+/gi, + replacement: "mongodb://[REDACTED_CREDENTIALS]@[REDACTED_HOST]" + }, + { + re: /\bBearer\s+[A-Za-z0-9._~+/=-]{24,}\b/g, + replacement: "Bearer [REDACTED_TOKEN]" + }, + { + re: /\b[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{20,}\b/g, + replacement: "[REDACTED_JWT]" + } + ]; + + let redacted = text; + for (const { re, replacement } of rules) { + redacted = redacted.replace(re, replacement); + } + return redacted; + } + + test("leaves normal prose untouched", () => { + const input = + "We use PostgreSQL for our database and deploy on AWS using GitHub Actions."; + assert.strictEqual(redactSensitiveText(input), input); + }); + + test("leaves short hex strings untouched (not a JWT)", () => { + const input = "commit abc123def456 on branch main"; + assert.strictEqual(redactSensitiveText(input), input); + }); + + test("leaves UUID-like strings untouched", () => { + const input = "trace-id: 550e8400-e29b-41d4-a716-446655440000"; + const result = redactSensitiveText(input); + assert.ok(!result.includes("REDACTED")); + }); + + test("redacts actual JWT but not short dotted strings", () => { + const jwt = + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"; + const benign = "v1.2.3"; + + assert.ok(redactSensitiveText(jwt).includes("[REDACTED_JWT]")); + assert.strictEqual(redactSensitiveText(benign), benign); + }); + + test("redacts multiple patterns in a single line", () => { + const input = + "AKIAIOSFODNN7EXAMPLE token: ghp_example1234567890abcdefgh postgres://user:pass@host/db"; + const result = redactSensitiveText(input); + assert.ok(result.includes("[REDACTED_AWS_ACCESS_KEY]")); + assert.ok(result.includes("[REDACTED_GITHUB_TOKEN]")); + assert.ok(result.includes("[REDACTED_CREDENTIALS]")); + }); +}); + +suite("Integration: Configuration Edge Cases", () => { + test("securityIgnorePatterns as non-array does not throw", () => { + const cfg = vscode.workspace.getConfiguration("tenure"); + const value = cfg.get("securityIgnorePatterns"); + + if (value !== undefined && value !== null) { + assert.ok( + Array.isArray(value) || + typeof value === "string" || + typeof value === "object" + ); + } + }); + + test("baseUrl with trailing slash is handled consistently", () => { + const urls = [ + "http://localhost:5757", + "http://localhost:5757/", + "http://localhost:5757///" + ]; + + const normalized = urls.map((u) => u.replace(/\/+$/, "")); + assert.strictEqual(new Set(normalized).size, 1); + assert.strictEqual(normalized[0], "http://localhost:5757"); + }); + + test("baseUrl ws conversion is correct", () => { + const httpUrl = "http://localhost:5757"; + const httpsUrl = "https://tenure.example.com:5757"; + + const ws1 = httpUrl.replace(/^http/, "ws") + "/v1/ws/beliefs"; + const ws2 = httpsUrl.replace(/^http/, "ws") + "/v1/ws/beliefs"; + + assert.strictEqual(ws1, "ws://localhost:5757/v1/ws/beliefs"); + assert.strictEqual(ws2, "wss://tenure.example.com:5757/v1/ws/beliefs"); + }); +}); + +suite("Integration: Record Belief Scope Fallback", () => { + function slugify(name: string): string { + return name + .toLowerCase() + .replace(/^@[^/]+\//, "") + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + } + + test("file scope falls back to project scope", () => { + const currentScope = "project:my-app"; + const scopeLevel = "file"; + const scope = + scopeLevel === "file" && currentScope + ? [currentScope] + : ["user:universal"]; + + assert.deepStrictEqual(scope, ["project:my-app"]); + }); + + test("universal scope ignores currentScope completely", () => { + const scopeLevel = "universal"; + const scope = + scopeLevel === "universal" ? ["user:universal"] : ["project:test"]; + + assert.deepStrictEqual(scope, ["user:universal"]); + }); + + test("project scope uses currentScope when available", () => { + const currentScope = "project:my-project"; + const scopeLevel = "project"; + const scope = currentScope ? [currentScope] : ["user:universal"]; + + assert.deepStrictEqual(scope, ["project:my-project"]); + }); + + test("project scope falls back to universal when currentScope is null", () => { + const currentScope: string | null = null; + const scopeLevel = "project"; + const scope = currentScope ? [currentScope] : ["user:universal"]; + + assert.deepStrictEqual(scope, ["user:universal"]); + }); + + test("canonical name construction is stable across equivalent inputs", () => { + const buildName = (content: string) => + content + .toLowerCase() + .replace(/[^a-z0-9\s]/g, "") + .split(/\s+/) + .slice(0, 5) + .join("_"); + + const a = buildName("We should use fetch not axios"); + const b = buildName("We should use fetch not axios!!"); + const c = buildName("We should use fetch not axios"); + + assert.strictEqual(a, "we_should_use_fetch_not"); + assert.strictEqual(b, "we_should_use_fetch_not"); + assert.strictEqual(c, "we_should_use_fetch_not"); + }); +}); + +suite("Integration: Host App Detection", () => { + function resolveHostApp(appName: string): string { + const name = appName.toLowerCase(); + if (name.includes("cursor")) return "cursor"; + if (name.includes("windsurf")) return "windsurf"; + if (name.includes("vscodium")) return "vscodium"; + if (name.includes("visual studio code")) return "vscode"; + if (name === "code") return "vscode"; + return "unknown"; + } + + test("detects VS Code variants", () => { + assert.strictEqual(resolveHostApp("Visual Studio Code"), "vscode"); + assert.strictEqual( + resolveHostApp("Visual Studio Code - Insiders"), + "vscode" + ); + assert.strictEqual(resolveHostApp("Code"), "vscode"); + assert.strictEqual(resolveHostApp("Code - OSS"), "vscode"); + }); + + test("detects Cursor and Windsurf without ambiguity", () => { + assert.strictEqual(resolveHostApp("Cursor"), "cursor"); + assert.strictEqual(resolveHostApp("Windsurf"), "windsurf"); + assert.strictEqual(resolveHostApp("Windsurf Next"), "windsurf"); + }); + + test("VSCodium is not confused with Code", () => { + assert.strictEqual(resolveHostApp("VSCodium"), "vscodium"); + assert.strictEqual(resolveHostApp("VSCodium - Insiders"), "vscodium"); + }); + + test("unknown apps return unknown", () => { + assert.strictEqual(resolveHostApp(""), "unknown"); + assert.strictEqual(resolveHostApp("Emacs"), "unknown"); + assert.strictEqual(resolveHostApp("IntelliJ IDEA"), "unknown"); + }); + + test("case insensitive", () => { + assert.strictEqual(resolveHostApp("VISUAL STUDIO CODE"), "vscode"); + assert.strictEqual(resolveHostApp("cursor"), "cursor"); + assert.strictEqual(resolveHostApp("WindSurf"), "windsurf"); + }); +}); + +suite("Integration: Local Fallback Slug Stability", () => { + function getLocalFallbackSlug(workspaceRoot: string): string { + const { createHash } = require("node:crypto"); + const folderName = path.basename(workspaceRoot); + const hash = createHash("md5") + .update(workspaceRoot) + .digest("hex") + .slice(0, 8); + return `${folderName}-${hash}`; + } + + test("same path always produces same slug", () => { + const root = "/home/user/my-project"; + assert.strictEqual(getLocalFallbackSlug(root), getLocalFallbackSlug(root)); + }); + + test("different paths produce different slugs", () => { + const a = getLocalFallbackSlug("/home/alice/project"); + const b = getLocalFallbackSlug("/home/bob/project"); + assert.notStrictEqual(a, b); + }); + + test("slug contains folder name and 8-char hex", () => { + const slug = getLocalFallbackSlug("/data/repos/backend"); + assert.ok(slug.startsWith("backend-")); + const parts = slug.split("-"); + const hash = parts[parts.length - 1]; + assert.strictEqual(hash.length, 8); + assert.match(hash, /^[0-9a-f]{8}$/); + }); +}); + +suite("Integration: LM Provider Model Deduplication", () => { + interface ModelInfo { + id: string; + name: string; + family: string; + version: string; + maxInputTokens: number; + maxOutputTokens: number; + } + + function deduplicate(models: ModelInfo[]): ModelInfo[] { + const seen = new Set(); + return models.filter((m) => { + if (seen.has(m.id)) return false; + seen.add(m.id); + return true; + }); + } + + test("default model is not duplicated when also in probed list", () => { + const probed = [ + { + id: "gpt-4o", + name: "gpt-4o", + family: "tenure", + version: "1", + maxInputTokens: 128000, + maxOutputTokens: 16000 + }, + { + id: "claude-sonnet-4-20250514", + name: "claude-sonnet-4-20250514", + family: "tenure", + version: "1", + maxInputTokens: 128000, + maxOutputTokens: 16000 + } + ]; + + const defaultModel = { + id: "gpt-4o", + name: "gpt-4o", + family: "tenure", + version: "1", + maxInputTokens: 128000, + maxOutputTokens: 16000 + }; + + let models = [defaultModel, ...probed]; + models = deduplicate(models); + + assert.strictEqual(models.length, 2); + }); + + test("default model is prepended when not in probed list", () => { + const probed = [ + { + id: "gpt-4o-mini", + name: "gpt-4o-mini", + family: "tenure", + version: "1", + maxInputTokens: 128000, + maxOutputTokens: 16000 + } + ]; + + const defaultModel = { + id: "gpt-4o", + name: "gpt-4o", + family: "tenure", + version: "1", + maxInputTokens: 128000, + maxOutputTokens: 16000 + }; + + let models = [defaultModel, ...probed]; + models = deduplicate(models); + + assert.strictEqual(models.length, 2); + assert.strictEqual(models[0].id, "gpt-4o"); + }); +}); + +suite("Integration: SSE Parsing Robustness", () => { + async function* parseSSE( + chunks: string[] + ): AsyncGenerator> { + let buffer = ""; + for (const chunk of chunks) { + buffer += chunk; + const parts = buffer.split("\n"); + buffer = parts.pop()!; + for (const part of parts) { + const trimmed = part.trim(); + if (trimmed.startsWith("data: ")) { + const payload = trimmed.slice(6); + if (payload === "[DONE]") return; + try { + yield JSON.parse(payload) as Record; + } catch { + continue; + } + } + } + } + } + + test("handles message split across chunks", async () => { + const chunks = [ + 'data: {"choices":[{"delta":{"content":"Hel', + 'lo"}}]}\n\n' + ]; + + const results: string[] = []; + for await (const data of parseSSE(chunks)) { + const choices = data.choices as Array<{ delta?: { content?: string } }>; + results.push(choices?.[0]?.delta?.content ?? ""); + } + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0], "Hello"); + }); + + test("handles multiple complete messages in one chunk", async () => { + const chunks = [ + 'data: {"choices":[{"delta":{"content":"A"}}]}\n\ndata: {"choices":[{"delta":{"content":"B"}}]}\n\n' + ]; + + const results: string[] = []; + for await (const data of parseSSE(chunks)) { + const choices = data.choices as Array<{ delta?: { content?: string } }>; + results.push(choices?.[0]?.delta?.content ?? ""); + } + + assert.deepStrictEqual(results, ["A", "B"]); + }); + + test("handles malformed data lines gracefully", async () => { + const chunks = [ + 'data: {"choices":[{"delta":{"content":"good"}}]}\n\ndata: not-json\n\ndata: {"choices":[{"delta":{"content":"also good"}}]}\n\n' + ]; + + const results: string[] = []; + for await (const data of parseSSE(chunks)) { + const choices = data.choices as Array<{ delta?: { content?: string } }>; + results.push(choices?.[0]?.delta?.content ?? ""); + } + + assert.deepStrictEqual(results, ["good", "also good"]); + }); + + test("handles [DONE] signal correctly", async () => { + const chunks = [ + 'data: {"choices":[{"delta":{"content":"last"}}]}\n\ndata: [DONE]\n\ndata: {"choices":[{"delta":{"content":"ignored"}}]}\n\n' + ]; + + const results: string[] = []; + for await (const data of parseSSE(chunks)) { + const choices = data.choices as Array<{ delta?: { content?: string } }>; + results.push(choices?.[0]?.delta?.content ?? ""); + } + + assert.deepStrictEqual(results, ["last"]); + }); + + test("handles empty data lines", async () => { + const chunks = [ + 'data: \n\ndata: {"choices":[{"delta":{"content":"valid"}}]}\n\n' + ]; + + const results: string[] = []; + for await (const data of parseSSE(chunks)) { + const choices = data.choices as Array<{ delta?: { content?: string } }>; + results.push(choices?.[0]?.delta?.content ?? ""); + } + + assert.deepStrictEqual(results, ["valid"]); + }); +}); + +suite("Integration: File Policy Segment Guards (Defense in Depth)", () => { + function getSegments(relativePath: string): string[] { + return relativePath.replace(/\\/g, "/").split("/").filter(Boolean); + } + + function hasSensitiveSegment(segments: string[]): boolean { + return segments.some((s) => + [".ssh", ".aws", ".azure", ".kube", ".gnupg"].includes(s) + ); + } + + function hasGeneratedSegment(segments: string[]): boolean { + return segments.some((s) => ["node_modules", ".git"].includes(s)); + } + + test("unusual node_modules path still caught by segment guard", () => { + const segments = getSegments("weird/deep/nested/node_modules/pkg/index.js"); + assert.ok(hasGeneratedSegment(segments)); + }); + + test(".ssh anywhere in path triggers security", () => { + const segments = getSegments("home/user/.ssh/authorized_keys"); + assert.ok(hasSensitiveSegment(segments)); + }); + + test(".aws anywhere in path triggers security", () => { + const segments = getSegments("home/user/.aws/credentials"); + assert.ok(hasSensitiveSegment(segments)); + }); + + test(".kube triggers security even without kubeconfig filename", () => { + const segments = getSegments("home/user/.kube/some-file"); + assert.ok(hasSensitiveSegment(segments)); + }); + + test(".gnupg triggers security", () => { + const segments = getSegments("home/user/.gnupg/pubring.kbx"); + assert.ok(hasSensitiveSegment(segments)); + }); + + test("normal source paths do not trigger segment guards", () => { + const segments = getSegments("src/components/Button.tsx"); + assert.ok(!hasSensitiveSegment(segments)); + assert.ok(!hasGeneratedSegment(segments)); + }); + + test("paths that contain segment-like substrings are not falsely caught", () => { + const segments = getSegments("src/my_ssh_helper.ts"); + assert.ok(!hasSensitiveSegment(segments)); + assert.ok(!hasGeneratedSegment(segments)); + }); +}); + +suite("Integration: Onboarding State Machine Transitions", () => { + type OnboardingState = + | "token_entry" + | "provider_setup" + | "model_picker" + | "questions" + | "review" + | "complete"; + + test("token entry transitions to provider setup when no provider configured", () => { + const state = "token_entry"; + const hasProvider = false; + const hasModel = false; + + let next: OnboardingState = state; + if (state === "token_entry" && !hasProvider) { + next = "provider_setup"; + } + + assert.strictEqual(next, "provider_setup"); + }); + + test("token entry transitions to model picker when provider exists but no model", () => { + const state = "token_entry"; + const hasProvider = true; + const hasModel = false; + + let next: OnboardingState = state; + if (state === "token_entry") { + if (!hasProvider) next = "provider_setup"; + else if (!hasModel) next = "model_picker"; + else next = "questions"; + } + + assert.strictEqual(next, "model_picker"); + }); + + test("skip setup during questions still commits seeded_agent flag", () => { + let seededFlag = false; + + const skip = () => { + seededFlag = true; + }; + + skip(); + assert.ok(seededFlag); + }); + + test("seeded_agent check uses strict equality with true", () => { + const rawConfig: Record = { + "seeded_agent:vscode": true, + "seeded_agent:openwebui": "true" + }; + + const alreadySeeded = + rawConfig["seeded_agent:vscode"] === true || + rawConfig["seeded_agent:openwebui"] === true || + rawConfig["seeded_agent:openclaw"] === true; + + assert.ok(alreadySeeded); + }); + + test("string 'true' does not count as seeded in strict check for vscode", () => { + const rawConfig: Record = { + "seeded_agent:vscode": "true" + }; + + const alreadySeeded = + rawConfig["seeded_agent:vscode"] === true || + rawConfig["seeded_agent:openwebui"] === true || + rawConfig["seeded_agent:openclaw"] === true; + + assert.ok(!alreadySeeded); + }); +}); + +suite("Integration: Continue Config Injection Edge Cases", () => { + type ContinueInjectResult = + | "injected" + | "already_configured" + | "no_config_file" + | "parse_error" + | "ts_config"; + + function simulateInject( + configExists: boolean, + tsConfigExists: boolean, + alreadyHasBaseUrl: boolean, + validJson: boolean, + modelsIsArray: boolean + ): ContinueInjectResult { + if (tsConfigExists) return "ts_config"; + if (!configExists) return "no_config_file"; + if (!validJson) return "parse_error"; + if (alreadyHasBaseUrl) return "already_configured"; + return "injected"; + } + + test("ts_config takes precedence over json config", () => { + assert.strictEqual( + simulateInject(true, true, false, true, true), + "ts_config" + ); + }); + + test("no config file returns no_config_file", () => { + assert.strictEqual( + simulateInject(false, false, false, true, true), + "no_config_file" + ); + }); + + test("malformed JSON returns parse_error", () => { + assert.strictEqual( + simulateInject(true, false, false, false, true), + "parse_error" + ); + }); + + test("already configured returns already_configured", () => { + assert.strictEqual( + simulateInject(true, false, true, true, true), + "already_configured" + ); + }); + + test("fresh config returns injected", () => { + assert.strictEqual( + simulateInject(true, false, false, true, true), + "injected" + ); + }); + + test("ts_config trumps already_configured in json", () => { + assert.strictEqual( + simulateInject(true, true, true, true, true), + "ts_config" + ); + }); +}); + +suite("Integration: Git Remote Resolution Edge Cases", () => { + function extractRepoName(remoteUrl: string): string | null { + const match = remoteUrl.match(/\/([^/]+?)(?:\.git)?$/); + return match?.[1] ?? null; + } + + test("extracts from HTTPS URL", () => { + assert.strictEqual( + extractRepoName("https://github.com/org/repo.git"), + "repo" + ); + }); + + test("extracts from SSH URL", () => { + assert.strictEqual(extractRepoName("git@github.com:org/repo.git"), "repo"); + }); + + test("extracts from URL without .git suffix", () => { + assert.strictEqual(extractRepoName("https://github.com/org/repo"), "repo"); + }); + + test("handles nested group paths", () => { + assert.strictEqual( + extractRepoName("https://gitlab.com/group/subgroup/repo.git"), + "repo" + ); + }); + + test("returns null for malformed URL", () => { + assert.strictEqual(extractRepoName("not-a-url"), null); + }); + + test("returns null for empty string", () => { + assert.strictEqual(extractRepoName(""), null); + }); +}); + +suite("Integration: Policy Decision Consistency", () => { + test("suppress_all always implies suppressContent and suppressMetadata", () => { + const policy = { + decision: "suppress_all" as const, + category: "security" as const, + suppressContent: true, + suppressMetadata: true, + ignored: true + }; + + assert.ok(policy.suppressContent); + assert.ok(policy.suppressMetadata); + assert.ok(policy.ignored); + }); + + test("suppress_content implies suppressContent but not suppressMetadata", () => { + const policy = { + decision: "suppress_content" as const, + category: "noise" as const, + suppressContent: true, + suppressMetadata: false, + ignored: true + }; + + assert.ok(policy.suppressContent); + assert.ok(!policy.suppressMetadata); + assert.ok(policy.ignored); + }); + + test("allow has all suppression flags off", () => { + const policy = { + decision: "allow" as const, + category: "allowed" as const, + suppressContent: false, + suppressMetadata: false, + ignored: false + }; + + assert.ok(!policy.suppressContent); + assert.ok(!policy.suppressMetadata); + assert.ok(!policy.ignored); + }); + + test("outside_workspace is always suppress_all", () => { + const policy = { + decision: "suppress_all" as const, + category: "outside_workspace" as const, + reason: "Path outside workspace root", + suppressContent: true, + suppressMetadata: true, + ignored: true + }; + + assert.strictEqual(policy.decision, "suppress_all"); + assert.ok(policy.suppressContent); + assert.ok(policy.suppressMetadata); + }); + + test("unsupported_scheme is always suppress_all", () => { + const policy = { + decision: "suppress_all" as const, + category: "unsupported_scheme" as const, + reason: "Unsupported URI scheme: vscode-notebook", + suppressContent: true, + suppressMetadata: true, + ignored: true + }; + + assert.strictEqual(policy.decision, "suppress_all"); + }); +}); + +suite("Integration: Tool Call Accumulator", () => { + interface ToolCallAccumulator { + id: string; + name: string; + arguments: string; + } + + function accumulate( + acc: Record, + toolCalls: Array<{ + index: number; + id?: string; + function?: { name?: string; arguments?: string }; + }> + ): void { + for (const tc of toolCalls) { + if (!acc[tc.index]) { + acc[tc.index] = { id: "", name: "", arguments: "" }; + } + if (tc.id) acc[tc.index].id = tc.id; + if (tc.function?.name) acc[tc.index].name = tc.function.name; + if (tc.function?.arguments) + acc[tc.index].arguments += tc.function.arguments; + } + } + + test("accumulates tool call across multiple deltas", () => { + const acc: Record = {}; + + accumulate(acc, [ + { index: 0, id: "call_1", function: { name: "read_file" } } + ]); + accumulate(acc, [{ index: 0, function: { arguments: '{"path":' } }]); + accumulate(acc, [ + { index: 0, function: { arguments: '"/src/index.ts"}' } } + ]); + + assert.strictEqual(acc[0].id, "call_1"); + assert.strictEqual(acc[0].name, "read_file"); + assert.strictEqual(acc[0].arguments, '{"path":"/src/index.ts"}'); + }); + + test("handles multiple parallel tool calls", () => { + const acc: Record = {}; + + accumulate(acc, [ + { index: 0, id: "call_1", function: { name: "read_file" } }, + { index: 1, id: "call_2", function: { name: "search" } } + ]); + accumulate(acc, [{ index: 0, function: { arguments: '{"path":"a.ts"}' } }]); + accumulate(acc, [ + { index: 1, function: { arguments: '{"query":"test"}' } } + ]); + + assert.strictEqual(acc[0].name, "read_file"); + assert.strictEqual(acc[0].arguments, '{"path":"a.ts"}'); + assert.strictEqual(acc[1].name, "search"); + assert.strictEqual(acc[1].arguments, '{"query":"test"}'); + }); + + test("filters out entries with no name", () => { + const acc: Record = {}; + + accumulate(acc, [{ index: 0, function: { arguments: "partial" } }]); + + const valid = Object.values(acc).filter((tc) => tc.name); + assert.strictEqual(valid.length, 0); + }); +}); diff --git a/integrations/vscode/src/test/suite/tenureConfig.test.ts b/integrations/vscode/src/test/suite/tenureConfig.test.ts index b44ea78..e620df6 100644 --- a/integrations/vscode/src/test/suite/tenureConfig.test.ts +++ b/integrations/vscode/src/test/suite/tenureConfig.test.ts @@ -3,7 +3,10 @@ import * as vscode from "vscode"; import * as path from "path"; import * as fs from "fs"; import * as os from "os"; -import { readTenureConfig } from "../../tenureConfig.js"; +import { + readTenureConfig, + generateDefaultTenureConfig +} from "../../tenureConfig.js"; suite("tenureConfig", () => { let tmpDir: string; @@ -16,33 +19,192 @@ suite("tenureConfig", () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - test("reads .tenure file", async () => { - fs.writeFileSync(path.join(tmpDir, ".tenure"), "my-project"); + suite(".tenure (legacy plain text)", () => { + test("reads .tenure file", async () => { + fs.writeFileSync(path.join(tmpDir, ".tenure"), "my-project"); + const result = await readTenureConfig(vscode.Uri.file(tmpDir)); + assert.strictEqual(result?.projectId, "my-project"); + }); - const result = await readTenureConfig(vscode.Uri.file(tmpDir)); - assert.strictEqual(result?.projectId, "my-project"); + test("returns null when no .tenure file exists", async () => { + const result = await readTenureConfig(vscode.Uri.file(tmpDir)); + assert.strictEqual(result, null); + }); + + test("returns null for empty string", async () => { + fs.writeFileSync(path.join(tmpDir, ".tenure"), ""); + const result = await readTenureConfig(vscode.Uri.file(tmpDir)); + assert.strictEqual(result, null); + }); + + test("returns null when file contains only whitespace", async () => { + fs.writeFileSync(path.join(tmpDir, ".tenure"), " \n "); + const result = await readTenureConfig(vscode.Uri.file(tmpDir)); + assert.strictEqual(result, null); + }); + + test("trims whitespace from project ID", async () => { + fs.writeFileSync(path.join(tmpDir, ".tenure"), " my-project \n"); + const result = await readTenureConfig(vscode.Uri.file(tmpDir)); + assert.strictEqual(result?.projectId, "my-project"); + }); }); - test("returns null when no .tenure file exists", async () => { - const result = await readTenureConfig(vscode.Uri.file(tmpDir)); - assert.strictEqual(result, null); + suite(".tenure.json (new JSON config)", () => { + test("reads .tenure.json with projectId, ignore, and noiseIgnores", async () => { + const config = { + projectId: "my-app", + ignore: ["customers/**", "contracts/**"], + noiseIgnores: ["*.sql", "infra/prod/**"] + }; + fs.writeFileSync( + path.join(tmpDir, ".tenure.json"), + JSON.stringify(config) + ); + const result = await readTenureConfig(vscode.Uri.file(tmpDir)); + assert.strictEqual(result?.projectId, "my-app"); + assert.deepStrictEqual(result?.ignore, ["customers/**", "contracts/**"]); + assert.deepStrictEqual(result?.noiseIgnores, ["*.sql", "infra/prod/**"]); + }); + + test("reads .tenure.json with projectId only", async () => { + fs.writeFileSync( + path.join(tmpDir, ".tenure.json"), + JSON.stringify({ projectId: "simple" }) + ); + const result = await readTenureConfig(vscode.Uri.file(tmpDir)); + assert.strictEqual(result?.projectId, "simple"); + assert.strictEqual(result?.ignore, undefined); + assert.strictEqual(result?.noiseIgnores, undefined); + }); + + test("reads .tenure.json with empty ignore arrays", async () => { + fs.writeFileSync( + path.join(tmpDir, ".tenure.json"), + JSON.stringify({ projectId: "app", ignore: [], noiseIgnores: [] }) + ); + const result = await readTenureConfig(vscode.Uri.file(tmpDir)); + assert.strictEqual(result?.projectId, "app"); + assert.deepStrictEqual(result?.ignore, []); + assert.deepStrictEqual(result?.noiseIgnores, []); + }); + + test("filters non-string entries from ignore and noiseIgnores", async () => { + const config = { + projectId: "p", + ignore: ["valid", null, 123, true], + noiseIgnores: ["keep", false, 0] + }; + fs.writeFileSync( + path.join(tmpDir, ".tenure.json"), + JSON.stringify(config) + ); + const result = await readTenureConfig(vscode.Uri.file(tmpDir)); + assert.deepStrictEqual(result?.ignore, ["valid"]); + assert.deepStrictEqual(result?.noiseIgnores, ["keep"]); + }); + + test("returns null for .tenure.json without projectId", async () => { + fs.writeFileSync( + path.join(tmpDir, ".tenure.json"), + JSON.stringify({ ignore: ["x"] }) + ); + const result = await readTenureConfig(vscode.Uri.file(tmpDir)); + assert.strictEqual(result, null); + }); + + test("returns null for .tenure.json with empty projectId string", async () => { + fs.writeFileSync( + path.join(tmpDir, ".tenure.json"), + JSON.stringify({ projectId: "" }) + ); + const result = await readTenureConfig(vscode.Uri.file(tmpDir)); + assert.strictEqual(result, null); + }); + + test("returns null for .tenure.json with empty file", async () => { + fs.writeFileSync(path.join(tmpDir, ".tenure.json"), ""); + const result = await readTenureConfig(vscode.Uri.file(tmpDir)); + assert.strictEqual(result, null); + }); + + test("returns null for .tenure.json with malformed JSON", async () => { + fs.writeFileSync(path.join(tmpDir, ".tenure.json"), "{ broken"); + const result = await readTenureConfig(vscode.Uri.file(tmpDir)); + assert.strictEqual(result, null); + }); + + test("returns null for .tenure.json that is a JSON array", async () => { + fs.writeFileSync(path.join(tmpDir, ".tenure.json"), "[1, 2, 3]"); + const result = await readTenureConfig(vscode.Uri.file(tmpDir)); + assert.strictEqual(result, null); + }); + + test("returns null for .tenure.json with projectId that is not a string", async () => { + fs.writeFileSync( + path.join(tmpDir, ".tenure.json"), + JSON.stringify({ projectId: 123 }) + ); + const result = await readTenureConfig(vscode.Uri.file(tmpDir)); + assert.strictEqual(result, null); + }); + }); + + suite("read priority: .tenure.json over .tenure", () => { + test("prefers .tenure.json when both files exist", async () => { + fs.writeFileSync(path.join(tmpDir, ".tenure"), "legacy-project"); + fs.writeFileSync( + path.join(tmpDir, ".tenure.json"), + JSON.stringify({ projectId: "json-project", ignore: ["secrets/**"] }) + ); + const result = await readTenureConfig(vscode.Uri.file(tmpDir)); + assert.strictEqual(result?.projectId, "json-project"); + assert.deepStrictEqual(result?.ignore, ["secrets/**"]); + }); + + test("falls back to .tenure when .tenure.json missing", async () => { + fs.writeFileSync(path.join(tmpDir, ".tenure"), "legacy-project"); + const result = await readTenureConfig(vscode.Uri.file(tmpDir)); + assert.strictEqual(result?.projectId, "legacy-project"); + assert.strictEqual(result?.ignore, undefined); + }); + + test("falls back to .tenure when .tenure.json exists but has no projectId", async () => { + fs.writeFileSync( + path.join(tmpDir, ".tenure.json"), + JSON.stringify({ ignore: ["x"] }) + ); + fs.writeFileSync(path.join(tmpDir, ".tenure"), "fallback-project"); + const result = await readTenureConfig(vscode.Uri.file(tmpDir)); + assert.strictEqual(result?.projectId, "fallback-project"); + }); + }); +}); + +suite("generateDefaultTenureConfig", () => { + test("produces valid JSON with projectId", () => { + const output = generateDefaultTenureConfig("my-app"); + const parsed = JSON.parse(output); + assert.strictEqual(parsed.projectId, "my-app"); + assert.deepStrictEqual(parsed.ignore, []); + assert.deepStrictEqual(parsed.noiseIgnores, []); }); - test("ignores unknown extra fields without erroring", async () => { - fs.writeFileSync(path.join(tmpDir, ".tenure"), "my-project"); - const result = await readTenureConfig(vscode.Uri.file(tmpDir)); - assert.strictEqual(result?.projectId, "my-project"); + test("produces pretty-printed output with 2-space indent", () => { + const output = generateDefaultTenureConfig("app"); + assert.ok(output.includes(' "projectId"')); + assert.ok(output.includes(' "ignore"')); }); - test("returns null for empty JSON object alternative — empty string", async () => { - fs.writeFileSync(path.join(tmpDir, ".tenure"), ""); - const result = await readTenureConfig(vscode.Uri.file(tmpDir)); - assert.strictEqual(result, null); + test("ends with a newline", () => { + const output = generateDefaultTenureConfig("app"); + assert.ok(output.endsWith("\n")); }); - test("returns null when file contains only whitespace", async () => { - fs.writeFileSync(path.join(tmpDir, ".tenure"), " \n "); - const result = await readTenureConfig(vscode.Uri.file(tmpDir)); - assert.strictEqual(result, null); + test("only contains expected keys", () => { + const output = generateDefaultTenureConfig("app"); + const parsed = JSON.parse(output); + const keys = Object.keys(parsed); + assert.deepStrictEqual(keys, ["projectId", "ignore", "noiseIgnores"]); }); }); diff --git a/integrations/vscode/src/workspaceSync.ts b/integrations/vscode/src/workspaceSync.ts index 7c22f4a..1429fa7 100644 --- a/integrations/vscode/src/workspaceSync.ts +++ b/integrations/vscode/src/workspaceSync.ts @@ -1,11 +1,15 @@ import * as vscode from "vscode"; import { resolveGitRemote } from "./gitResolver.js"; import type { TokenStore } from "./tokenStore.js"; -import { readTenureConfig } from "./tenureConfig.js"; +import { + generateDefaultTenureConfig, + readTenureConfig +} from "./tenureConfig.js"; import { TenureBeliefsViewProvider } from "./beliefsViewProvider.js"; import type { TenureLmProvider } from "./lmProvider.js"; import path, { basename } from "node:path"; import { createHash } from "node:crypto"; +import { getTenureFilePolicyForPath } from "./filePolicy.js"; export interface WorkspaceState { workspace_root: string; @@ -30,7 +34,7 @@ export class WorkspaceSync { private readonly context: vscode.ExtensionContext, private readonly statusBar?: vscode.StatusBarItem, private readonly beliefsProvider?: TenureBeliefsViewProvider, - private readonly lmProvider?: TenureLmProvider, + private readonly lmProvider?: TenureLmProvider ) { this.lastSyncedState = null; @@ -68,22 +72,27 @@ export class WorkspaceSync { sendActiveFileUpdate( activeFile: string, activeLanguage: string, - fileUri: vscode.Uri, + fileUri: vscode.Uri ): void { if (!this.beliefsProvider || !this.cachedProjectName) return; const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; if (!workspaceFolder) return; + const policy = getTenureFilePolicyForPath(activeFile, workspaceFolder.uri); + + const safeActiveFile = policy.suppressMetadata ? null : activeFile; + const safeActiveLanguage = policy.suppressMetadata ? null : activeLanguage; + this.beliefsProvider.sendWorkspaceState({ workspace_root: workspaceFolder.uri.fsPath, project_name: this.cachedProjectName, git_remote: this.cachedGitRemote, - active_file: activeFile, - active_language: activeLanguage, + active_file: safeActiveFile, + active_language: safeActiveLanguage }); - if (fileUri.scheme === "file") { + if (!policy.suppressContent && fileUri.scheme === "file") { vscode.workspace.fs.stat(fileUri).then((stat) => { this.beliefsProvider?.sendFileMeta(activeFile, stat.size); }); @@ -124,12 +133,26 @@ export class WorkspaceSync { const activeFile = activeFileUri ? toRelativeFile(activeFileUri) : null; const activeLanguage = activeEditor?.document.languageId ?? null; + let safeActiveFile = activeFile; + let safeActiveLanguage = activeLanguage; + + if (activeFileUri) { + const policy = getTenureFilePolicyForPath( + activeFile ?? "", + workspaceFolder.uri + ); + if (policy.suppressMetadata) { + safeActiveFile = null; + safeActiveLanguage = null; + } + } + const state: WorkspaceState = { workspace_root: workspaceFolder.uri.fsPath, project_name: this.cachedProjectName!, git_remote: this.cachedGitRemote, - active_file: activeFile, - active_language: activeLanguage, + active_file: safeActiveFile, + active_language: safeActiveLanguage }; const stateKey = JSON.stringify(state); @@ -146,7 +169,7 @@ export class WorkspaceSync { previousState.project_name, state.project_name, token, - baseUrl, + baseUrl ).catch(() => {}); } } @@ -173,7 +196,7 @@ export class WorkspaceSync { private async checkAndPromptOnboarding( baseUrl: string, - token: string, + token: string ): Promise { if (this.onboardingPromptShown) return; @@ -181,12 +204,12 @@ export class WorkspaceSync { const [providersRes, cfgRes] = await Promise.all([ fetch(`${baseUrl}/admin/providers`, { headers: { Authorization: `Bearer ${token}` }, - signal: AbortSignal.timeout(10_000), + signal: AbortSignal.timeout(10_000) }), fetch(`${baseUrl}/admin/config`, { headers: { Authorization: `Bearer ${token}` }, - signal: AbortSignal.timeout(10_000), - }), + signal: AbortSignal.timeout(10_000) + }) ]); if (!providersRes.ok || !cfgRes.ok) return; @@ -200,7 +223,7 @@ export class WorkspaceSync { openai_configured: boolean; anthropic_configured: boolean; [key: string]: unknown; - }>, + }> ]); const hasProvider = providersData.providers.some((p) => p.configured); @@ -211,20 +234,20 @@ export class WorkspaceSync { this.onboardingPromptShown = true; const alreadyDismissed = this.context.globalState.get( - "tenure.onboardingNudgeDismissed", + "tenure.onboardingNudgeDismissed" ); if (!alreadyDismissed) { const action = await vscode.window.showInformationMessage( "Tenure: No provider configured. Run setup to connect an LLM and enable memory injection.", "Set up Tenure", - "Dismiss", + "Dismiss" ); if (action === "Set up Tenure") { vscode.commands.executeCommand("tenure.runOnboarding"); } else if (action === "Dismiss") { await this.context.globalState.update( "tenure.onboardingNudgeDismissed", - true, + true ); } } @@ -235,20 +258,20 @@ export class WorkspaceSync { this.onboardingPromptShown = true; const alreadyDismissed = this.context.globalState.get( - "tenure.onboardingNudgeDismissed", + "tenure.onboardingNudgeDismissed" ); if (!alreadyDismissed) { const action = await vscode.window.showInformationMessage( "Tenure: Provider is connected but no model has been selected. Run setup to finish.", "Run Setup", - "Dismiss", + "Dismiss" ); if (action === "Run Setup") { vscode.commands.executeCommand("tenure.runOnboarding"); } else if (action === "Dismiss") { await this.context.globalState.update( "tenure.onboardingNudgeDismissed", - true, + true ); } } @@ -276,20 +299,30 @@ export class WorkspaceSync { } private async checkAndPromptTenureFile( - workspaceRoot: vscode.Uri, + workspaceRoot: vscode.Uri ): Promise { const alreadyPrompted = this.context.globalState.get( - "tenure.tenureFilePromptShown", + "tenure.tenureFilePromptShown" ); if (alreadyPrompted) return; - const tenureFileUri = vscode.Uri.joinPath(workspaceRoot, ".tenure"); + const tenureJsonUri = vscode.Uri.joinPath(workspaceRoot, ".tenure.json"); + const tenureLegacyUri = vscode.Uri.joinPath(workspaceRoot, ".tenure"); try { - await vscode.workspace.fs.stat(tenureFileUri); + await vscode.workspace.fs.stat(tenureJsonUri); await this.context.globalState.update( "tenure.tenureFilePromptShown", - true, + true + ); + return; + } catch {} + + try { + await vscode.workspace.fs.stat(tenureLegacyUri); + await this.context.globalState.update( + "tenure.tenureFilePromptShown", + true ); return; } catch {} @@ -301,19 +334,19 @@ export class WorkspaceSync { await this.context.globalState.update("tenure.tenureFilePromptShown", true); const action = await vscode.window.showInformationMessage( - "Tenure: No `.tenure` file found. Preview one to enable project-scoped memory?", - "Preview `.tenure`", - "Remind me later", + "Tenure: No `.tenure.json` file found. Create one to enable project-scoped memory?", + "Create `.tenure.json`", + "Remind me later" ); - if (action !== "Preview `.tenure`") return; + if (action !== "Create `.tenure.json`") return; const inferredName = this.cachedProjectName ?? slugify(path.basename(workspaceRoot.fsPath)); const doc = await vscode.workspace.openTextDocument({ - content: inferredName, - language: "plaintext", + content: generateDefaultTenureConfig(inferredName), + language: "json" }); await vscode.window.showTextDocument(doc); } @@ -322,19 +355,19 @@ export class WorkspaceSync { oldName: string, newName: string, token: string, - baseUrl: string, + baseUrl: string ): Promise { await fetch(`${baseUrl}/v1/workspace/migrate-scope`, { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${token}` }, body: JSON.stringify({ old_scope: `project:${slugify(oldName)}`, - new_scope: `project:${slugify(newName)}`, + new_scope: `project:${slugify(newName)}` }), - signal: AbortSignal.timeout(5000), + signal: AbortSignal.timeout(5000) }); } }