{"record":{"id":"d23fd40f1b086489","repo":"nanocoai/nanoclaw","slug":"mount-mount-hostpath-must-be-a-canonical-absolu","errorCode":null,"errorMessage":"mount ${mount.hostPath} must be a canonical absolute path (no '..', '.', '//', or trailing '/')","messagePattern":"mount (.+?) must be a canonical absolute path \\(no '\\.\\.', '\\.', '//', or trailing '/'\\)","errorType":"validation","errorClass":"deniedByPolicy","httpStatus":null,"severity":"error","filePath":"src/drivers/types.ts","lineNumber":440,"sourceCode":"    // role, and a second container claiming it would make every 'agent'-keyed\n    // rule (identity-material exclusion, the realization's supervision) apply\n    // to an ambiguous target.\n    throw specInvalid('spec must carry exactly one agent container');\n  }\n  const pluginsRoot = stampedPluginsRoot(spec, policy);\n  for (const container of spec.containers) {\n    const seenTargets = new Set<string>();\n    for (const mount of container.mounts) {\n      if (!hostPathCanonical(mount.hostPath)) {\n        // Every class rule below is a prefix check against a trusted root, and\n        // a prefix check reads `materialsRoot/../outside` as inside — the\n        // runtime then normalizes it OUTSIDE the root it was judged against.\n        // Requiring the canonical absolute form makes the string these rules\n        // judge the same path the runtime mounts. (A relative source would not\n        // even be a bind: Docker reads it as a named volume.) Symlinks remain\n        // beyond a lexical check — that is what `admissionEnforced`\n        // realizations are for.\n        throw deniedByPolicy(\n          `mount ${mount.hostPath} must be a canonical absolute path (no '..', '.', '//', or trailing '/')`,\n        );\n      }\n      if (seenTargets.has(mount.containerPath)) {\n        // Two sources for one target would make the realized mount an ordering\n        // artifact. Composition resolves collisions (contributed mounts win),\n        // so a spec reaching a driver has exactly one source per target.\n        throw specInvalid(`duplicate containerPath ${mount.containerPath} on ${container.role}`);\n      }\n      seenTargets.add(mount.containerPath);\n      const required =\n        classRequiredByPath(mount.hostPath, policy) ??\n        (pluginsRoot && underRoot(mount.hostPath, pluginsRoot) ? 'install-surface' : null);\n      if (required && mount.class !== required) {\n        // Where a file lives decides what it IS, so the class is not the\n        // composer's to choose for these roots. Without this the taxonomy is\n        // only as strong as whoever assigns the class, and two of the four\n        // classes carry safety properties that a demotion silently drops:","sourceCodeStart":422,"sourceCodeEnd":458,"githubUrl":"https://github.com/nanocoai/nanoclaw/blob/294ef2aee85218b23ad30eda9dfe10e590b54a8c/src/drivers/types.ts#L422-L458","documentation":"validateSpec rejects any mount whose hostPath is not in canonical absolute form: it must start with '/', and contain no empty segments ('//'), no '.', no '..', and no trailing '/'. Every mount class rule downstream is a prefix check against a trusted root, and a non-canonical path like /root/../outside would pass the prefix check while the runtime normalizes it outside the root. Requiring the canonical form makes the string the rules judge identical to the path actually mounted.","triggerScenarios":"A container spec includes a mount with hostPath such as 'data/keys', '/var/run/../etc', '/opt/x/', or '/opt//x'. This can come from user-supplied config joined with path.join on relative components, or template strings that embed a trailing slash. It is thrown host-side from validateSpec, typically invoked via prepare() before any container is created.","commonSituations":"Building mount paths from config values with relative segments; copying docker -v style syntax (which tolerates some of this) into a typed spec; constructing paths by string concatenation instead of path.resolve; CI environments where a workspace root env var is empty, yielding paths like '/workspace//group'.","solutions":["Normalize every hostPath with path.resolve() before adding it to the spec: resolve collapses '..', '.', and duplicate slashes and produces a rooted absolute path.","Trim trailing slashes (path.resolve already does) and assert the result still lies under the intended root before passing the spec to prepare/validateSpec.","If the path is user-supplied, reject or canonicalize it at the config boundary rather than inside the driver call."],"exampleFix":"// before\nspec.containers[0].mounts.push({ hostPath: `${cfg.root}/../secrets/key.pem`, containerPath: '/keys/key.pem', class: 'identity-material', mode: 'ro' });\n\n// after\nimport { resolve } from 'node:path';\nspec.containers[0].mounts.push({ hostPath: resolve(cfg.root, '../secrets/key.pem'), containerPath: '/keys/key.pem', class: 'identity-material', mode: 'ro' });","handlingStrategy":"validation","validationCode":"import { resolve, sep } from 'node:path';\nfunction assertCanonicalMounts(spec: SessionSpec): void {\n  for (const c of spec.containers)\n    for (const m of c.mounts) {\n      const r = resolve(m.hostPath);\n      if (r !== m.hostPath) m.hostPath = r; // normalize\n      if (!m.hostPath.startsWith(sep) || m.hostPath.includes('/..') || m.hostPath.endsWith('/'))\n        throw new Error(`non-canonical hostPath: ${m.hostPath}`);\n    }\n}","typeGuard":"function isCanonicalHostPath(p: string): boolean {\n  return p.startsWith('/') && p.split('/').slice(1).every(s => s !== '' && s !== '.' && s !== '..');\n}","tryCatchPattern":null,"preventionTips":["Always build hostPaths with path.resolve, never string concatenation.","Assert resolved paths stay under their intended root before composing the spec.","Reject relative paths at the config-parsing boundary."],"tags":["mount","path-validation","container","security"],"backgroundTag":"non-canonical-path-rejected","analyzedSha":"294ef2aee85218b23ad30eda9dfe10e590b54a8c","analyzedAt":"2026-08-28T13:59:10.357Z","schemaVersion":2},"datasetVersion":"2026-08-28T16:17:29.566Z"}