santifer/career-ops · warning

symlink traversal test skipped

Error message

symlink traversal test skipped: ${e.message}

What it means

The plugin-loader test suite validates that symlink-based path traversal (an entry or skill symlink escaping the plugin directory) is rejected. The traversal checks are wrapped in try/catch that swaps in a captured console.warn; any unexpected exception inside the block (e.g. filesystem without symlink support, an unexpected throw from validateEntry) downgrades the whole block to this warning instead of failing the run.

Solutions

  1. On Windows, enable Developer Mode or run the terminal as administrator so symlink creation is permitted, or run the suite under WSL/Linux.
  2. If the validator is throwing, fix it to return null for invalid manifests — the test expects a null verdict, not an exception.
  3. Run on a filesystem that supports symlinks; skip acceptance is fine on exotic filesystems.
  4. Inspect e.message in the warning to distinguish an environment limitation (EPERM) from a code bug (unexpected TypeError).

Example fix

// before (fixture setup)
fs.symlinkSync(escapeTarget, linkedPath);
// after
try { fs.symlinkSync(escapeTarget, linkedPath); } catch (e) { if (e.code === 'EPERM') return t.skip('symlinks unavailable'); throw e; }
Defensive patterns

Strategy: try-catch

Validate before calling

let symlinkOk = true; try { const p = join(tmpdir(), 'st'); fs.symlinkSync('target', p); fs.unlinkSync(p); } catch (e) { symlinkOk = e.code !== 'EPERM'; } if (!symlinkOk) console.warn('symlinks unavailable; traversal tests will skip');

Type guard

const canSymlink = () => { try { const p = join(tmpdir(), 'sl-probe'); symlinkSync('x', p); unlinkSync(p); return true; } catch { return false; } };

Try / catch

try { assertSymlinkTraversalRejected(); } catch (e) { warn(`symlink traversal test skipped: ${e.message}`); }

Prevention

When it happens

Trigger: An exception escapes the block that calls the manifest/entry validator with symlink fixtures (skill: 'linked-skill.md', entry under an escaping linked-dir) — e.g. fs.symlinkSync unsupported on the platform (Windows without privileges), or the validator throwing instead of returning null.

Common situations: Windows CI without Developer Mode / symlink privilege (EPERM creating symlinks), filesystems that disallow symlinks (FAT, some containers), or a validator regression that throws rather than returning a rejection verdict.

Understand the failure class

Background: Path traversal blocked: "path escapes the workspace" and "outside site root" errors when a path will not stay inside its allowed directory — this error's family across 26 libraries.

Related errors


AI-assisted analysis of santifer/career-ops@e7abd431fc (2026-09-16). Data as JSON: /api/errors/80ab58abef154520. Report an issue: GitHub.

Appendix: source

Thrown at test-all.mjs:15172

  if (vm({ ...base, requiredEnv: ['X_TOKEN'], allowedHosts: ['api.x.com'] }) !== null) pass('a valid keyed manifest is accepted');
  else fail('valid keyed manifest should be accepted');
  if (vm({ ...base, entry: '../../scan.mjs' }) === null) pass('entry escaping the plugin directory is rejected (traversal guard)');
  else fail('entry traversal should be rejected');
  writeFileSync(join(__manifestTmp, 'outside.mjs'), 'export default {};');
  writeFileSync(join(__manifestTmp, 'outside.md'), '# outside\n');
  mkdirSync(join(__manifestTmp, 'outside-dir'), { recursive: true });
  try {
    symlinkSync(join(__manifestTmp, 'outside.mjs'), join(__manifestTmp, 'x', 'linked-entry.mjs'));
    symlinkSync(join(__manifestTmp, 'outside.md'), join(__manifestTmp, 'x', 'linked-skill.md'));
    symlinkSync(join(__manifestTmp, 'outside-dir'), join(__manifestTmp, 'x', 'linked-dir'), 'dir');
    if (vm({ ...base, entry: 'linked-entry.mjs' }) === null) pass('entry symlink escaping the plugin directory is rejected');
    else fail('entry symlink traversal should be rejected');
    if (vm({ ...base, skill: 'linked-skill.md' }) === null) pass('skill symlink escaping the plugin directory is rejected');
    else fail('skill symlink traversal should be rejected');
    if (vm({ ...base, entry: 'linked-dir/missing-entry.mjs' }) === null) pass('missing entry under an escaping symlink directory is rejected');
    else fail('missing entry under symlink traversal should be rejected');
  } catch (e) {
    warn(`symlink traversal test skipped: ${e.message}`);
  }
  if (validateManifest({ ...base, id: 'y' }, '/tmp/x', 'x') === null) pass('manifest id must equal the directory name');
  else fail('id != dirname should be rejected');
  if (vm({ ...base, apiVersion: 2 }) === null) pass('unknown apiVersion is rejected (forward-compat gate)');
  else fail('apiVersion 2 should be rejected');
  console.warn = __origWarn;

  // Build an isolated tmp project root.
  __pluginTmp = mkdtempSync(join(tmpdir(), 'co-plugins-'));
  mkdirSync(join(__pluginTmp, 'plugins'), { recursive: true });

  // (a) BYTE-IDENTICAL no-op when config/plugins.yml is absent — and NO env mutation.
  const beforeGemini = process.env.GEMINI_API_KEY;
  const map = new Map([['greenhouse', { id: 'greenhouse', fetch() {} }]]);
  await mergeProviderPlugins(map, { root: __pluginTmp });
  if (map.size === 1 && map.get('greenhouse')) pass('mergeProviderPlugins is a no-op when config/plugins.yml is absent');
  else fail(`merge should be a no-op without plugins.yml (size=${map.size})`);
  if (process.env.GEMINI_API_KEY === beforeGemini) pass('no .env is read / no env mutation when plugins.yml is absent (byte-identical guarantee)');

View on GitHub (pinned to e7abd431fc)