← Files ForgeMindARCHIVED FILE

src/policy.mjs

3.91 KB · Oct 2, 2026 · 00:31 UTC

↓ Download file

import path from 'node:path';

const EFFECT_RANK = { allow: 0, approval: 1, deny: 2 };

export const DEFAULT_POLICY = Object.freeze({
  actions: {
    read: 'allow',
    write: 'allow',
    command: 'allow',
    destructive: 'approval',
    deployment: 'approval',
    migration: 'approval',
    network: 'approval',
    cost: 'approval',
  },
  protectedPaths: ['.git/', '.env', '.forgemind.local.json'],
});

export function mergePolicies(defaults = DEFAULT_POLICY, shared = {}, personal = {}, options = {}) {
  const policy = {
    actions: { ...(defaults.actions ?? {}) },
    protectedPaths: [...new Set(defaults.protectedPaths ?? [])],
  };
  const sources = Object.fromEntries(Object.keys(policy.actions).map((key) => [`actions.${key}`, 'default']));
  const rejections = [];
  applyLayer(policy, sources, shared, 'shared', true, rejections, options);
  applyLayer(policy, sources, personal, 'personal', false, rejections, options);
  Object.defineProperty(policy, '_sources', { value: sources, enumerable: false });
  return { policy, rejections };
}

export function evaluateAction(policy, action) {
  const normalizedPath = action.path?.replaceAll('\\', '/').replace(/^\.\//, '');
  if (normalizedPath && (policy.protectedPaths ?? []).some((entry) => isProtected(entry, normalizedPath))) {
    return { decision: 'deny', source: 'protectedPaths', rationale: `Protected path cannot be modified: ${normalizedPath}` };
  }
  const effect = policy.actions?.[action.kind] ?? policy.actions?.command ?? 'approval';
  const layer = policy._sources?.[`actions.${action.kind}`] ?? 'default';
  const owner = layer === 'shared' ? 'Team policy' : layer === 'personal' ? 'Personal policy' : 'Secure default policy';
  const verb = effect === 'deny' ? 'denies' : effect === 'approval' ? 'requires approval for' : 'allows';
  return { decision: effect, source: `actions.${action.kind}`, rationale: `${owner} ${verb} ${action.kind}.` };
}

export function evaluateMissionGrant({ policy, grant, action, now = new Date() }) {
  if (!grant || grant.missionId !== action.missionId) return { decision: 'deny', source: 'grant', rationale: 'A matching mission grant is required.' };
  if (!grant.expiresAt || new Date(grant.expiresAt) <= now) return { decision: 'deny', source: 'grant', rationale: 'Grant is absent or expired.' };
  if (!Array.isArray(grant.operations) || !grant.operations.includes(action.operation)) return { decision: 'deny', source: 'grant', rationale: 'Grant does not allow this operation.' };
  if (Number.isInteger(grant.maxActions) && grant.maxActions < 1) return { decision: 'deny', source: 'grant', rationale: 'Grant action budget is exhausted.' };
  const policyDecision = evaluateAction(policy, action);
  return policyDecision.decision === 'allow' ? { decision: 'allow', source: 'grant', rationale: 'Policy and scoped grant allow this action.' } : policyDecision;
}

function applyLayer(policy, sources, layer, source, canReplace, rejections, options) {
  for (const [kind, requested] of Object.entries(layer.actions ?? {})) {
    if (!Object.hasOwn(EFFECT_RANK, requested)) continue;
    const retained = policy.actions[kind] ?? 'allow';
    const weakens = EFFECT_RANK[requested] < EFFECT_RANK[retained];
    if (weakens && !canReplace && !options.approvedWeakening) {
      rejections.push({ path: `actions.${kind}`, requested, retained });
      continue;
    }
    if (canReplace || EFFECT_RANK[requested] >= EFFECT_RANK[retained] || options.approvedWeakening) {
      policy.actions[kind] = requested;
      sources[`actions.${kind}`] = source;
    }
  }
  policy.protectedPaths = [...new Set([...policy.protectedPaths, ...(layer.protectedPaths ?? [])])];
}

function isProtected(entry, candidate) {
  const normalized = entry.replaceAll('\\', '/').replace(/^\.\//, '');
  return candidate === normalized.replace(/\/$/, '') || candidate.startsWith(normalized.endsWith('/') ? normalized : `${normalized}/`);
}

SHA-256: e915b7a0b6bd676e86bf12af6b09202351f81738e05b40d90f34f0304e5ccacf