mirror of
https://github.com/affaan-m/ECC.git
synced 2026-11-09 09:35:59 +00:00
fix(install): harden Antigravity target containment, agent tools, and binary asset copies
This commit is contained in:
1 parent
25278e7d7b
commit
68eb49d175
5 files changed
+275
-11
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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}/`)
|
||||
));
|
||||
|
||||
@@ -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 = '<unknown>') {
|
||||
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 = '<unknown>') {
|
||||
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,
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user