diff --git a/lib/host.js b/lib/host.js index 2912ef8..3219782 100644 --- a/lib/host.js +++ b/lib/host.js @@ -6,7 +6,7 @@ import { randomUUID } from 'node:crypto' import { mkdir } from 'node:fs/promises' import { homedir } from 'node:os' -import { basename, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import { claimOccurrence, executeClaimedRun, extractAssistantText, interruptActiveRuns, publicJob, settleRun, TITLE_PREFIX } from './fire.js' import { assertDeliveryAllowedForIdentity, deliverRunToIm, mergeDeliveryMention, mirrorRunToSession, normalizeDelivery, normalizeOrigin } from './delivery.js' import { apiError, resolveLocale } from './i18n.js' @@ -190,6 +190,9 @@ function allowsForeignJobCwd(identity, getUdsAuth, ownerEmpNo) { const live = uds.resolveIdentityForEmpNo(empNo) if (live && canViewAllJobs(live)) return true } + if (typeof uds.getRole === 'function' && isElevatedCronRole(uds.getRole(empNo))) return true + // Match dsh-acl: local fallback cookie user is always elevated. + if (empNo === 'administrator') return true return false } @@ -205,6 +208,7 @@ function enrichIdentity(identity, getUdsAuth) { || identity.permissions?.canViewAllSessions || isElevatedCronRole(live.role) || isElevatedCronRole(identity.role) + || identity.empNo === 'administrator' ) return { ...identity, @@ -224,7 +228,7 @@ function enrichIdentity(identity, getUdsAuth) { } } } - if (isElevatedCronRole(identity.role)) { + if (isElevatedCronRole(identity.role) || identity.empNo === 'administrator') { return { ...identity, permissions: { @@ -237,7 +241,25 @@ function enrichIdentity(identity, getUdsAuth) { return identity } -function sanitizeJobCwd(cwd, identity, getUdsAuth, { forOwnerEmpNo } = {}) { +export function normalizeFsPath(value) { + return String(value || '').trim().replace(/\\/g, '/').replace(/\/+$/, '') +} + +export function isPathInside(candidate, root) { + const cand = normalizeFsPath(candidate).toLowerCase() + const base = normalizeFsPath(root).toLowerCase() + if (!cand || !base) return false + return cand === base || cand.startsWith(`${base}/`) +} + +/** + * Decide the durable cwd for a job. + * - Elevated owners: any explicit cwd + * - Owner tree (isUserPath): keep + * - Inside provision forest but not owner tree: clamp to ownerPath + * - Outside forest (shared trees like D:\\code\\gpt): keep when allowExternalCwd + */ +function resolveSanitizedCwd(cwd, identity, getUdsAuth, { forOwnerEmpNo, allowExternalCwd = true } = {}) { const raw = typeof cwd === 'string' ? cwd.trim() : '' const uds = typeof getUdsAuth === 'function' ? getUdsAuth() : null const owner = forOwnerEmpNo || identity?.empNo @@ -246,11 +268,36 @@ function sanitizeJobCwd(cwd, identity, getUdsAuth, { forOwnerEmpNo } = {}) { : null if (!raw) return ownerPath || '' if (allowsForeignJobCwd(identity, getUdsAuth, owner)) return raw - // Without uds-auth, keep the caller cwd (tools may stamp owner from path only). if (!uds) return raw if (owner && uds?.isUserPath?.(owner, raw)) return raw - if (ownerPath) return ownerPath - return '' + if (ownerPath) { + const forest = dirname(ownerPath) + if (forest && isPathInside(raw, forest)) return ownerPath + if (allowExternalCwd !== false) return raw + return ownerPath + } + return allowExternalCwd !== false ? raw : '' +} + +function sanitizeJobCwd(cwd, identity, getUdsAuth, opts = {}) { + const raw = typeof cwd === 'string' ? cwd.trim() : '' + const effective = resolveSanitizedCwd(cwd, identity, getUdsAuth, opts) + if ( + raw + && normalizeFsPath(raw).toLowerCase() !== normalizeFsPath(effective).toLowerCase() + && opts.rejectClamp + ) { + const error = new Error( + `cwd "${raw}" is not allowed for this user (effective "${effective || '(empty)'}"). ` + + 'Use a path under your provisioned workspace, a shared tree outside workspaceRoot ' + + '(when allowExternalCwd is on), or a super_admin / fallback_admin account.', + ) + error.code = 'CWD_FORBIDDEN' + error.requestedCwd = raw + error.effectiveCwd = effective + throw error + } + return effective } function isTrustedApiRequest(request) { @@ -424,7 +471,14 @@ export function createHostService(options = {}) { safeInput.delivery = delivery safeInput.ownerEmpNo = ownerIdentity.empNo safeInput.ownerDisplayName = ownerIdentity.displayName || ownerIdentity.empNo - safeInput.cwd = sanitizeJobCwd(safeInput.cwd, ownerIdentity, getUdsAuth, { forOwnerEmpNo: ownerIdentity.empNo }) + const snap = await snapshot() + const allowExternalCwd = snap?.settings?.allowExternalCwd !== false + const requestedCwd = String(safeInput.cwd || '').trim() + safeInput.cwd = sanitizeJobCwd(safeInput.cwd, ownerIdentity, getUdsAuth, { + forOwnerEmpNo: ownerIdentity.empNo, + allowExternalCwd, + rejectClamp: !!requestedCwd, + }) } else if (safeInput.ownerEmpNo) { safeInput.ownerEmpNo = normalizeOwnerEmpNo(safeInput.ownerEmpNo) } else { @@ -482,11 +536,17 @@ export function createHostService(options = {}) { nextInput.delivery = mergeDeliveryMention(job.delivery, patch.delivery) assertDeliveryAllowedForIdentity(nextInput.delivery, enriched) } + const allowExternalCwd = current.settings?.allowExternalCwd !== false + const requestedCwd = patch.cwd !== undefined ? String(patch.cwd || '').trim() : '' nextInput.cwd = sanitizeJobCwd( nextInput.cwd, enriched, getUdsAuth, - { forOwnerEmpNo: nextInput.ownerEmpNo }, + { + forOwnerEmpNo: nextInput.ownerEmpNo, + allowExternalCwd, + rejectClamp: !!requestedCwd, + }, ) } if (patch.ownerEmpNo !== undefined && canViewAllJobs(enrichIdentity(patch._identity, getUdsAuth))) { @@ -1089,7 +1149,8 @@ export function ownerAllowsForeignCwd(ownerEmpNo, uds) { /** * Placement order: - * 1. Explicit job.cwd (kept for super_admin owners; clamped to owner tree for users) + * 1. Explicit job.cwd (kept for elevated owners; shared trees outside forest kept when allowExternalCwd; + * clamped to owner tree only when inside provision forest but not the owner's path) * 2. Owner provisioned path (multi-user) * 3. Recent registry workspace — only standalone / non-IM unassigned * 4. Shared ops-cron fallback @@ -1105,12 +1166,19 @@ export function resolveSessionPlacement(ctx, job = {}, deps = {}) { ? uds.getProvisionedWorkspacePath(ownerEmpNo) : null const imOrigin = normalizeOrigin(job?.origin)?.kind === 'im' + const allowExternalCwd = deps.allowExternalCwd !== false let requested = String(job?.cwd || '').trim() if (requested && ownerEmpNo && !ownerEmpNo.startsWith('__') && uds?.isUserPath) { const allowForeign = ownerAllowsForeignCwd(ownerEmpNo, uds) if (!uds.isUserPath(ownerEmpNo, requested) && !allowForeign && ownerPath) { - requested = ownerPath + const forest = dirname(ownerPath) + if (forest && isPathInside(requested, forest)) { + requested = ownerPath + } else if (allowExternalCwd === false) { + requested = ownerPath + } + // else: outside forest → keep requested (shared business cwd) } } if (requested) return { cwd: requested, workspace: match(requested), missingCwd: false } @@ -1390,7 +1458,7 @@ export function makeLiveSessionPort(ctx) { } const sessionId = randomUUID() const udsAuth = tryGet(ctx, 'udsAuth') - const placement = resolveSessionPlacement(ctx, job, { udsAuth }) + const placement = resolveSessionPlacement(ctx, job, { udsAuth, allowExternalCwd: true }) if (placement.missingCwd) { const error = new Error('IM scheduled job has no cwd; recreate it from the channel chat') error.code = 'IM_JOB_MISSING_CWD' diff --git a/lib/index.js b/lib/index.js index 26774b9..db178ab 100644 --- a/lib/index.js +++ b/lib/index.js @@ -115,6 +115,7 @@ export const Config = Schema.object({ historyLimit: Schema.number().min(10).max(2000).step(1).default(DEFAULT_SETTINGS.historyLimit), overlapPolicy: Schema.union(['skip']).default(DEFAULT_SETTINGS.overlapPolicy), misfirePolicy: Schema.union(['skip']).default(DEFAULT_SETTINGS.misfirePolicy), + allowExternalCwd: Schema.boolean().default(DEFAULT_SETTINGS.allowExternalCwd), }) function resolveConfig(config = {}) { @@ -126,6 +127,7 @@ function resolveConfig(config = {}) { historyLimit: Number(config.historyLimit) > 0 ? Number(config.historyLimit) : DEFAULT_SETTINGS.historyLimit, overlapPolicy: config.overlapPolicy === 'skip' ? 'skip' : DEFAULT_SETTINGS.overlapPolicy, misfirePolicy: config.misfirePolicy === 'skip' ? 'skip' : DEFAULT_SETTINGS.misfirePolicy, + allowExternalCwd: config.allowExternalCwd !== false, } } diff --git a/lib/store.js b/lib/store.js index bf20a7a..88f7ef4 100644 --- a/lib/store.js +++ b/lib/store.js @@ -22,6 +22,12 @@ export const DEFAULT_SETTINGS = { historyLimit: 200, overlapPolicy: 'skip', misfirePolicy: 'skip', + /** + * When true (default), an explicit job cwd that lies OUTSIDE the multi-tenant + * provision forest (workspaceRoot/) is kept — e.g. D:\\code\\gpt. + * Paths under the forest but not the owner's tree are still clamped. + */ + allowExternalCwd: true, } export function emptyState() { @@ -37,6 +43,9 @@ export function emptyState() { export function normalizeSettings(input = {}, fallback = DEFAULT_SETTINGS) { const src = input && typeof input === 'object' ? input : {} const historyLimit = Number(src.historyLimit) + const allowExternalCwd = src.allowExternalCwd !== undefined + ? src.allowExternalCwd !== false + : fallback.allowExternalCwd !== false return { enabled: src.enabled !== false, timezone: typeof src.timezone === 'string' && src.timezone.trim() @@ -47,6 +56,7 @@ export function normalizeSettings(input = {}, fallback = DEFAULT_SETTINGS) { : fallback.historyLimit, overlapPolicy: src.overlapPolicy === 'skip' ? 'skip' : fallback.overlapPolicy, misfirePolicy: src.misfirePolicy === 'skip' ? 'skip' : fallback.misfirePolicy, + allowExternalCwd, } } diff --git a/package.json b/package.json index 4dddbc3..3d25487 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dsh-ops-cron", "description": "Scheduled tasks for DeepSeek Harness: Agent cron_* tools + sidebar 定时任务, with DSH or WhatsApp/IM delivery per job.", - "version": "0.1.14", + "version": "0.1.15", "private": false, "type": "module", "repository": { diff --git a/test/host.test.js b/test/host.test.js index e364ad8..98e8642 100644 --- a/test/host.test.js +++ b/test/host.test.js @@ -603,10 +603,16 @@ test('resolveSessionPlacement keeps foreign cwd for super_admin owner under stri assert.equal(kept.cwd, 'D:/code/gpt') const clamped = resolveSessionPlacement(ctx, { - cwd: 'D:/code/gpt', + cwd: '/tmp/user-workspaces/peer/x', ownerEmpNo: 'tester', }, { udsAuth: uds }) assert.equal(clamped.cwd, '/tmp/user-workspaces/tester') + + const external = resolveSessionPlacement(ctx, { + cwd: 'D:/code/gpt', + ownerEmpNo: 'tester', + }, { udsAuth: uds }) + assert.equal(external.cwd, 'D:/code/gpt') }) test('resolveSessionPlacement prefers owner provisioned path over recent workspace when cwd empty', () => { @@ -668,7 +674,18 @@ test('super_admin createJob keeps explicit foreign cwd; normal user is clamped', cwd: 'D:/code/gpt', schedule: { kind: 'cron', expr: '0 3 * * *', timezone: 'Asia/Shanghai' }, }, userId) - assert.equal(userJob.cwd, '/tmp/user-workspaces/tester') + // Shared tree outside provision forest is kept (allowExternalCwd default). + assert.equal(userJob.cwd, 'D:/code/gpt') + + await assert.rejects( + () => service.createJob({ + name: 'steal', + prompt: 'no', + cwd: '/tmp/user-workspaces/peer/secret', + schedule: { kind: 'cron', expr: '0 4 * * *', timezone: 'Asia/Shanghai' }, + }, userId), + (err) => err && err.code === 'CWD_FORBIDDEN', + ) }) test('createJob elevates via live resolveIdentityForEmpNo when caller permissions are empty', async (t) => {