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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@ new version heading in the same commit.

## [Unreleased]

## [0.78.1] — 2026-07-10
### Fixed
- **Per-member agent copies no longer dangle their shared-bundle symlinks under uid isolation.**
`syncAgentDir` copies an agent's dir into the member home with `cp -a` (symlinks preserved as relative
links). An agent whose tools are relative symlinks to a shared bundle (`iwp -> ../../tools/iwp`,
`tools -> ../../tools/tools`, `eng-repo`, …) then broke at the new location, because
`<stateRoot>/<member>/agents/<agent>/../../tools` resolves under the member home where nothing exists —
so every `./iwp` / `bash tools/…` / `./eng-repo` would fail the moment `AOS_UID_ISOLATION` is enabled.
After the full copy, `resolveExternalSymlinks` now repoints each symlink that escapes the agent dir to
an ABSOLUTE target resolved against the original source (in-tree links are left relative). Pure helper
`externalSymlinkAbsoluteTarget` decides per link. (The member uid still needs read access to the shared
bundle — this fixes path resolution, not bundle permissions.) Test: `npm run test:symlink-isolation`.


## [0.78.0] — 2026-07-10
### Added
- **Host credential injection (Phase 2c) — a granted SSH host's key is now delivered to the agent's
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agent-os",
"version": "0.78.0",
"version": "0.78.1",
"description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.",
"license": "MIT",
"type": "commonjs",
Expand All @@ -24,7 +24,8 @@
"demo:dev": "ts-node src/demo.ts",
"test:governance": "node scripts/governance-conformance.cjs",
"test:enrich": "node scripts/test-enrich-patterns.cjs",
"test:context": "node scripts/context-injection-test.cjs"
"test:context": "node scripts/context-injection-test.cjs",
"test:symlink-isolation": "node scripts/test-symlink-isolation.cjs"
},
"keywords": [
"agents",
Expand Down
31 changes: 31 additions & 0 deletions scripts/test-symlink-isolation.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/usr/bin/env node
/**
* Unit test for `externalSymlinkAbsoluteTarget` — decides whether a copied agent symlink escapes the
* agent dir (the shared tool bundle) and, if so, the absolute target to repoint it at so it survives the
* per-member copy under uid isolation. Run: node scripts/test-symlink-isolation.cjs
*/
const path = require('path');
const assert = require('assert');
const { externalSymlinkAbsoluteTarget } = require(path.join(__dirname, '..', 'dist/edge/launcher'));

const SRC = '/srv/aos/data/agents/billing-ops';
let pass = 0;
const eq = (got, want, label) => { assert.strictEqual(got, want, `FAIL ${label}: got ${got}`); pass++; console.log(' ok ' + label); };

// Shared-bundle symlinks (the real case) → rewrite to absolute, resolved against the source.
eq(externalSymlinkAbsoluteTarget(SRC, 'iwp', '../../tools/iwp'), '/srv/aos/data/tools/iwp', 'iwp → absolute bundle path');
eq(externalSymlinkAbsoluteTarget(SRC, 'tools', '../../tools/tools'), '/srv/aos/data/tools/tools', 'tools/ dir symlink → absolute');
eq(externalSymlinkAbsoluteTarget(SRC, 'eng-repo', '../../tools/eng-repo'), '/srv/aos/data/tools/eng-repo', 'eng-repo → absolute');

// Absolute link → leave alone.
eq(externalSymlinkAbsoluteTarget(SRC, 'iwp', '/opt/tools/iwp'), null, 'already-absolute → null');

// In-tree relative link (stays valid after the copy) → leave alone.
eq(externalSymlinkAbsoluteTarget(SRC, '.claude/skills/x', '../../CLAUDE.md'), null, 'in-tree link (resolves inside agent dir) → null');
eq(externalSymlinkAbsoluteTarget(SRC, 'a/b', '../c'), null, 'in-tree sibling link → null');

// Degenerate: link to the agent dir itself → treated as in-tree (null).
eq(externalSymlinkAbsoluteTarget(SRC, 'self', '.'), null, 'link to agent dir itself → null');

console.log(`\nsymlink-isolation: ${pass}/7 checks passed`);
process.exit(0);
49 changes: 49 additions & 0 deletions src/edge/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,28 @@ const MEMBER_RE = /^[a-z0-9][a-z0-9_-]{0,39}$/; // also a safe path segmen
const SESSION_RE = /^[a-z0-9]{1,32}$/i;
const TMUX_RE = /^aos-[a-z0-9]{1,32}$/i;
const AGENT_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i; // agent id = a folder name; safe path segment

/**
* A per-member agent working copy is made with `cp -a` (symlinks preserved as-is). A RELATIVE symlink
* that points OUTSIDE the agent's own dir — e.g. the shared tool bundle `iwp -> ../../tools/iwp` — then
* dangles at the new location, because `<stateRoot>/<member>/agents/<agent>/../../tools` resolves under
* the member home, where nothing exists. Given the ORIGINAL agent source dir, a symlink's path within it,
* and its (relative) target, return the ABSOLUTE target to repoint it at (resolved against the source),
* or `null` to leave it alone — an absolute link, or an in-tree link that still resolves after the copy.
*/
export function externalSymlinkAbsoluteTarget(
agentSrcDir: string,
symlinkRelPath: string,
linkTarget: string,
): string | null {
if (path.isAbsolute(linkTarget)) return null;
const resolved = path.resolve(path.dirname(path.join(agentSrcDir, symlinkRelPath)), linkTarget);
const rel = path.relative(agentSrcDir, resolved);
// rel === '' → the agent dir itself; a sub-path (not starting with '..') → in-tree. Either way the link
// does not escape, so it still resolves after the copy — leave it. Only a '..'-escaping link is rewritten.
const insideAgentDir = rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
return insideAgentDir ? null : resolved;
}
/** env keys the app may set on a session (everything else is dropped). */
export const ENV_ALLOWLIST = new Set([
'AOS_URL', 'SESSION', 'AGENT', 'TASK_B64', 'AOS_SECRET', 'CLAUDE_SESSION_ID',
Expand Down Expand Up @@ -351,6 +373,7 @@ export class LauncherDaemon {
this.exec('mkdir', ['-p', work]);
if (firstTime) {
this.exec('cp', ['-a', `${src}/.`, `${work}/`]);
this.resolveExternalSymlinks(src, work);
} else {
if (fs.existsSync(path.join(src, 'CLAUDE.md'))) this.exec('cp', ['-a', path.join(src, 'CLAUDE.md'), `${work}/`]);
const srcSkills = path.join(src, '.claude', 'skills');
Expand All @@ -365,6 +388,32 @@ export class LauncherDaemon {
return work;
}

/**
* After a full copy, repoint every symlink that escapes the agent dir (e.g. the shared tool bundle,
* `iwp -> ../../tools/iwp`) to an ABSOLUTE target resolved against the ORIGINAL source — so `./iwp`,
* `./eng-repo`, `bash tools/…` keep resolving from the per-member working copy under `<stateRoot>/…`
* where the relative `../../tools` would otherwise dangle. In-tree relative symlinks are left as-is.
* (The member uid still needs read access to the shared bundle; this only fixes the path resolution.)
*/
private resolveExternalSymlinks(src: string, work: string): void {
const walk = (dir: string): void => {
let entries: fs.Dirent[];
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
const wpath = path.join(dir, e.name);
if (e.isSymbolicLink()) {
let target: string;
try { target = fs.readlinkSync(wpath); } catch { continue; }
const abs = externalSymlinkAbsoluteTarget(src, path.relative(work, wpath), target);
if (abs) this.exec('ln', ['-sfn', abs, wpath]);
} else if (e.isDirectory()) {
walk(wpath);
}
}
};
walk(work);
}

/** The member holder's live (dynamic) uid, via the injected resolver or systemd MainPID → /proc. */
private resolveUid(member: string): number | null {
if (this.opts.resolveUid) return this.opts.resolveUid(member);
Expand Down
Loading