Module refinery.lib.scripts.ps1.analysis.world
The closed-world model of the PowerShell analysis substrate: whether the script leaves the .NET type
system and the command table intact, so that a present-member purity grant in
refinery.lib.scripts.ps1.analysis.effects can be trusted. A member read the metadata proves inert
(String.Length is a plain property) still runs code when the script has re-pointed that member
through the Extended Type System (Update-TypeData -Force), and a resolved type name still
constructs a different type when the script has remapped its accelerator — both confirmed possible
on Windows PowerShell. The gate therefore grants a present-member read only where the world is
closed: no code the script runs can have performed such a mutation.
The predicate opens the world on two axes with opposite defaults. Dispatch is an allow-list: a
command is inert only when its name is statically known, so any & $x / . $x / computed-name
call opens the world, closing the open-ended escape of runtime-constructed code without a list to
forget.
Mutation is a curated, documented deny-list — a pure allow-list would be vacuous, since the
collected metadata omits hundreds of host cmdlets and every one would then read as a possible
mutator. The deny-list is enumerated here rather than left silent, and its residual — an exotic
aliasing spelling, a using module statement, a computed provider path — is a soundness gap, not a
recall gap: a mutator the list misses leaves the world reading closed, which fires the grants and
deletes the reads that mutator makes effectful. Every name added to it buys correctness, not recall.
This is a leaf model in Phase 5c — a one-shot whole-script verdict — cached in
Ps1ModelCache and queried through
TypeOracle. Ps1TypeWorld.world_closed_at() takes the read
node so a flow-sensitive successor can make the answer depend on where the read sits relative to the
leaks that reach it; in this phase the node is not yet consulted.
Expand source code Browse git
"""
The closed-world model of the PowerShell analysis substrate: whether the script leaves the .NET type
system and the command table intact, so that a present-member purity grant in
`refinery.lib.scripts.ps1.analysis.effects` can be trusted. A member read the metadata proves inert
(`String.Length` is a plain property) still runs code when the script has re-pointed that member
through the Extended Type System (`Update-TypeData -Force`), and a resolved type name still
constructs a different type when the script has remapped its accelerator — both confirmed possible
on Windows PowerShell. The gate therefore grants a present-member read only where the world is
*closed*: no code the script runs can have performed such a mutation.
The predicate opens the world on two axes with opposite defaults. **Dispatch** is an allow-list: a
command is inert only when its name is statically known, so any `& $x` / `. $x` / computed-name
call opens the world, closing the open-ended escape of runtime-constructed code without a list to
forget.
**Mutation** is a curated, documented deny-list — a pure allow-list would be vacuous, since the
collected metadata omits hundreds of host cmdlets and every one would then read as a possible
mutator. The deny-list is enumerated here rather than left silent, and its residual — an exotic
aliasing spelling, a `using module` statement, a computed provider path — is a *soundness* gap, not a
recall gap: a mutator the list misses leaves the world reading closed, which fires the grants and
deletes the reads that mutator makes effectful. Every name added to it buys correctness, not recall.
This is a leaf model in Phase 5c — a one-shot whole-script verdict — cached in
`refinery.lib.scripts.ps1.analysis.cache.Ps1ModelCache` and queried through
`refinery.lib.scripts.ps1.analysis.types.TypeOracle`. `Ps1TypeWorld.world_closed_at` takes the read
node so a flow-sensitive successor can make the answer depend on where the read sits relative to the
leaks that reach it; in this phase the node is not yet consulted.
"""
from __future__ import annotations
import enum
from typing import NamedTuple
from refinery.lib.scripts.ps1.ast import (
assignment_target_variables,
get_member_name,
is_execution_context_invoke,
is_opaque_dispatch,
is_scriptblock_create,
is_scriptblock_invoke,
normalize_command_name,
normalize_dotnet_type_name,
resolve_command_name,
string_value,
unwrap_assignment_target,
unwrap_parens,
)
from refinery.lib.scripts.ps1.model import (
Ps1AccessKind,
Ps1ArrayLiteral,
Ps1AssignmentExpression,
Ps1ClassDefinition,
Ps1CommandArgument,
Ps1CommandInvocation,
Ps1EnumDefinition,
Ps1FunctionDefinition,
Ps1InvokeMember,
Ps1MemberAccess,
Ps1ScopeModifier,
Ps1Script,
Ps1ScriptBlock,
Ps1StringLiteral,
Ps1TypeExpression,
Ps1Variable,
)
#: Commands that execute arbitrary code supplied as data. `Invoke-Expression` is the canonical one;
#: the opaque dispatch and scriptblock-execution forms are recognized syntactically instead. The job
#: and remoting cmdlets belong here rather than beside the mutators: each takes a scriptblock the
#: walk cannot read when it is written as a variable, and the type-system effects such a block
#: performs are runspace-global, so a child scope does not contain them.
_LEAK_CMDLETS = frozenset({
'invoke-command',
'invoke-expression',
'start-job',
'start-threadjob',
})
#: Commands that mutate the .NET type system, so reflection can no longer be trusted to describe a
#: type's members. Curated and documented rather than derived — the module docstring says why a
#: mutation allow-list would be vacuous. Names are compared after alias resolution.
_MUTATION_CMDLETS = frozenset({
'add-member',
'add-type',
'import-module',
'new-module',
'update-typedata',
})
#: Commands that redefine command identity, after which a later bareword can no longer be trusted to
#: name what the metadata says — including a mutator hidden behind the new name. A static
#: single-definition alias is inlined away before this runs, so a *surviving* one is an alias the
#: inliner could not resolve.
_ALIAS_CMDLETS = frozenset({
'import-alias',
'new-alias',
'remove-alias',
'set-alias',
})
#: The file extension of a PowerShell script. Invoking one runs its definitions and whatever type
#: mutations it performs into this session, whichever operator carries the call.
_SCRIPT_FILE_SUFFIX = '.ps1'
#: The variable namespaces that name a command rather than a value: assigning into either redefines
#: command identity the way `_ALIAS_CMDLETS` do.
_IDENTITY_SCOPES = frozenset({
Ps1ScopeModifier.ALIAS,
Ps1ScopeModifier.FUNCTION,
})
#: The provider names that address command identity, written as a path argument to an item cmdlet
#: (`Set-Item alias:x ...`). Matched by name rather than by enumerating every aliasing cmdlet, which
#: is the family the mutation deny-list cannot close by name.
_IDENTITY_PROVIDERS = ('alias', 'function')
class Ps1TypeWorld:
"""
The verdict of `build_closed_world`: whether the running script leaves the type system and
command table intact. It carries both command-table facts the purity gate needs — the
whole-world verdict (`world_closed_at`) and the set of command names the script redefines
(`command_shadowed`) — so the two cannot drift apart. Held in a
`refinery.lib.scripts.ps1.analysis.cache.Ps1ModelCache` slot and consulted through
`refinery.lib.scripts.ps1.analysis.types.TypeOracle`.
"""
def __init__(self, closed: bool, shadowed: frozenset[str]):
self._closed = closed
self._shadowed = shadowed
def world_closed_at(self, node) -> bool:
"""
Whether the type world is closed at `node`. In Phase 5c this is the whole-script verdict and
`node` is not consulted — it is a forward-declared seam for the flow-sensitive successor,
which will make the answer depend on whether a leak reaches this read rather than whether
the script contains one anywhere.
"""
return self._closed
def command_shadowed(self, name: str) -> bool:
"""
Whether `name` is a command the script redefines with a script-local `function`/`filter`
or a `function:`/`alias:`-scope assignment, so the collected metadata no longer describes
what the name runs. The analysis must not trust such a name for typing or purity. The set is
whole-script and conservative — an inner-scope redefinition distrusts the name everywhere,
which only keeps more — mirroring `world_closed_at`'s whole-script granularity.
The query is normalized the way the set was built, so the spelling a caller happens to hold
cannot answer `False` for a name the walk recorded under its canonical key.
"""
return normalize_command_name(name) in self._shadowed
@property
def shadowed_names(self) -> frozenset[str]:
"""
Every command name the script redefines, keyed through
`refinery.lib.scripts.ps1.ast.normalize_command_name`. Exposed so a transform that must not
rewrite a name the script has taken over reads the one set the whole-tree walk built,
instead of keeping a narrower private one that sees only `function` definitions.
"""
return self._shadowed
def build_closed_world(root: Ps1Script) -> Ps1TypeWorld:
"""
Walk the whole tree once, computing both command-table facts: whether any node opens the world
(a single opener anywhere is global and retroactive, so it closes off the verdict) and the set
of command names the script redefines. The walk cannot short-circuit on the first opener
because the shadow set needs every redefinition, wherever it sits.
"""
closed = True
shadowed: set[str] = set()
for node in root.walk():
redefined = _identity_redefinitions(node)
shadowed.update(record.name for record in redefined)
if _opens_world(node, redefined):
closed = False
return Ps1TypeWorld(closed, frozenset(shadowed))
class _IdentityBody(enum.Enum):
"""
What a command redefinition binds the name to, which is what decides whether the redefinition
also opens the type world. An enum rather than a boolean because the two ways of *not* being a
visible block are unrelated — one hides a body inside a value, the other names another command
entirely — and collapsing them would make the world rule read as if it had one reason.
"""
#: A scriptblock literal standing in the tree, so the whole-tree walk reads its statements and
#: catches a mutation inside it by presence. The only kind that leaves the world closed.
VISIBLE_BLOCK = enum.auto()
#: A value the walk cannot see through — a variable, a call's result, or a compound assignment
#: folding in whatever the name held before.
OPAQUE_VALUE = enum.auto()
#: Another command, named rather than defined. Its body is not in this script at all.
EXTERNAL_COMMAND = enum.auto()
class _IdentityRedefinition(NamedTuple):
name: str
body: _IdentityBody
def _identity_redefinitions(node) -> tuple[_IdentityRedefinition, ...]:
"""
The command names `node` redefines, each normalized to the key a call resolves under and paired
with what it binds, or an empty tuple. A `function`/`filter` definition names one command
directly; an assignment into the `function:`/`alias:` variable namespace names one per slot it
writes, so the multi-assignment `${function:Get-Date}, $y = { ... }, 2` records `get-date` where
matching one target shape against one variable would miss it.
Normalizing is what makes the name usable: `function global:Get-Date` defines exactly what a
later unqualified `Get-Date` runs, and a shadow set holding the qualified spelling answers `False`
to every consumer that asks about the unqualified one.
Only a plain `=` onto a single target reports `VISIBLE_BLOCK`. A multi-assignment could be
paired up with the values on its right, but the target list drops non-variable slots, so the
position a variable came from is already lost — and the shape is rare enough that reading
every slot of one as opaque costs nothing.
"""
if isinstance(node, Ps1FunctionDefinition):
return (_IdentityRedefinition(
normalize_command_name(node.name), _IdentityBody.VISIBLE_BLOCK),)
if not isinstance(node, Ps1AssignmentExpression):
return ()
targets = assignment_target_variables(node.target)
single = len(targets) == 1 and not isinstance(
unwrap_assignment_target(node.target), Ps1ArrayLiteral)
return tuple(
_IdentityRedefinition(
normalize_command_name(variable.name),
_assigned_identity_body(node, variable, single),
)
for variable in targets
if variable.scope in _IDENTITY_SCOPES
)
def _assigned_identity_body(
node: Ps1AssignmentExpression, variable: Ps1Variable, single: bool,
) -> _IdentityBody:
"""
What an identity-scope assignment binds its name to. The `alias:` namespace always names another
command; the `function:` namespace binds a scriptblock, which is readable only when written out
as a literal and this assignment plainly rebinds one target.
"""
if variable.scope is Ps1ScopeModifier.ALIAS:
return _IdentityBody.EXTERNAL_COMMAND
if not single or node.operator != '=' or node.value is None:
return _IdentityBody.OPAQUE_VALUE
if isinstance(unwrap_parens(node.value), Ps1ScriptBlock):
return _IdentityBody.VISIBLE_BLOCK
return _IdentityBody.OPAQUE_VALUE
def _opens_world(node, redefined: tuple[_IdentityRedefinition, ...]) -> bool:
"""
Whether `node` leaves the type system or the command table in a state the collected metadata no
longer describes. `redefined` is the identity classification of the same node, so an assignment
into the identity namespaces is recognized once, not by two functions that can drift apart.
A redefinition binding a visible scriptblock does *not* open the world. Its body stands in the
tree, so a mutation inside it is caught by presence like any other statement, and the same
construct spelled `function X { }` has always left the world closed. Opening on it would kill
every member grant in the script over a name the shadow set already distrusts.
A `class` or `enum` definition does open it, for the reason `Add-Type` does: it puts a type into
the session under a name the collected metadata never described, and a name it *did* describe is
exactly the interesting case — `class Math { static [int] Abs([int]$x) { <payload> } }` makes
`[Math]::Abs(1)` run that body while `resolve_type` still vouches for `System.Math`. The
definition standing in the tree does not help, because the grant is keyed on the type name
rather than on the presence of a body.
"""
if isinstance(node, (Ps1ClassDefinition, Ps1EnumDefinition)):
return True
if isinstance(node, Ps1CommandInvocation):
return _command_opens_world(node)
if isinstance(node, Ps1InvokeMember):
return (
is_scriptblock_create(node)
or is_scriptblock_invoke(node)
or is_execution_context_invoke(node)
or _is_type_accelerator_mutation(node)
or _is_psobject_member_mutation(node)
)
if isinstance(node, Ps1AssignmentExpression):
return any(record.body is not _IdentityBody.VISIBLE_BLOCK for record in redefined)
return False
def _command_opens_world(cmd: Ps1CommandInvocation) -> bool:
if is_opaque_dispatch(cmd):
return True
if _runs_another_script_file(cmd):
return True
name = resolve_command_name(cmd)
if name is None:
return False
if name in _LEAK_CMDLETS or name in _MUTATION_CMDLETS or name in _ALIAS_CMDLETS:
return True
return _touches_identity_provider(cmd)
def _runs_another_script_file(cmd: Ps1CommandInvocation) -> bool:
"""
Whether `cmd` runs a `.ps1` file that is not part of this tree, so the analysis cannot see what
it defines or mutates. Dot-sourcing is the spelling that matters most — it runs the file's
definitions into the current scope — but the operator is not what makes the file opaque: a
`& '.\\stage2.ps1'` or a bareword `stage2.ps1` runs the same code, and the Extended Type System
and accelerator mutations it may perform are runspace-global rather than scope-local, so the
child scope a call operator opens does not contain them.
A dot-sourced inline block (`.{ ... }`) runs only its visible body, which the walk covers, and
`. $x` is already opaque dispatch.
"""
if not isinstance(cmd.name, Ps1StringLiteral):
return False
return (
cmd.invocation_operator == '.'
or cmd.name.value.lower().endswith(_SCRIPT_FILE_SUFFIX)
)
def _touches_identity_provider(cmd: Ps1CommandInvocation) -> bool:
"""
Whether any argument is a literal path into the `alias:` or `function:` provider, the vector
that escapes a name-keyed deny-list because `Set-Item alias:x Update-TypeData` mutates identity
without `Set-Item` being an aliasing cmdlet. Recognized by the provider the path names, not by
resolving the aliased target, so an obfuscated or dynamic target cannot slip through.
The provider is read after the same two decorations `resolve_command_name` strips from a
command name, because a path addresses the identical namespace through either:
`Microsoft.PowerShell.Core\\Function::Get-Date` is what `function:Get-Date` is short for, and a
prefix test keyed on the short spelling reads the long one as an ordinary file path.
"""
for arg in cmd.arguments:
value = arg.value if isinstance(arg, Ps1CommandArgument) else arg
text = string_value(value)
if text is None:
continue
provider = text.rpartition('\\')[2].partition(':')[0].lower()
if provider in _IDENTITY_PROVIDERS:
return True
return False
def _is_type_accelerator_mutation(node: Ps1InvokeMember) -> bool:
"""
Whether `node` adds or removes a type accelerator through
`[…PSObject+TypeAccelerators]::Add/Remove`, which remaps what a type name resolves to.
"""
obj = node.object
return (
node.access is Ps1AccessKind.STATIC
and isinstance(obj, Ps1TypeExpression)
and 'typeaccelerators' in normalize_dotnet_type_name(obj.name)
and isinstance(node.member, str)
and node.member.lower() in ('add', 'remove')
)
def _is_psobject_member_mutation(node: Ps1InvokeMember) -> bool:
"""
Whether `node` adds or removes a member through the reflective
`$obj.PSObject.Members.Add/Remove` chain, the Extended Type System mutation that is not a cmdlet
call and so escapes the name-keyed deny-list.
"""
if node.access is not Ps1AccessKind.INSTANCE:
return False
if not (isinstance(node.member, str) and node.member.lower() in ('add', 'remove')):
return False
members = node.object
if not isinstance(members, Ps1MemberAccess):
return False
members_name = get_member_name(members.member)
if members_name is None or members_name.lower() != 'members':
return False
psobject = members.object
if not isinstance(psobject, Ps1MemberAccess):
return False
psobject_name = get_member_name(psobject.member)
return psobject_name is not None and psobject_name.lower() == 'psobject'
Functions
def build_closed_world(root)-
Walk the whole tree once, computing both command-table facts: whether any node opens the world (a single opener anywhere is global and retroactive, so it closes off the verdict) and the set of command names the script redefines. The walk cannot short-circuit on the first opener because the shadow set needs every redefinition, wherever it sits.
Expand source code Browse git
def build_closed_world(root: Ps1Script) -> Ps1TypeWorld: """ Walk the whole tree once, computing both command-table facts: whether any node opens the world (a single opener anywhere is global and retroactive, so it closes off the verdict) and the set of command names the script redefines. The walk cannot short-circuit on the first opener because the shadow set needs every redefinition, wherever it sits. """ closed = True shadowed: set[str] = set() for node in root.walk(): redefined = _identity_redefinitions(node) shadowed.update(record.name for record in redefined) if _opens_world(node, redefined): closed = False return Ps1TypeWorld(closed, frozenset(shadowed))
Classes
class Ps1TypeWorld (closed, shadowed)-
The verdict of
build_closed_world(): whether the running script leaves the type system and command table intact. It carries both command-table facts the purity gate needs — the whole-world verdict (world_closed_at) and the set of command names the script redefines (command_shadowed) — so the two cannot drift apart. Held in aPs1ModelCacheslot and consulted throughTypeOracle.Expand source code Browse git
class Ps1TypeWorld: """ The verdict of `build_closed_world`: whether the running script leaves the type system and command table intact. It carries both command-table facts the purity gate needs — the whole-world verdict (`world_closed_at`) and the set of command names the script redefines (`command_shadowed`) — so the two cannot drift apart. Held in a `refinery.lib.scripts.ps1.analysis.cache.Ps1ModelCache` slot and consulted through `refinery.lib.scripts.ps1.analysis.types.TypeOracle`. """ def __init__(self, closed: bool, shadowed: frozenset[str]): self._closed = closed self._shadowed = shadowed def world_closed_at(self, node) -> bool: """ Whether the type world is closed at `node`. In Phase 5c this is the whole-script verdict and `node` is not consulted — it is a forward-declared seam for the flow-sensitive successor, which will make the answer depend on whether a leak reaches this read rather than whether the script contains one anywhere. """ return self._closed def command_shadowed(self, name: str) -> bool: """ Whether `name` is a command the script redefines with a script-local `function`/`filter` or a `function:`/`alias:`-scope assignment, so the collected metadata no longer describes what the name runs. The analysis must not trust such a name for typing or purity. The set is whole-script and conservative — an inner-scope redefinition distrusts the name everywhere, which only keeps more — mirroring `world_closed_at`'s whole-script granularity. The query is normalized the way the set was built, so the spelling a caller happens to hold cannot answer `False` for a name the walk recorded under its canonical key. """ return normalize_command_name(name) in self._shadowed @property def shadowed_names(self) -> frozenset[str]: """ Every command name the script redefines, keyed through `refinery.lib.scripts.ps1.ast.normalize_command_name`. Exposed so a transform that must not rewrite a name the script has taken over reads the one set the whole-tree walk built, instead of keeping a narrower private one that sees only `function` definitions. """ return self._shadowedInstance variables
var shadowed_names-
Every command name the script redefines, keyed through
normalize_command_name(). Exposed so a transform that must not rewrite a name the script has taken over reads the one set the whole-tree walk built, instead of keeping a narrower private one that sees onlyfunctiondefinitions.Expand source code Browse git
@property def shadowed_names(self) -> frozenset[str]: """ Every command name the script redefines, keyed through `refinery.lib.scripts.ps1.ast.normalize_command_name`. Exposed so a transform that must not rewrite a name the script has taken over reads the one set the whole-tree walk built, instead of keeping a narrower private one that sees only `function` definitions. """ return self._shadowed
Methods
def world_closed_at(self, node)-
Whether the type world is closed at
node. In Phase 5c this is the whole-script verdict andnodeis not consulted — it is a forward-declared seam for the flow-sensitive successor, which will make the answer depend on whether a leak reaches this read rather than whether the script contains one anywhere.Expand source code Browse git
def world_closed_at(self, node) -> bool: """ Whether the type world is closed at `node`. In Phase 5c this is the whole-script verdict and `node` is not consulted — it is a forward-declared seam for the flow-sensitive successor, which will make the answer depend on whether a leak reaches this read rather than whether the script contains one anywhere. """ return self._closed def command_shadowed(self, name)-
Whether
nameis a command the script redefines with a script-localfunction/filteror afunction:/alias:-scope assignment, so the collected metadata no longer describes what the name runs. The analysis must not trust such a name for typing or purity. The set is whole-script and conservative — an inner-scope redefinition distrusts the name everywhere, which only keeps more — mirroringworld_closed_at's whole-script granularity.The query is normalized the way the set was built, so the spelling a caller happens to hold cannot answer
Falsefor a name the walk recorded under its canonical key.Expand source code Browse git
def command_shadowed(self, name: str) -> bool: """ Whether `name` is a command the script redefines with a script-local `function`/`filter` or a `function:`/`alias:`-scope assignment, so the collected metadata no longer describes what the name runs. The analysis must not trust such a name for typing or purity. The set is whole-script and conservative — an inner-scope redefinition distrusts the name everywhere, which only keeps more — mirroring `world_closed_at`'s whole-script granularity. The query is normalized the way the set was built, so the spelling a caller happens to hold cannot answer `False` for a name the walk recorded under its canonical key. """ return normalize_command_name(name) in self._shadowed