nanocoai/nanoclaw · critical · deniedByPolicy

mount ${mount.hostPath} must be classed ${required}, not ${m

Error message

mount ${mount.hostPath} must be classed ${required}, not ${mount.class}

What it means

The mount's hostPath falls under a policy root whose class is decided by location (classRequiredByPath, or the stamped plugins root implying 'install-surface'), but the spec declares a different class. Where a file lives decides what it is: allowing the composer to relabel, say, a session private key as 'allowlisted-extra' would mount it into the agent and defeat the no-credentials invariant, and relabeling runner source would escape its read-only rule.

Source

Thrown at src/drivers/types.ts:464

        // artifact. Composition resolves collisions (contributed mounts win),
        // so a spec reaching a driver has exactly one source per target.
        throw specInvalid(`duplicate containerPath ${mount.containerPath} on ${container.role}`);
      }
      seenTargets.add(mount.containerPath);
      const required =
        classRequiredByPath(mount.hostPath, policy) ??
        (pluginsRoot && underRoot(mount.hostPath, pluginsRoot) ? 'install-surface' : null);
      if (required && mount.class !== required) {
        // Where a file lives decides what it IS, so the class is not the
        // composer's to choose for these roots. Without this the taxonomy is
        // only as strong as whoever assigns the class, and two of the four
        // classes carry safety properties that a demotion silently drops:
        // `allowlisted-extra` is permitted unconditionally, so relabelling a
        // session private key as one mounts it INTO THE AGENT — defeating the
        // no-credentials invariant outright — and relabelling the runner source
        // as one escapes the read-only rule on the code the agent executes.
        // Neither is exotic: both are a single word in a mount literal.
        throw deniedByPolicy(`mount ${mount.hostPath} must be classed ${required}, not ${mount.class}`);
      }
      if (mount.class === 'install-surface' && mount.mode !== 'ro') {
        throw deniedByPolicy(`install-surface mount ${mount.hostPath} must be ro`);
      }
      if (mount.class === 'identity-material' && (mount.mode !== 'ro' || container.role === 'agent')) {
        // The no-credentials invariant, as a checkable rule: identity materials
        // are ro-only and never enter the agent container.
        throw deniedByPolicy(`identity-material mount ${mount.hostPath} invalid on role ${container.role}`);
      }
      if (!mountAllowed(mount, spec, policy)) {
        throw deniedByPolicy(`mount ${mount.hostPath} violates class ${mount.class} scope ${mount.groupScope}`);
      }
    }
    for (const [key, value] of Object.entries(container.env)) {
      if (isSecretShaped(key, value)) {
        throw deniedByPolicy(`secret-shaped env '${key}' on ${container.role}`);
      }
    }

View on GitHub (pinned to 294ef2aee8)

Solutions

  1. Set the mount's class to the value named in the error message (the 'required' class) — e.g. class it 'identity-material' for paths under materialsRoot, 'install-surface' for plugins/surface roots.
  2. If the file genuinely is not classified material, move it out of the policy-governed root rather than relabeling it.
  3. Never work around by relabeling to 'allowlisted-extra' — that class is unconditional and the check exists precisely to block it.

Example fix

// before
{ hostPath: '/var/lib/nanoclaw/materials/session-key.pem', containerPath: '/creds/key.pem', class: 'allowlisted-extra', mode: 'ro' }

// after
{ hostPath: '/var/lib/nanoclaw/materials/session-key.pem', containerPath: '/creds/key.pem', class: 'identity-material', mode: 'ro' }
Defensive patterns

Strategy: validation

Validate before calling

// Mirror the location rule before calling the driver:
function expectedClass(hostPath: string, policy: MountPolicy, pluginsRoot?: string): string | null {
  if ([policy.materialsRoot, policy.groupsRoot, ...policy.surfaceRoots].some(r => hostPath.startsWith(r + '/') || hostPath === r))
    return classForRoot(hostPath, policy); // same root->class mapping the policy defines
  if (pluginsRoot && hostPath.startsWith(pluginsRoot + '/')) return 'install-surface';
  return null;
}

Try / catch

try { validateSpec(spec, policy); } catch (e) { if ((e as any).kind === 'denied-by-policy' && /must be classed/.test(e.detail)) { /* fix class from message, never bypass */ throw e; } throw e; }

Prevention

When it happens

Trigger: A mount whose hostPath is under policy.materialsRoot (or another classified root / the plugins root) is declared with class 'allowlisted-extra', 'group-surface', or anything other than the location-required class. Typical of hand-written specs or ported docker-compose volume entries where the author picked the most permissive class.

Common situations: Copy-pasting a working 'allowlisted-extra' mount literal and changing only the hostPath to point at a classified location; upgrading the policy roots (new materialsRoot) so previously-unclassified paths now require a class; authors choosing a permissive class to dodge the ro-only rules.

Related errors


AI-assisted analysis of nanocoai/nanoclaw@294ef2aee8 (2026-08-28). Data as JSON: /api/errors/bacfb676a510acb2. Report an issue: GitHub.