Unpick one site's conventions from the shipped bundle, and bind the launch-path

check to the file it actually runs

Two clusters the five-agent review found. Both are about a product that another
plant has to be able to install.

ADR-015: the shipped monitor bundle was the originating plant's fleet
convention frozen into product seed.

  The business policy was seeded as "RPCA Shopfloor App Monitors". RPCA is that
  plant's facility designator, the row is source='shipped', and the API refused
  to rename it - so every adopting site would carry another plant's label at the
  top of its policy list forever. 0007 now seeds the neutral name, and 0010
  renames it on installs that already stamped 0007, guarded so it cannot collide
  with a policy a site made itself. Downgrade is one-way on purpose: putting
  another site's designator back is not a state worth reaching.

  Which PC type the bundle attaches to was the literal 'Shopfloor PC'. A plant
  whose type is "Shop Floor Workstation" silently got no shipped monitors at
  all, and one using that name for something else got all of them. It is a
  setting now, defaulting to the seeded name so nothing changes here, and a
  blank value attaches the bundle to nothing - the right neutral answer for a
  site that does not want it.

  Shipped rows refused every edit, including isactive and launchpath. A plant
  whose eDNC is on D:, or that runs no UDC, could not correct the path or switch
  the monitor off, and then carried permanent false drift for it. ADR-015 allows
  seed data BECAUSE a site can edit it afterwards. Paths and the active flag are
  now editable; kind and displayname stay fixed, because that pair is the
  identity 0007's bundle matches on.

  Also corrected two false claims in 0007's docstring: the policy tables belong
  to the computers plugin, not the core baseline, and a guarded no-op does NOT
  reconcile later - Alembic stamps the revision either way, so the bundle would
  simply never seed.

LaunchPathPolicy was purely lexical. GetFullPath is string normalization that
never touches the disk, so the root check constrained the NAME while File.Exists
and CreateProcess followed reparse points to somewhere else. A junction under a
writable directory in an approved root - creating one needs no privilege, only
write access to the parent, and vendor trees under Program Files are not
reliably locked down - turned WebUI monitor-edit rights into code execution as
the console operator. It now resolves the real target, leaf and directory chain
both, and re-checks THAT against the roots, rejecting if resolution fails.

Alternate data streams passed the whole policy on net8.0: an interior colon no
longer throws in GetFullPath, so "readme.txt:payload.exe" reported an .exe
extension, passed the root check, and existed. Rejected explicitly now.

Six tests added for the shipped-row and pctype behaviour. The Core project
compiles clean. The launch-path change has NOT been exercised against real
junctions, ADS or 8.3 names - that needs the win11 VM, and per the reviewer it
wants a planted-violation suite there rather than my say-so.

Still open from the review: monitor revocation does not take effect until agent
restart, ActionPicker is keyboard-inoperable, the lightbox has no focus trap,
one attached monitor grants generic any-target process control, and screenshots
are retained unencrypted in the user profile.

Scope: product
This commit is contained in:
cproudlock
2026-09-02 15:44:17 -04:00
parent 88a683dad3
commit e98e15259e
8 changed files with 291 additions and 21 deletions

View File

@@ -63,6 +63,19 @@ public sealed class LaunchPathPolicy
return false;
}
// Reject an alternate data stream before anything else looks at the
// path. On .NET Framework an interior colon made GetFullPath throw; on
// net8.0 it does not, so "readme.txt:payload.exe" survives, reports an
// .exe extension, passes the root check, and File.Exists returns true
// for the stream. CreateProcess will run it. Write access to any
// existing file under an approved root would otherwise be enough.
var lastSegment = Path.GetFileName(resolved);
if (lastSegment.Contains(':'))
{
reason = "path names an alternate data stream";
return false;
}
var extension = Path.GetExtension(resolved);
if (!ApprovedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
{
@@ -82,10 +95,81 @@ public sealed class LaunchPathPolicy
return false;
}
fullPath = resolved;
// Everything above is LEXICAL: GetFullPath is pure string normalization
// and never touches the disk, so the root check so far constrains the
// name, not the file. File.Exists follows reparse points, which means a
// junction anywhere under an approved root pointed the check at one
// place and the execution at another. Creating a junction needs no
// privilege, only write access to the parent - and vendor install trees
// under Program Files are not reliably locked down.
//
// So resolve what the path actually refers to and re-check THAT. A
// resolve failure rejects: this is the last gate before the agent runs
// the file.
string finalPath;
try
{
finalPath = ResolveFinalPath(resolved);
}
catch (Exception ex)
{
reason = $"cannot resolve the real path: {ex.Message}";
return false;
}
if (!IsUnderApprovedRoot(finalPath))
{
reason = "path resolves outside every approved launch root";
return false;
}
fullPath = finalPath;
return true;
}
// The path with every symlink and junction on it followed, so the caller
// checks the file that will actually execute. The leaf is resolved first,
// then the directory chain above it - a junction is usually placed on a
// DIRECTORY, so resolving only the leaf would miss the case this exists to
// catch.
private static string ResolveFinalPath(string resolved)
{
var name = System.IO.File.ResolveLinkTarget(resolved, returnFinalTarget: true)?.FullName;
if (!string.IsNullOrWhiteSpace(name))
{
// ResolveLinkTarget resolves the file itself; run it back through
// GetFullPath so the comparison sees the same shape as the roots.
return Path.GetFullPath(name);
}
// Not a link. Its directory chain still might be, so resolve that.
var directory = Path.GetDirectoryName(resolved);
if (string.IsNullOrEmpty(directory))
return resolved;
var realDirectory = ResolveDirectoryChain(directory);
return Path.GetFullPath(Path.Combine(realDirectory, Path.GetFileName(resolved)));
}
// Walk up the directory chain resolving any junction or symlink on it. A
// junction is usually placed on a DIRECTORY, not on the executable, so
// resolving only the leaf would miss the case this exists to catch.
private static string ResolveDirectoryChain(string directory)
{
var resolvedDirectory = System.IO.Directory
.ResolveLinkTarget(directory, returnFinalTarget: true)?.FullName;
if (!string.IsNullOrWhiteSpace(resolvedDirectory))
return resolvedDirectory;
var parent = Path.GetDirectoryName(directory);
if (string.IsNullOrEmpty(parent) || parent == directory)
return directory;
var realParent = ResolveDirectoryChain(parent);
return realParent == parent
? directory
: Path.Combine(realParent, Path.GetFileName(directory));
}
private bool IsUnderApprovedRoot(string resolved)
{
foreach (var root in approvedRoots)