From 68eb49d175875b8a8fb218fe2c5b3b3d2ae2a609 Mon Sep 17 00:00:00 2001 From: minteeqahmed-png Date: Thu, 29 Oct 2026 21:37:41 +0500 Subject: [PATCH] fix(install): harden Antigravity target containment, agent tools, and binary asset copies --- scripts/lib/agent-tools.js | 22 +- .../install-targets/antigravity-project.js | 1 + scripts/lib/install/antigravity-agent.js | 24 +- scripts/lib/install/plan.js | 9 +- tests/lib/antigravity-install-target.test.js | 230 ++++++++++++++++++ 5 files changed, 275 insertions(+), 11 deletions(-) create mode 100644 tests/lib/antigravity-install-target.test.js diff --git a/scripts/lib/agent-tools.js b/scripts/lib/agent-tools.js index 8b810c885..8b6689013 100644 --- a/scripts/lib/agent-tools.js +++ b/scripts/lib/agent-tools.js @@ -1,9 +1,29 @@ 'use strict'; +function hasSingleSurroundingQuotePair(trimmed, quote) { + let escaped = false; + for (let index = 1; index < trimmed.length - 1; index += 1) { + const character = trimmed[index]; + if (escaped) { + escaped = false; + } else if (character === '\\') { + escaped = true; + } else if (character === quote) { + return false; + } + } + return !escaped; +} + function stripSurroundingQuotes(value) { const trimmed = value.trim(); const quote = trimmed[0]; - if ((quote === '"' || quote === "'") && trimmed.endsWith(quote)) { + if ( + trimmed.length >= 2 + && (quote === '"' || quote === "'") + && trimmed.endsWith(quote) + && hasSingleSurroundingQuotePair(trimmed, quote) + ) { return trimmed.slice(1, -1).trim(); } return trimmed; diff --git a/scripts/lib/install-targets/antigravity-project.js b/scripts/lib/install-targets/antigravity-project.js index 7861bbc25..01d3c8d38 100644 --- a/scripts/lib/install-targets/antigravity-project.js +++ b/scripts/lib/install-targets/antigravity-project.js @@ -12,6 +12,7 @@ const SUPPORTED_SOURCE_PREFIXES = ['rules', 'commands', 'agents', 'skills']; function supportsAntigravitySourcePath(sourceRelativePath) { const normalizedPath = normalizeRelativePath(sourceRelativePath); + if (normalizedPath.split('/').includes('..')) return false; return SUPPORTED_SOURCE_PREFIXES.some(prefix => ( normalizedPath === prefix || normalizedPath.startsWith(`${prefix}/`) )); diff --git a/scripts/lib/install/antigravity-agent.js b/scripts/lib/install/antigravity-agent.js index 7e4f7a962..392e12b69 100644 --- a/scripts/lib/install/antigravity-agent.js +++ b/scripts/lib/install/antigravity-agent.js @@ -1,9 +1,14 @@ 'use strict'; +const { normalizeAgentTools } = require('../agent-tools'); + const TOOL_NAMES = Object.freeze({ Read: 'view_file', + ReadFile: 'view_file', + NotebookRead: 'view_file', Write: 'write_to_file', Edit: 'replace_file_content', + MultiEdit: 'replace_file_content', Grep: 'grep_search', Glob: 'find_by_name', Bash: 'run_command', @@ -18,7 +23,10 @@ const MODEL_NAMES = Object.freeze({ }); function splitFrontmatter(source, label) { - const match = String(source || '').match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/); + const raw = String(source || ''); + const usesCrlf = raw.includes('\r\n'); + const normalized = usesCrlf ? raw.replace(/\r\n/g, '\n') : raw; + const match = normalized.match(/^---\n([\s\S]*?)\n---\n/); if (!match) { throw new Error(`Cannot adapt Antigravity agent ${label}: missing YAML frontmatter`); } @@ -33,20 +41,19 @@ function splitFrontmatter(source, label) { return { frontmatter, - body: source.slice(match[0].length), + body: normalized.slice(match[0].length), + usesCrlf, }; } function normalizeToolNames(value) { - const names = Array.isArray(value) ? value : String(value || '').split(','); - return [...new Set(names - .map(name => String(name).trim()) + return [...new Set(normalizeAgentTools(value) .filter(name => Object.hasOwn(TOOL_NAMES, name)) .map(name => TOOL_NAMES[name]))]; } function adaptAntigravityAgent(source, label = '') { - const { frontmatter, body } = splitFrontmatter(source, label); + const { frontmatter, body, usesCrlf } = splitFrontmatter(source, label); const { color: _claudeColor, ...supportedFrontmatter } = frontmatter; const adapted = { ...supportedFrontmatter }; if (Object.hasOwn(frontmatter, 'tools')) { @@ -60,9 +67,12 @@ function adaptAntigravityAgent(source, label = '') { const serialized = require('js-yaml') .dump(adapted, { lineWidth: -1, noRefs: true }) .trimEnd(); - return `---\n${serialized}\n---\n${body}`; + const output = `---\n${serialized}\n---\n${body}`; + return usesCrlf ? output.replace(/\n/g, '\r\n') : output; } module.exports = { adaptAntigravityAgent, + MODEL_NAMES, + TOOL_NAMES, }; diff --git a/scripts/lib/install/plan.js b/scripts/lib/install/plan.js index d8a8e93ff..757f1444b 100644 --- a/scripts/lib/install/plan.js +++ b/scripts/lib/install/plan.js @@ -104,10 +104,13 @@ function buildCopyFileOperation({ strategy, contentTransform, }) { - // Copilot's agent and workflow transforms are textual. Their directories may - // also contain binary assets and executable helpers, which must keep bytes. + // Agent frontmatter and workflow transforms are textual Markdown projections. + // Their directories may also contain binary assets and executable helpers, + // which must keep their bytes intact. const applicableTransform = (contentTransform === 'copilot-workflow-paths' - || contentTransform === 'copilot-agent-frontmatter') + || contentTransform === 'copilot-agent-frontmatter' + || contentTransform === 'antigravity-agent-frontmatter' + || contentTransform === 'qwen-agent-frontmatter') && !/\.md$/i.test(sourceRelativePath) ? undefined : contentTransform; return { kind: 'copy-file', diff --git a/tests/lib/antigravity-install-target.test.js b/tests/lib/antigravity-install-target.test.js new file mode 100644 index 000000000..110ae579f --- /dev/null +++ b/tests/lib/antigravity-install-target.test.js @@ -0,0 +1,230 @@ +/** + * Contract tests for the Antigravity install target and agent frontmatter transform. + */ + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const REPO_ROOT = path.resolve(__dirname, '..', '..'); +const { adaptAntigravityAgent } = require(path.join(REPO_ROOT, 'scripts', 'lib', 'install', 'antigravity-agent')); +const antigravityAdapter = require(path.join(REPO_ROOT, 'scripts', 'lib', 'install-targets', 'antigravity-project')); +const { getInstallTargetAdapter } = require(path.join(REPO_ROOT, 'scripts', 'lib', 'install-targets', 'registry')); +const { SUPPORTED_INSTALL_TARGETS } = require(path.join(REPO_ROOT, 'scripts', 'lib', 'install-manifests')); + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` \u2713 ${name}`); + passed += 1; + } catch (error) { + console.log(` \u2717 ${name}`); + console.log(` Error: ${error.message}`); + failed += 1; + } +} + +const CLAUDE_AGENT = [ + '---', + 'name: code-simplifier', + 'description: Refactoring and simplification specialist.', + 'tools: Read, ReadFile, NotebookRead, Edit, MultiEdit, Write, Grep, Glob, Bash, WebSearch, WebFetch, Task', + 'model: opus', + 'color: teal', + '---', + '', + '## Role', + '', + 'You simplify complex code.', + '', +].join('\n'); + +function planFor(modules) { + return antigravityAdapter.planOperations({ + repoRoot: '/repo', + projectRoot: '/project', + homeDir: '/home/user', + modules, + }); +} + +console.log('=== Testing Antigravity install target ===\n'); + +test('registry resolves the antigravity target and adapter id', () => { + assert.strictEqual(getInstallTargetAdapter('antigravity').id, 'antigravity-project'); + assert.strictEqual(getInstallTargetAdapter('antigravity-project').id, 'antigravity-project'); + assert.strictEqual(antigravityAdapter.target, 'antigravity'); + assert.strictEqual(antigravityAdapter.kind, 'project'); +}); + +test('antigravity is a supported install target', () => { + assert.ok(SUPPORTED_INSTALL_TARGETS.includes('antigravity')); +}); + +test('transform maps Claude tools (including MultiEdit/ReadFile/NotebookRead), maps model tiers, and strips color', () => { + const yaml = require('js-yaml'); + const adapted = adaptAntigravityAgent(CLAUDE_AGENT, 'agents/code-simplifier.md'); + const match = adapted.match(/^---\n([\s\S]*?)\n---\n/); + assert.ok(match, 'Expected YAML frontmatter'); + const parsed = yaml.load(match[1]); + + assert.strictEqual(parsed.name, 'code-simplifier'); + assert.strictEqual(parsed.description, 'Refactoring and simplification specialist.'); + assert.strictEqual(parsed.model, 'pro'); + assert.strictEqual(Object.hasOwn(parsed, 'color'), false); + assert.deepStrictEqual(parsed.tools, [ + 'view_file', + 'replace_file_content', + 'write_to_file', + 'grep_search', + 'find_by_name', + 'run_command', + 'search_web', + 'read_url_content', + ]); + assert.ok(adapted.includes('## Role')); + assert.ok(adapted.includes('You simplify complex code.')); +}); + +test('transform parses quoted tool scalars and maps haiku to flash', () => { + const yaml = require('js-yaml'); + const source = [ + '---', + 'name: fast-helper', + 'description: Quick lookup agent', + 'tools: \'"Read", "MultiEdit"\'', + 'model: haiku', + '---', + '', + 'Body', + '', + ].join('\n'); + const adapted = adaptAntigravityAgent(source, 'agents/fast-helper.md'); + const parsed = yaml.load(adapted.match(/^---\n([\s\S]*?)\n---\n/)[1]); + assert.strictEqual(parsed.model, 'flash'); + assert.deepStrictEqual(parsed.tools, ['view_file', 'replace_file_content']); +}); + +test('transform preserves CRLF line endings consistently on Windows-saved agents', () => { + const crlfSource = [ + '---', + 'name: windows-agent', + 'description: Windows line endings', + 'tools: Read, MultiEdit', + 'model: sonnet', + 'color: purple', + '---', + '', + 'Body line 1', + 'Body line 2', + ].join('\r\n'); + + const adapted = adaptAntigravityAgent(crlfSource, 'agents/windows-agent.md'); + assert.ok(adapted.includes('\r\n'), 'Expected CRLF line endings to be preserved'); + assert.ok(!/[^\r]\n/.test(adapted), 'Expected no mixed bare LF line endings in CRLF output'); + assert.ok(!adapted.includes('color:')); + assert.ok(adapted.includes('replace_file_content')); +}); + +test('transform rejects missing or non-object frontmatter', () => { + assert.throws( + () => adaptAntigravityAgent('# no frontmatter\n', 'agents/broken.md'), + /missing YAML frontmatter/ + ); + assert.throws( + () => adaptAntigravityAgent('---\n- item\n---\nbody\n', 'agents/broken.md'), + /frontmatter must be an object/ + ); +}); + +test('traversal segments produce no planned operations', () => { + for (const value of [ + 'agents/../../escape.md', + 'skills/../escape.md', + 'commands/../../escape.md', + 'rules/../../escape.md', + 'agents\\..\\..\\escape.md', + ]) { + assert.deepStrictEqual(planFor([{ id: 'agents-core', paths: [value] }]), []); + } +}); + +test('every planned operation is contained within .agents', () => { + const operations = planFor([ + { id: 'agents-core', paths: ['agents', '.agents', 'AGENTS.md'] }, + { id: 'commands-core', paths: ['commands'] }, + { id: 'framework-language', paths: ['skills/api-design'] }, + ]); + const root = path.join('/project', '.agents'); + assert.ok(operations.length > 0); + for (const operation of operations) { + const relative = path.relative(root, operation.destinationPath); + assert.ok( + relative && !relative.startsWith('..') && !path.isAbsolute(relative), + `operation escapes .agents: ${operation.destinationPath}` + ); + } +}); + +test('Antigravity installs non-Markdown agent assets byte-for-byte while adapting Markdown agents', () => { + const { createManifestInstallPlan, applyInstallPlan } = require('../../scripts/lib/install-executor'); + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-antigravity-agent-assets-')); + try { + const sourceRoot = path.join(temp, 'source'); + fs.mkdirSync(path.join(sourceRoot, 'manifests'), { recursive: true }); + fs.writeFileSync(path.join(sourceRoot, 'manifests', 'install-modules.json'), JSON.stringify({ + version: 1, + modules: [{ + id: 'fixture', + kind: 'agents', + paths: ['agents'], + targets: ['antigravity'], + dependencies: [], + }], + })); + fs.writeFileSync(path.join(sourceRoot, 'manifests', 'install-profiles.json'), JSON.stringify({ + version: 1, + profiles: {}, + })); + const agentRoot = path.join(sourceRoot, 'agents'); + fs.mkdirSync(path.join(agentRoot, 'assets'), { recursive: true }); + const binary = Buffer.from([0, 255, 254, 128, 42]); + const helper = '# helper text without YAML frontmatter\n'; + fs.writeFileSync(path.join(agentRoot, 'assets', 'icon.png'), binary); + fs.writeFileSync(path.join(agentRoot, 'assets', 'helper.txt'), helper); + fs.writeFileSync(path.join(agentRoot, 'code-simplifier.MD'), CLAUDE_AGENT); + + const plan = createManifestInstallPlan({ + sourceRoot, + target: 'antigravity', + moduleIds: ['fixture'], + projectRoot: path.join(temp, 'project'), + homeDir: path.join(temp, 'home'), + env: {}, + }); + applyInstallPlan(plan); + + const installed = path.join(plan.targetRoot, 'agents'); + assert.deepStrictEqual(fs.readFileSync(path.join(installed, 'assets', 'icon.png')), binary); + assert.strictEqual(fs.readFileSync(path.join(installed, 'assets', 'helper.txt'), 'utf8'), helper); + assert.strictEqual( + fs.readFileSync(path.join(installed, 'code-simplifier.MD'), 'utf8'), + adaptAntigravityAgent(CLAUDE_AGENT, 'agents/code-simplifier.MD') + ); + for (const operation of plan.operations.filter(op => op.sourceRelativePath.replace(/\\/g, '/').includes('/assets/'))) { + assert.ok(!operation.contentTransform, operation.sourceRelativePath); + } + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } +}); + +console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); +process.exit(failed === 0 ? 0 : 1); +