Files
jason.woltje f3f33a6171 fix(organization,cli,discord,seat,control-board): refusals name the field or file by label, never a host path (#1873)
redact6. A refusal names the field, the file or the instance. It never quotes a
config value, a host path, or a raw e.message or stderr that carries one.

- organization: checkCredentialRef names the field and each forbidden root by
  label; OrgError keeps .path only for a document field ($...); the registry,
  fence and lock refusals use the quiet helpers (call and code).
- cli: loadSystem never relays mosaic-config stderr: instance refusals keep
  their code and names, everything else reads as a field and its constraint
  against a labelled config. Project-key, access warning, halt gate, sessions
  file, launch socket, harness relays and the workspace rename name labels.
  Launch-log reasons read a Node system error as its call and code.
- scripts/mosaic-config: configuration not found names where the config came from.
- discord, seat, control-board: config, token, context, tool-root, journal,
  binding, registration and seen-marks refusals name labels.

Exit codes, refusal codes and check order are unchanged. Each new or changed
test fails on BASE be698e602.
2026-11-11 08:53:31 -06:00

135 lines
6.8 KiB
JavaScript

// Credential references (note section 2, addendum A section 7). A
// reference names where a token lives, never the token. The checks here use
// lstat and realpath only; nothing in this package opens a token file.
import { lstatSync, realpathSync } from "node:fs";
import { isAbsolute, join, normalize, relative, sep } from "node:path";
import { refuse } from "./errors.mjs";
import { credentialKind, isHostKind } from "./vocabulary.mjs";
import { requireObject, rejectUnknownKeys, requireDate } from "./util.mjs";
import { pluginName } from "../../plugins/src/organization.mjs";
const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
export const DATE_KEY = Object.freeze({ gitea: "rotateBy", vikunja: "expires" });
const WARN_DAYS = 7;
const DAY_MS = 24 * 60 * 60 * 1000;
// Shape check for one reference. Returns a frozen { service, file | env,
// rotateBy | expires }. Row 199 (#1697, design D1 and D2): `store` is the
// file at <configDir>/secrets/agents/<business>/<agent>/<service>, derived
// here and never written, so an entry can't name another agent's file. A
// reference with no agent (the tracker's sync bot) passes store = null and
// can't use the form. A `file` reference into the agents' store must be the
// agent's own path (`credential-not-yours`).
export function parseCredentialRef(ref, service, where, { store = null } = {}) {
if (!credentialKind(service) && !pluginName(service)) refuse(`${where}: unknown credential service ${JSON.stringify(service)}`);
requireObject(ref, where);
// Row 210 (#1707, design D1): an ssh key doesn't expire, so host.<id> takes rotateBy as gitea does.
const dateKey = isHostKind(service) ? "rotateBy" : (DATE_KEY[service] ?? "rotateBy");
rejectUnknownKeys(ref, ["file", "env", "store", dateKey], where);
const hasFile = ref.file !== undefined;
const hasEnv = ref.env !== undefined;
const hasStore = ref.store !== undefined;
if (Number(hasFile) + Number(hasEnv) + Number(hasStore) !== 1) refuse(`${where} must name exactly one of "file", "env" or "store"`);
const out = { service };
if (hasStore) {
if (ref.store !== true) refuse(`${where}.store must be true`);
if (!store) refuse(`${where}: a "store" reference needs an agent`);
out.store = true;
out.file = storePath(store, service);
} else if (hasFile) {
if (typeof ref.file !== "string" || !isAbsolute(ref.file) || normalize(ref.file) !== ref.file || ref.file.includes("\0")) {
refuse(`${where}.file must be a normalized absolute path`);
}
out.file = ref.file;
if (store && inside(join(store.configDir, "secrets", "agents"), ref.file) && ref.file !== storePath(store, service)) {
refuse(`${where}.file credential-not-yours: the file is in the agents' store but not this agent's path`);
}
} else {
if (typeof ref.env !== "string" || !ENV_NAME.test(ref.env)) refuse(`${where}.env must match ${ENV_NAME}`);
out.env = ref.env;
}
if (ref[dateKey] === undefined) refuse(`${where} needs "${dateKey}" (YYYY-MM-DD)`);
out[dateKey] = requireDate(ref[dateKey], `${where}.${dateKey}`);
return Object.freeze(out);
}
// The store path for one agent and service (design D1).
export function storePath({ configDir, business, agent }, service) {
return join(configDir, "secrets", "agents", business, agent, service);
}
function inside(root, path) {
const rel = relative(root, path);
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
}
// Check a parsed reference against the filesystem and the calendar.
// Returns { problems: [...], warnings: [...] }; a caller that finds any
// problem refuses. `forbiddenRoots` are directories a token file must not
// sit in (the repository, dataRoot): each a path, or { path, label } so a
// problem can name it. `where` is the reference's field (for example
// roles.coder.credentials.gitea). `now` is a Date.
// A problem or warning names the reference by its field, its service and its
// kind, and an env reference by its checked variable name. It never quotes the
// token file's path or a root's path (#1873).
export function checkCredentialRef(ref, { forbiddenRoots = [], now = new Date(), env = process.env, uid = process.getuid(), where = null } = {}) {
const problems = [];
const warnings = [];
const label = `${where ?? ref.service} ${ref.store ? "store file" : ref.file ? "file" : `env ${ref.env}`}`;
let notIssued = false;
if (ref.file) {
let stat = null;
try {
stat = lstatSync(ref.file);
} catch (e) {
// Row 199 (design D3a): a store reference with no file is an agent not issued yet.
notIssued = Boolean(ref.store) && e.code === "ENOENT";
if (notIssued) warnings.push(`${label}: not issued`);
else problems.push(`${label}: not found`);
}
if (stat) {
if (stat.isSymbolicLink() || !stat.isFile()) problems.push(`${label}: must be a regular file, not a symbolic link`);
else {
if (stat.uid !== uid) problems.push(`${label}: owned by uid ${stat.uid}, not ${uid}`);
if ((stat.mode & 0o077) !== 0) problems.push(`${label}: mode ${(stat.mode & 0o777).toString(8)} gives group or other access; use 600`);
if (stat.size === 0) problems.push(`${label}: empty`);
let real = ref.file;
try {
real = realpathSync(ref.file);
} catch {
problems.push(`${label}: path can't be resolved`);
}
for (const entry of forbiddenRoots) {
const root = typeof entry === "string" ? entry : entry.path;
const name = typeof entry === "string" ? "a forbidden root" : entry.label;
let realRoot = root;
try {
realRoot = realpathSync(root);
} catch {
// A root that doesn't exist yet can't contain the file.
}
if (inside(realRoot, real)) problems.push(`${label}: inside ${name}; token files live outside the repository and dataRoot`);
}
}
}
} else if (env[ref.env] === undefined || env[ref.env] === "") {
warnings.push(`${label}: not set in this environment; the launcher must provide it`);
}
// Row 194 (brief item 7, sage-1605 pick a): a human create writes the create day as the date,
// and issue writes the real one. Until then the date describes no token, so a store reference
// with no file skips both date checks. Every other reference keeps them.
if (notIssued) return { problems, warnings };
const days = (dateText) => Math.floor((Date.parse(`${dateText}T00:00:00Z`) - now.getTime()) / DAY_MS);
if (ref.expires) {
const left = days(ref.expires);
if (Date.parse(`${ref.expires}T00:00:00Z`) <= now.getTime()) problems.push(`${label}: expired on ${ref.expires}`);
else if (left < WARN_DAYS) warnings.push(`${label}: expires on ${ref.expires}`);
}
if (ref.rotateBy && Date.parse(`${ref.rotateBy}T00:00:00Z`) <= now.getTime()) {
warnings.push(`${label}: rotation was due on ${ref.rotateBy}`);
}
return { problems, warnings };
}