Skip to content
Merged
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
61 changes: 37 additions & 24 deletions packages/runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1274,40 +1274,48 @@ export type {
} from './goal-turn-lifecycle.js';

export {
// skills-metadata
MAX_SKILL_BODY_CHARS,
MAX_SKILL_TOOL_BODY_CHARS,
parseSkillFrontMatter,
validateSkillMetadata,
// skills-state
readSkillRuntimeState,
writeSkillRuntimeState,
writeSkillRuntimePreferences,
// skills-discovery
resolveSkillDiscoveryPaths,
scanSkills,
scanSkillsWithDiagnostics,
scanWorkspaceSkills,
scanWorkspaceSkillsWithDiagnostics,
// skills-context
MAX_SKILLS_PROMPT_CHARS,
MIN_SKILLS_PROMPT_TOKENS,
MAX_SKILLS_PROMPT_TOKENS,
SKILLS_PROMPT_CONTEXT_RATIO,
resolveSkillsPromptCharBudget,
scanWorkspaceSkills,
scanWorkspaceSkillsWithDiagnostics,
scanSkills,
scanSkillsWithDiagnostics,
resolveSkillDiscoveryPaths,
buildSkillsPromptFragment,
buildSkillsPromptFragmentWithReport,
selectSkillsForContext,
selectSkillScanForContext,
searchSkills,
loadSkillInstructions,
gateSkillsByHostCapabilities,
// skills-agent-tools
buildSkillAgentTool,
buildSkillSearchAgentTool,
SkillShadowSelectionTracker,
SKILL_TOOL_NAME,
SKILL_SEARCH_TOOL_NAME,
gateSkillsByHostCapabilities,
parseSkillFrontMatter,
validateSkillMetadata,
readSkillRuntimeState,
writeSkillRuntimeState,
writeSkillRuntimePreferences,
} from './skills.js';
export {
// path-containment (contained I/O moved in #1408)
readContainedRegularFile,
readContainedRegularTextFile,
writeContainedRegularTextFile,
isRecord,
} from './skills.js';
} from './path-containment.js';
export {
listInvocableSkills,
resolveSkillInvocations,
Expand Down Expand Up @@ -1337,21 +1345,29 @@ export {
} from './path-containment.js';
export type { PathInsideApi } from './path-containment.js';
export type {
// skills-state
SkillRuntimeStatus,
SkillRuntimePreference,
SkillRuntimeStateReadResult,
// skills-discovery
SkillScope,
SkillDiscoverySource,
SkillRuntimePreference,
SkillDiscoveryEntry,
SkillSource,
SkillSourceResolver,
RuntimeSkillDefinition,
ScannedSkill,
SkillScanDiagnostic,
SkillScanResult,
SkillDiscoveryDiagnostic,
RejectedSkillDefinition,
// skills-metadata
SkillManifest,
SkillValidationSeverity,
SkillValidationCode,
SkillValidationIssue,
SkillMetadataValidationResult,
SkillScanDiagnostic,
SkillScanResult,
SkillDiscoveryDiagnostic,
RejectedSkillDefinition,
RuntimeSkillDefinition,
ScannedSkill,
// skills-context
HostCapabilities,
HostCapabilitiesResolver,
SkillCatalogBudgetOptions,
Expand All @@ -1362,13 +1378,10 @@ export type {
SkillsPromptFragmentResult,
SkillSearchMatch,
SkillSearchResult,
SkillToolOptions,
SkillHostCompatibility,
GatedSkill,
LoadedSkillInstructions,
LoadSkillInstructionsResult,
SkillRuntimeStateReadResult,
SkillSource,
SkillSourceResolver,
SkillDiscoveryEntry,
// skills-agent-tools
SkillToolOptions,
} from './skills.js';
102 changes: 101 additions & 1 deletion packages/runtime/src/path-containment.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { isAbsolute, relative, sep } from 'node:path';
import { createHash } from 'node:crypto';
import { lstat, mkdir, readFile, realpath, rename, unlink, writeFile } from 'node:fs/promises';
import { isAbsolute, join, relative, sep } from 'node:path';

/**
* Shared filesystem-containment and identifier guards. This is the single
Expand Down Expand Up @@ -55,3 +57,101 @@ export function toRelative(root: string, target: string): string {
const rel = relative(root, target);
return rel === '' ? '.' : rel.split(sep).join('/');
}

// ── Contained file I/O ────────────────────────────────────────────────────

/**
* Read a regular file, verifying its realpath stays inside `rootDir`.
* Rejects symlinks and paths that escape the containment root.
*/
export async function readContainedRegularFile(
rootDir: string,
filePath: string,
): Promise<{ ok: true; bytes: Buffer } | { ok: false }> {
try {
const [rootReal, fileStat] = await Promise.all([realpath(rootDir), lstat(filePath)]);
if (!fileStat.isFile() || fileStat.isSymbolicLink()) return { ok: false };
const fileReal = await realpath(filePath);
if (!isPathInside(rootReal, fileReal)) return { ok: false };
return { ok: true, bytes: await readFile(filePath) };
} catch {
return { ok: false };
}
}

/**
* Read a regular text file, verifying its realpath stays inside `rootDir`.
* Returns the content and its sha256 hash.
*/
export async function readContainedRegularTextFile(
rootDir: string,
filePath: string,
): Promise<
| { ok: true; content: string; sha256: string }
| { ok: false; reason: 'blocked_path' | 'read_failed' }
> {
try {
const [rootReal, fileStat] = await Promise.all([realpath(rootDir), lstat(filePath)]);
if (!fileStat.isFile() || fileStat.isSymbolicLink())
return { ok: false, reason: 'blocked_path' };
const fileReal = await realpath(filePath);
if (!isPathInside(rootReal, fileReal)) return { ok: false, reason: 'blocked_path' };
const content = await readFile(filePath, 'utf8');
return { ok: true, content, sha256: `sha256:${sha256(content)}` };
} catch {
return { ok: false, reason: 'read_failed' };
}
}

/**
* Atomically write a text file inside `rootDir` via a temp file + rename.
* Rejects symlinks and paths that escape the containment root.
*/
export async function writeContainedRegularTextFile(
rootDir: string,
filePath: string,
content: string,
): Promise<boolean> {
const tempPath = join(rootDir, `.maka-write.${process.pid}.${Date.now()}.tmp`);
try {
const rootReal = await realpath(rootDir);
const existing = await lstat(filePath).catch((error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') return null;
throw error;
});
if (existing !== null && (!existing.isFile() || existing.isSymbolicLink())) return false;
if (existing !== null) {
const fileReal = await realpath(filePath);
if (!isPathInside(rootReal, fileReal)) return false;
}
await writeFile(tempPath, content, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
const tempStat = await lstat(tempPath);
if (!tempStat.isFile() || tempStat.isSymbolicLink()) {
await unlink(tempPath).catch(() => {});
return false;
}
const tempReal = await realpath(tempPath);
if (!isPathInside(rootReal, tempReal)) {
await unlink(tempPath).catch(() => {});
return false;
}
await rename(tempPath, filePath);
return true;
} catch {
await unlink(tempPath).catch(() => {});
return false;
}
}

// ── Generic type guard ────────────────────────────────────────────────────

/** True when `value` is a plain record (object, not array, not null). */
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

// ── Crypto helper ─────────────────────────────────────────────────────────

function sha256(text: string): string {
return createHash('sha256').update(text).digest('hex');
}
5 changes: 2 additions & 3 deletions packages/runtime/src/skill-invocation.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import {
gateSkillsByHostCapabilities,
loadSkillInstructionsFromScan,
scanSkills,
type HostCapabilities,
type LoadedSkillInstructions,
type LoadSkillInstructionsResult,
type SkillSource,
} from './skills.js';
} from './skills-context.js';
import { scanSkills, type SkillSource } from './skills-discovery.js';
import {
boundSkillInvocationRequest,
failedSkillInvocationReceipt,
Expand Down
Loading
Loading