#!/usr/bin/env node
// @ts-nocheck
/**
* 🛡️ Policy service (GitOps hardening).
*/
const crypto = require('node:crypto');
const ToolError = require('../errors/ToolError');
const DEFAULT_LOCK_TTL_MS = 15 * 60_000;
const MAX_LOCK_TTL_MS = 24 * 60 * 60_000;
const DAY_INDEX = {
sun: 0,
mon: 1,
tue: 2,
wed: 3,
thu: 4,
fri: 5,
sat: 6,
};
function ensureObject(value, label) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw ToolError.invalidParams({ field: label, message: `${label} must be an object` });
}
return value;
}
function ensureOptionalObject(value, label) {
if (value === undefined || value === null) {
return null;
}
return ensureObject(value, label);
}
function normalizeStringArray(value, label) {
if (value === undefined || value === null) {
return null;
}
if (!Array.isArray(value)) {
throw ToolError.invalidParams({
field: label || 'value',
message: 'must be an array of strings',
});
}
const items = value
.map((entry) => (entry === undefined || entry === null ? '' : String(entry).trim()))
.filter(Boolean);
return items.length > 0 ? items : null;
}
function readTruthEnv(raw) {
if (raw === undefined || raw === null) {
return false;
}
const value = String(raw).trim().toLowerCase();
return value === '1' || value === 'true' || value === 'yes' || value === 'on';
}
function normalizePolicyProfileName(value) {
if (value === undefined || value === null) {
return null;
}
const trimmed = String(value).trim();
return trimmed ? trimmed : null;
}
function parseTimeMinutes(raw, label) {
if (raw === undefined || raw === null || raw === '') {
return null;
}
const value = String(raw).trim();
if (value === '24:00') {
return 24 * 60;
}
const match = value.match(/^(\d{1,2}):(\d{2})$/);
if (!match) {
throw ToolError.invalidParams({ field: label, message: `${label} must be HH:MM (24h)` });
}
const hours = Number(match[1]);
const minutes = Number(match[2]);
if (!Number.isInteger(hours) || hours < 0 || hours > 23) {
throw ToolError.invalidParams({ field: label, message: `${label} hours must be 0-23` });
}
if (!Number.isInteger(minutes) || minutes < 0 || minutes > 59) {
throw ToolError.invalidParams({ field: label, message: `${label} minutes must be 0-59` });
}
return hours * 60 + minutes;
}
function normalizeDays(value, label) {
if (value === undefined || value === null) {
return null;
}
if (!Array.isArray(value)) {
throw ToolError.invalidParams({ field: label || 'days', message: 'days must be an array' });
}
const days = new Set();
for (const raw of value) {
if (raw === '*' || raw === 'all') {
return null;
}
const normalized = typeof raw === 'string' ? raw.trim().toLowerCase() : raw;
if (typeof normalized === 'number') {
if (!Number.isInteger(normalized) || normalized < 0 || normalized > 6) {
throw ToolError.invalidParams({ field: label || 'days', message: 'days entries must be 0-6' });
}
days.add(normalized);
continue;
}
if (typeof normalized === 'string') {
const idx = DAY_INDEX[normalized.slice(0, 3)];
if (idx === undefined) {
throw ToolError.invalidParams({ field: label || 'days', message: `Unknown day: ${raw}` });
}
days.add(idx);
continue;
}
throw ToolError.invalidParams({ field: label || 'days', message: 'days entries must be strings or numbers' });
}
return days.size > 0 ? Array.from(days) : null;
}
function normalizeChangeWindows(raw) {
if (raw === undefined || raw === null) {
return null;
}
if (!Array.isArray(raw)) {
throw ToolError.invalidParams({ field: 'policy.change_windows', message: 'change_windows must be an array' });
}
const windows = [];
for (const entry of raw) {
const window = ensureObject(entry, 'change_windows entry');
const start = parseTimeMinutes(window.start, 'change_windows.start');
const end = parseTimeMinutes(window.end, 'change_windows.end');
const days = normalizeDays(window.days, 'change_windows.days');
const tz = window.tz === undefined || window.tz === null || window.tz === ''
? 'UTC'
: String(window.tz).trim();
if (tz !== 'UTC') {
throw ToolError.invalidParams({
field: 'change_windows.tz',
message: 'change_windows.tz currently only supports UTC',
hint: 'Omit tz or set tz=\"UTC\".',
});
}
windows.push({
days,
start: start ?? 0,
end: end ?? 24 * 60,
tz,
});
}
return windows;
}
function isWithinWindowsUtc(now, windows) {
if (!windows) {
return true;
}
if (!Array.isArray(windows) || windows.length === 0) {
return false;
}
const day = now.getUTCDay();
const minutes = now.getUTCHours() * 60 + now.getUTCMinutes();
for (const window of windows) {
const days = window.days;
const start = window.start;
const end = window.end;
const dayAllowed = !days || days.includes(day);
const prevDayAllowed = !days || days.includes((day + 6) % 7);
if (start <= end) {
if (dayAllowed && minutes >= start && minutes < end) {
return true;
}
continue;
}
// Cross-midnight window: D start→24:00 OR (D+1) 00:00→end.
if ((dayAllowed && minutes >= start) || (prevDayAllowed && minutes < end)) {
return true;
}
}
return false;
}
function computeRepoRootKey(repoRoot) {
const hash = crypto.createHash('sha256').update(String(repoRoot || '')).digest('hex').slice(0, 16);
return `repo:${hash}`;
}
class PolicyService {
constructor(logger, validation, stateService) {
this.logger = logger.child('policy');
this.validation = validation;
this.stateService = stateService;
}
resolvePolicy(inputs, projectContext) {
const direct = this.resolvePolicyValue(inputs?.policy ?? inputs?.policy_profile ?? inputs?.policy_profile_name, 'inputs.policy', projectContext);
if (direct) {
return direct;
}
const fromTarget = this.resolvePolicyValue(inputs?.target?.policy, 'target.policy', projectContext);
if (fromTarget) {
return fromTarget;
}
const fromProject = this.resolvePolicyValue(projectContext?.target?.policy, 'target.policy', projectContext);
if (fromProject) {
return fromProject;
}
return this.resolveAutonomyPolicy();
}
resolveAutonomyPolicy() {
const raw = process.env.SENTRYFROGG_AUTONOMY_POLICY || process.env.SF_AUTONOMY_POLICY;
if (raw !== undefined && raw !== null && String(raw).trim() !== '') {
const trimmed = String(raw).trim();
if (trimmed === 'operatorless') {
return { mode: 'operatorless' };
}
if (trimmed.startsWith('{')) {
try {
const parsed = JSON.parse(trimmed);
return parsed && typeof parsed === 'object' ? parsed : null;
} catch (error) {
return null;
}
}
return null;
}
const autonomyFlag = process.env.SENTRYFROGG_AUTONOMY || process.env.SF_AUTONOMY;
if (readTruthEnv(autonomyFlag)) {
return { mode: 'operatorless' };
}
return null;
}
resolvePolicyProfile(name, projectContext) {
const trimmed = normalizePolicyProfileName(name);
if (!trimmed) {
return null;
}
const profiles = projectContext?.project?.policy_profiles;
if (!profiles || typeof profiles !== 'object' || Array.isArray(profiles)) {
return null;
}
const entry = profiles[trimmed];
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
return null;
}
return entry;
}
resolvePolicyValue(value, label, projectContext) {
if (value === undefined || value === null) {
return null;
}
if (typeof value === 'string') {
const profile = this.resolvePolicyProfile(value, projectContext);
if (!profile) {
throw ToolError.notFound({
code: 'POLICY_PROFILE_NOT_FOUND',
message: `${label} profile '${value}' not found`,
hint: 'Use a known project.policy_profiles key, or pass the policy object directly.',
details: { profile: value },
});
}
return profile;
}
if (typeof value !== 'object' || Array.isArray(value)) {
throw ToolError.invalidParams({ field: label, message: `${label} must be an object or profile name` });
}
return value;
}
normalizePolicy(policy) {
const payload = ensureObject(policy, 'policy');
const mode = payload.mode === undefined || payload.mode === null || payload.mode === ''
? null
: String(payload.mode).trim();
const allowIntents = normalizeStringArray(payload.allow?.intents, 'policy.allow.intents');
const allowMerge = payload.allow?.merge;
if (allowMerge !== undefined && typeof allowMerge !== 'boolean') {
throw ToolError.invalidParams({ field: 'policy.allow.merge', message: 'policy.allow.merge must be a boolean' });
}
const allowedRemotes = normalizeStringArray(payload.repo?.allowed_remotes, 'policy.repo.allowed_remotes');
const allowedNamespaces = normalizeStringArray(payload.kubernetes?.allowed_namespaces, 'policy.kubernetes.allowed_namespaces');
const changeWindows = normalizeChangeWindows(payload.change_windows);
const lockRaw = payload.lock === undefined || payload.lock === null ? null : ensureOptionalObject(payload.lock, 'policy.lock');
const lockEnabled = lockRaw?.enabled === undefined ? true : lockRaw.enabled;
if (lockEnabled !== undefined && typeof lockEnabled !== 'boolean') {
throw ToolError.invalidParams({ field: 'policy.lock.enabled', message: 'policy.lock.enabled must be a boolean' });
}
const ttlMsRaw = lockRaw?.ttl_ms;
const ttlMs = ttlMsRaw === undefined || ttlMsRaw === null || ttlMsRaw === ''
? DEFAULT_LOCK_TTL_MS
: Number(ttlMsRaw);
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
throw ToolError.invalidParams({ field: 'policy.lock.ttl_ms', message: 'policy.lock.ttl_ms must be a positive number' });
}
return {
mode,
allow: {
intents: allowIntents,
merge: allowMerge,
},
repo: {
allowed_remotes: allowedRemotes,
},
kubernetes: {
allowed_namespaces: allowedNamespaces,
},
change_windows: changeWindows,
lock: {
enabled: Boolean(lockEnabled),
ttl_ms: Math.min(Math.floor(ttlMs), MAX_LOCK_TTL_MS),
},
};
}
assertGitOpsWriteAllowed({ intentType, inputs, policy, now }) {
if (!policy) {
throw ToolError.denied({
code: 'POLICY_REQUIRED',
message: 'GitOps write intents require policy',
hint: 'Provide inputs.policy (mode=operatorless), configure target.policy, or set SENTRYFROGG_AUTONOMY_POLICY=operatorless.',
});
}
if (policy.mode !== 'operatorless') {
throw ToolError.denied({
code: 'POLICY_MODE_REQUIRED',
message: 'policy.mode=operatorless is required for GitOps write intents',
hint: 'Set inputs.policy.mode="operatorless" (or target.policy.mode) and retry.',
});
}
if (policy.allow.intents && !policy.allow.intents.includes(intentType)) {
throw ToolError.denied({
code: 'POLICY_DENIED_INTENT',
message: `policy denies intent: ${intentType}`,
hint: 'Ask an operator to allow this intent in policy.allow.intents or choose an allowed intent.',
details: { intent_type: intentType },
});
}
if ((intentType === 'gitops.propose' || intentType === 'gitops.release') && inputs?.merge === true) {
if (policy.allow.merge === false) {
throw ToolError.denied({
code: 'POLICY_DENIED_MERGE',
message: 'policy denies merge',
hint: 'Set inputs.merge=false or ask an operator to allow merges (policy.allow.merge).',
});
}
}
if (policy.repo.allowed_remotes) {
const remote = inputs?.remote ? String(inputs.remote).trim() : 'origin';
if (!policy.repo.allowed_remotes.includes(remote)) {
throw ToolError.denied({
code: 'POLICY_DENIED_REMOTE',
message: `policy denies git remote: ${remote}`,
hint: 'Use an allowed remote or ask an operator to add it to policy.repo.allowed_remotes.',
details: { remote },
});
}
}
if (policy.kubernetes.allowed_namespaces && inputs?.namespace) {
const namespace = String(inputs.namespace).trim();
if (!policy.kubernetes.allowed_namespaces.includes(namespace)) {
throw ToolError.denied({
code: 'POLICY_DENIED_NAMESPACE',
message: `policy denies namespace: ${namespace}`,
hint: 'Choose an allowed namespace or ask an operator to add it to policy.kubernetes.allowed_namespaces.',
details: { namespace },
});
}
}
if (!isWithinWindowsUtc(now, policy.change_windows)) {
throw ToolError.denied({
code: 'POLICY_CHANGE_WINDOW',
message: 'policy denies write outside change window',
hint: 'Wait for the next change window or ask an operator to adjust policy.change_windows.',
});
}
}
assertRepoWriteAllowed({ action, inputs, policy, now }) {
if (!policy) {
return;
}
if (policy.mode !== 'operatorless') {
throw ToolError.denied({
code: 'POLICY_MODE_REQUIRED',
message: 'policy.mode=operatorless is required for repo write operations',
hint: 'Set policy.mode="operatorless" (in inputs.policy or target.policy) and retry.',
details: { action },
});
}
if (policy.repo.allowed_remotes && inputs?.remote !== undefined) {
const remote = inputs?.remote ? String(inputs.remote).trim() : 'origin';
if (!policy.repo.allowed_remotes.includes(remote)) {
throw ToolError.denied({
code: 'POLICY_DENIED_REMOTE',
message: `policy denies git remote: ${remote}`,
hint: 'Use an allowed remote or ask an operator to add it to policy.repo.allowed_remotes.',
details: { remote, action },
});
}
}
if (!isWithinWindowsUtc(now, policy.change_windows)) {
throw ToolError.denied({
code: 'POLICY_CHANGE_WINDOW',
message: 'policy denies write outside change window',
hint: 'Wait for the next change window or ask an operator to adjust policy.change_windows.',
details: { action },
});
}
}
assertKubectlWriteAllowed({ inputs, policy, now }) {
if (!policy) {
return;
}
if (policy.mode !== 'operatorless') {
throw ToolError.denied({
code: 'POLICY_MODE_REQUIRED',
message: 'policy.mode=operatorless is required for kubectl write operations',
hint: 'Set policy.mode="operatorless" (in inputs.policy or target.policy) and retry.',
});
}
if (policy.kubernetes.allowed_namespaces) {
const namespace = inputs?.namespace ? String(inputs.namespace).trim() : '';
if (!namespace) {
throw ToolError.denied({
code: 'POLICY_NAMESPACE_REQUIRED',
message: 'policy requires explicit namespace for kubectl write operations',
hint: 'Pass -n/--namespace (or use a tool that accepts namespace explicitly) and retry.',
details: { allowed_namespaces: policy.kubernetes.allowed_namespaces },
});
}
if (!policy.kubernetes.allowed_namespaces.includes(namespace)) {
throw ToolError.denied({
code: 'POLICY_DENIED_NAMESPACE',
message: `policy denies namespace: ${namespace}`,
hint: 'Choose an allowed namespace or ask an operator to add it to policy.kubernetes.allowed_namespaces.',
details: { namespace },
});
}
}
if (!isWithinWindowsUtc(now, policy.change_windows)) {
throw ToolError.denied({
code: 'POLICY_CHANGE_WINDOW',
message: 'policy denies write outside change window',
hint: 'Wait for the next change window or ask an operator to adjust policy.change_windows.',
});
}
}
buildLockKey({ projectName, targetName, repoRoot }) {
if (projectName && targetName) {
return `gitops.lock.project:${projectName}:${targetName}`;
}
if (repoRoot) {
return `gitops.lock.${computeRepoRootKey(repoRoot)}`;
}
return null;
}
async acquireLock({ key, traceId, ttlMs, meta }) {
if (!this.stateService) {
throw ToolError.internal({
code: 'STATE_SERVICE_UNAVAILABLE',
message: 'state service is not available for lock enforcement',
hint: 'This is a server configuration error. Enable StateService in bootstrap.',
});
}
const now = Date.now();
const nowIso = new Date(now).toISOString();
const expiresIso = new Date(now + ttlMs).toISOString();
const existing = await this.stateService.get(key, 'persistent');
const lock = existing?.value;
const parseExpires = (value) => {
if (!value || typeof value !== 'string') {
return null;
}
const ts = Date.parse(value);
return Number.isFinite(ts) ? ts : null;
};
const expired = (current) => {
if (!current || typeof current !== 'object') {
return true;
}
const expiresAt = parseExpires(current.expires_at);
if (expiresAt === null) {
return true;
}
return expiresAt <= now;
};
if (lock && typeof lock === 'object' && !expired(lock)) {
if (lock.trace_id && String(lock.trace_id) === String(traceId)) {
const nextCount = Math.max(1, Number(lock.count) || 1) + 1;
const next = {
...lock,
count: nextCount,
updated_at: nowIso,
expires_at: expiresIso,
};
await this.stateService.set(key, next, 'persistent');
return next;
}
throw ToolError.conflict({
code: 'LOCK_HELD',
message: `environment lock is held (key=${key}) until ${lock.expires_at}`,
hint: 'Wait for the lock to expire, or cancel the conflicting operation before retrying.',
details: { key, expires_at: lock.expires_at, holder_trace_id: lock.trace_id },
retryable: true,
});
}
const next = {
...(meta || {}),
trace_id: traceId,
acquired_at: nowIso,
updated_at: nowIso,
expires_at: expiresIso,
ttl_ms: ttlMs,
count: 1,
};
await this.stateService.set(key, next, 'persistent');
return next;
}
async releaseLock({ key, traceId }) {
if (!this.stateService) {
return;
}
const nowIso = new Date().toISOString();
const existing = await this.stateService.get(key, 'persistent');
const lock = existing?.value;
if (!lock || typeof lock !== 'object') {
return;
}
if (!lock.trace_id || String(lock.trace_id) !== String(traceId)) {
return;
}
const count = Math.max(1, Number(lock.count) || 1);
if (count > 1) {
await this.stateService.set(key, { ...lock, count: count - 1, updated_at: nowIso }, 'persistent');
return;
}
await this.stateService.unset(key, 'persistent');
}
async guardGitOpsWrite({ intentType, inputs, traceId, projectName, targetName, repoRoot, projectContext }) {
const rawPolicy = this.resolvePolicy(inputs, projectContext || null);
if (!rawPolicy) {
throw ToolError.denied({
code: 'POLICY_REQUIRED',
message: 'GitOps write intents require policy',
hint: 'Provide inputs.policy (mode=operatorless), configure target.policy, set project.policy_profiles, or set SENTRYFROGG_AUTONOMY_POLICY=operatorless.',
});
}
const normalized = this.normalizePolicy(rawPolicy);
this.assertGitOpsWriteAllowed({ intentType, inputs, policy: normalized, now: new Date() });
const lockKey = normalized.lock.enabled
? this.buildLockKey({ projectName, targetName, repoRoot })
: null;
if (!lockKey && normalized.lock.enabled) {
throw ToolError.invalidParams({
message: 'policy.lock.enabled requires project/target or repo_root for lock scope',
hint: 'Provide project+target (via workspace/project) or pass repo_root so the lock scope can be derived.',
});
}
if (lockKey) {
await this.acquireLock({
key: lockKey,
traceId,
ttlMs: normalized.lock.ttl_ms,
meta: {
intent: intentType,
project: projectName || undefined,
target: targetName || undefined,
repo_root: repoRoot || undefined,
},
});
}
return {
policy: normalized,
lock_key: lockKey,
release: async () => {
if (!lockKey) {
return;
}
await this.releaseLock({ key: lockKey, traceId });
},
};
}
async guardRepoWrite({ action, inputs, traceId, projectContext, repoRoot }) {
const rawPolicy = this.resolvePolicy(inputs, projectContext);
if (!rawPolicy) {
return null;
}
const normalized = this.normalizePolicy(rawPolicy);
this.assertRepoWriteAllowed({ action, inputs, policy: normalized, now: new Date() });
const lockKey = normalized.lock.enabled
? this.buildLockKey({
projectName: projectContext?.projectName,
targetName: projectContext?.targetName,
repoRoot,
})
: null;
if (!lockKey && normalized.lock.enabled) {
throw ToolError.invalidParams({
message: 'policy.lock.enabled requires project/target or repo_root for lock scope',
hint: 'Provide project+target (via workspace/project) or pass repo_root so the lock scope can be derived.',
});
}
if (lockKey) {
await this.acquireLock({
key: lockKey,
traceId,
ttlMs: normalized.lock.ttl_ms,
meta: {
action,
project: projectContext?.projectName || undefined,
target: projectContext?.targetName || undefined,
repo_root: repoRoot || undefined,
},
});
}
return {
policy: normalized,
lock_key: lockKey,
release: async () => {
if (!lockKey) {
return;
}
await this.releaseLock({ key: lockKey, traceId });
},
};
}
async guardKubectlWrite({ inputs, traceId, projectContext, repoRoot }) {
const rawPolicy = this.resolvePolicy(inputs, projectContext);
if (!rawPolicy) {
return null;
}
const normalized = this.normalizePolicy(rawPolicy);
this.assertKubectlWriteAllowed({ inputs, policy: normalized, now: new Date() });
const lockKey = normalized.lock.enabled
? this.buildLockKey({
projectName: projectContext?.projectName,
targetName: projectContext?.targetName,
repoRoot,
})
: null;
if (!lockKey && normalized.lock.enabled) {
throw ToolError.invalidParams({
message: 'policy.lock.enabled requires project/target or repo_root for lock scope',
hint: 'Provide project+target (via workspace/project) or pass repo_root so the lock scope can be derived.',
});
}
if (lockKey) {
await this.acquireLock({
key: lockKey,
traceId,
ttlMs: normalized.lock.ttl_ms,
meta: {
action: 'kubectl',
namespace: inputs?.namespace || undefined,
project: projectContext?.projectName || undefined,
target: projectContext?.targetName || undefined,
repo_root: repoRoot || undefined,
},
});
}
return {
policy: normalized,
lock_key: lockKey,
release: async () => {
if (!lockKey) {
return;
}
await this.releaseLock({ key: lockKey, traceId });
},
};
}
}
module.exports = PolicyService;