Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false

- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22.14.0
registry-url: https://registry.npmjs.org

- run: npm ci
Expand Down
29 changes: 16 additions & 13 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@open-gitagent/gitagent",
"version": "2.0.1",
"version": "2.0.3",
"description": "A universal git-native multimodal always learning AI Agent (TinyHuman)",
"author": "shreyaskapale",
"license": "MIT",
Expand Down Expand Up @@ -79,5 +79,9 @@
"@types/node": "^22.0.0",
"@types/node-cron": "^3.0.11",
"typescript": "^5.7.0"
},
"overrides": {
"basic-ftp": "^6.0.1",
"uuid": "^11.1.1"
}
}
7 changes: 5 additions & 2 deletions src/chat-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import { query } from "./sdk.js";
/** Types we skip — too large or ephemeral */
const SKIP_TYPES = new Set(["audio_delta", "agent_thinking"]);

function sanitizeBranch(branch: string): string {
return branch.replace(/\//g, "__");
export function sanitizeBranch(branch: string): string {
// Allowlist safe filename characters — anything else becomes "__". This
// can never yield a path separator ("/" or "\") or a "." segment, so the
// result can never traverse outside historyDir, and it is trivial to audit.
return branch.replace(/[^a-zA-Z0-9_-]/g, "__");
}

function historyDir(agentDir: string): string {
Expand Down
6 changes: 6 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ export interface EnvConfig {
[key: string]: any;
}

const UNSAFE_MERGE_KEYS = new Set(["__proto__", "constructor", "prototype"]);

function deepMerge(base: Record<string, any>, override: Record<string, any>): Record<string, any> {
const result = { ...base };
for (const key of Object.keys(override)) {
if (UNSAFE_MERGE_KEYS.has(key)) continue;
if (
result[key] &&
typeof result[key] === "object" &&
Expand Down Expand Up @@ -47,6 +50,9 @@ export async function loadEnvConfig(agentDir: string, env?: string): Promise<Env
const base = await loadYamlFile(join(configDir, "default.yaml"));

if (envName) {
if (!/^[a-zA-Z0-9_-]+$/.test(envName)) {
throw new Error(`Invalid environment name "${envName}" — only letters, digits, "-", and "_" are allowed`);
}
const envOverride = await loadYamlFile(join(configDir, `${envName}.yaml`));
return deepMerge(base, envOverride) as EnvConfig;
}
Expand Down
4 changes: 2 additions & 2 deletions src/context.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readFileSync, existsSync } from "fs";
import { join } from "path";
import { loadHistory } from "./chat-history.js";
import { loadHistory, sanitizeBranch } from "./chat-history.js";
import type { ServerMessage } from "./adapter.js";

/** Token estimate: ~4 chars per token */
Expand Down Expand Up @@ -40,7 +40,7 @@ function findMemory(agentDir: string): string {

/** Read the chat summary file for a branch */
function readSummary(agentDir: string, branch: string): string {
const safeBranch = branch.replace(/\//g, "__");
const safeBranch = sanitizeBranch(branch);
const path = join(agentDir, ".gitagent", `chat-summary-${safeBranch}.md`);
return safeRead(path);
}
Expand Down
12 changes: 10 additions & 2 deletions src/knowledge.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { readFile, readdir } from "fs/promises";
import { join } from "path";
import { join, resolve, relative, isAbsolute, sep } from "path";
import yaml from "js-yaml";

export interface KnowledgeEntry {
Expand Down Expand Up @@ -40,9 +40,17 @@ export async function loadKnowledge(agentDir: string): Promise<LoadedKnowledge>
const available: KnowledgeEntry[] = [];

for (const entry of index.entries) {
// Reject any entry whose path escapes knowledgeDir — applied to both
// always_load and on-demand (available) entries, so a malicious
// index.yaml can't be surfaced for later reads either.
const resolvedPath = resolve(join(knowledgeDir, entry.path));
const rel = relative(resolve(knowledgeDir), resolvedPath);
if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) {
continue; // entry.path escapes knowledgeDir — skip
}
if (entry.always_load) {
try {
const content = await readFile(join(knowledgeDir, entry.path), "utf-8");
const content = await readFile(resolvedPath, "utf-8");
preloaded.push({ path: entry.path, content: content.trim() });
} catch {
// Skip missing files
Expand Down
21 changes: 16 additions & 5 deletions src/loader.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { readFile, mkdir, writeFile } from "fs/promises";
import { join } from "path";
import { randomUUID } from "crypto";
import { execSync } from "child_process";
import { execFileSync } from "child_process";
import { getModel } from "@mariozechner/pi-ai";
import type { Model } from "@mariozechner/pi-ai";
import yaml from "js-yaml";
Expand Down Expand Up @@ -142,9 +142,12 @@ export interface LoadedAgent {
plugins: LoadedPlugin[];
}

const UNSAFE_MERGE_KEYS = new Set(["__proto__", "constructor", "prototype"]);

function deepMerge(base: Record<string, any>, override: Record<string, any>): Record<string, any> {
const result = { ...base };
for (const key of Object.keys(override)) {
if (UNSAFE_MERGE_KEYS.has(key)) continue;
if (
result[key] &&
typeof result[key] === "object" &&
Expand Down Expand Up @@ -173,11 +176,14 @@ async function resolveInheritance(
await mkdir(depsDir, { recursive: true });

// Clone parent into .gitagent/deps/
const parentName = manifest.extends.split("/").pop()?.replace(/\.git$/, "") || "parent";
const rawParentName = manifest.extends.split("/").pop()?.replace(/\.git$/, "") || "parent";
// Strip anything that isn't a safe identifier char — handles backslashes,
// "..", etc. that could otherwise survive the forward-slash-only split above.
const parentName = rawParentName.replace(/[^a-zA-Z0-9_-]/g, "_") || "parent";
const parentDir = join(depsDir, parentName);

try {
execSync(`git clone --depth 1 "${manifest.extends}" "${parentDir}" 2>/dev/null || true`, {
execFileSync("git", ["clone", "--depth", "1", manifest.extends, parentDir], {
cwd: agentDir,
stdio: "pipe",
});
Expand Down Expand Up @@ -221,10 +227,15 @@ async function resolveDependencies(
await mkdir(depsDir, { recursive: true });

for (const dep of manifest.dependencies) {
if (!/^[a-zA-Z0-9_-]+$/.test(dep.name)) {
console.warn(`Skipping dependency with invalid name "${dep.name}" — only letters, digits, "-", and "_" are allowed`);
continue;
}
const depDir = join(depsDir, dep.name);
try {
execSync(
`git clone --depth 1 --branch "${dep.version}" "${dep.source}" "${depDir}" 2>/dev/null || true`,
execFileSync(
"git",
["clone", "--depth", "1", "--branch", dep.version, dep.source, depDir],
{ cwd: agentDir, stdio: "pipe" },
);
} catch {
Expand Down
4 changes: 4 additions & 0 deletions src/plugin-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ async function handleRemove(agentDir: string, args: string[]): Promise<void> {
console.error(red("Usage: gitagent plugin remove <name>"));
process.exit(1);
}
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) {
console.error(red("Plugin name must be kebab-case (e.g., my-plugin)"));
process.exit(1);
}

// Try local first, then installed
const localDir = join(agentDir, "plugins", name);
Expand Down
92 changes: 59 additions & 33 deletions src/plugins.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { readFile, readdir, stat, mkdir, rm } from "fs/promises";
import { join } from "path";
import { join, resolve, relative, isAbsolute, sep } from "path";
import { execFileSync } from "child_process";
import { createRequire } from "module";
import { homedir } from "os";
Expand All @@ -19,6 +19,16 @@ import type { SkillMetadata } from "./skills.js";
const require = createRequire(import.meta.url);
const { version: GITAGENT_VERSION } = require("../package.json");

// Resolves relPath against baseDir and returns the result only if it stays
// within baseDir — guards against a plugin manifest (author-controlled,
// untrusted for third-party plugins) pointing outside its own directory.
function resolveWithinDir(baseDir: string, relPath: string): string | null {
const resolvedPath = resolve(join(baseDir, relPath));
const rel = relative(resolve(baseDir), resolvedPath);
if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) return null;
return resolvedPath;
}

const KEBAB_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;

// ── Engine version check ────────────────────────────────────────────────
Expand Down Expand Up @@ -134,7 +144,10 @@ export async function installPlugin(
await mkdir(targetDir, { recursive: true });

// Derive plugin name from source
const name = source.split("/").pop()?.replace(/\.git$/, "") || "plugin";
const rawName = source.split("/").pop()?.replace(/\.git$/, "") || "plugin";
// Normalize to lowercase kebab-case so the derived name always satisfies
// KEBAB_RE at load time (avoids "installs but fails to load" mismatches).
const name = rawName.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "plugin";
const pluginDir = join(targetDir, name);

if (await dirExists(pluginDir)) {
Expand Down Expand Up @@ -227,47 +240,56 @@ async function loadPlugin(
// Load prompt addition
let promptAddition = "";
if (manifest.provides?.prompt) {
try {
promptAddition = await readFile(join(pluginDir, manifest.provides.prompt), "utf-8");
} catch {
// Prompt file not found, skip
const promptPath = resolveWithinDir(pluginDir, manifest.provides.prompt);
if (promptPath) {
try {
promptAddition = await readFile(promptPath, "utf-8");
} catch {
// Prompt file not found, skip
}
} else {
console.warn(`Plugin "${manifest.id}": provides.prompt escapes plugin directory — skipping`);
}
}

// Load programmatic entry point
let programmaticTools: any[] = [];
let memoryLayers: import("./plugin-types.js").MemoryLayerDef[] = [];
if (manifest.entry) {
try {
const { createPluginApi } = await import("./plugin-sdk.js");
const api = createPluginApi(manifest.id, pluginDir, config);
const entryPath = join(pluginDir, manifest.entry);
const mod = await import(entryPath);
if (typeof mod.register === "function") {
await mod.register(api);
} else if (typeof mod.default === "function") {
await mod.default(api);
}
programmaticTools = api.getTools();
// Merge programmatic hooks
const progHooks = api.getHooks();
if (progHooks) {
if (!hooks) hooks = { hooks: {} };
for (const event of ["on_session_start", "pre_tool_use", "post_response", "on_error"] as const) {
if (progHooks[event]) {
hooks.hooks[event] = [...(hooks.hooks[event] || []), ...progHooks[event]!];
const entryPath = resolveWithinDir(pluginDir, manifest.entry);
if (!entryPath) {
console.warn(`Plugin "${manifest.id}": entry escapes plugin directory — skipping`);
} else {
try {
const { createPluginApi } = await import("./plugin-sdk.js");
const api = createPluginApi(manifest.id, pluginDir, config);
const mod = await import(entryPath);
if (typeof mod.register === "function") {
await mod.register(api);
} else if (typeof mod.default === "function") {
await mod.default(api);
}
programmaticTools = api.getTools();
// Merge programmatic hooks
const progHooks = api.getHooks();
if (progHooks) {
if (!hooks) hooks = { hooks: {} };
for (const event of ["on_session_start", "pre_tool_use", "post_response", "on_error"] as const) {
if (progHooks[event]) {
hooks.hooks[event] = [...(hooks.hooks[event] || []), ...progHooks[event]!];
}
}
}
// Merge programmatic prompt
const extraPrompt = api.getPrompt();
if (extraPrompt) {
promptAddition = promptAddition ? `${promptAddition}\n\n${extraPrompt}` : extraPrompt;
}
// Collect memory layers
memoryLayers = api.getMemoryLayers();
} catch (err: any) {
console.warn(`Plugin "${manifest.id}": failed to load entry "${manifest.entry}": ${err.message}`);
}
// Merge programmatic prompt
const extraPrompt = api.getPrompt();
if (extraPrompt) {
promptAddition = promptAddition ? `${promptAddition}\n\n${extraPrompt}` : extraPrompt;
}
// Collect memory layers
memoryLayers = api.getMemoryLayers();
} catch (err: any) {
console.warn(`Plugin "${manifest.id}": failed to load entry "${manifest.entry}": ${err.message}`);
}
}

Expand Down Expand Up @@ -323,6 +345,10 @@ export async function discoverAndLoadPlugins(
for (const [pluginName, pluginConf] of Object.entries(pluginsConfig)) {
// Skip disabled plugins
if (pluginConf.enabled === false) continue;
if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
console.warn(`Skipping plugin with invalid name "${pluginName}" — only letters, digits, "-", and "_" are allowed`);
continue;
}

// Auto-install from source if needed
if (pluginConf.source) {
Expand Down
Loading