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 value model — a one-shot whole-script verdict plus the shadow set — cached in
Ps1ModelCache. Whether the world is closed at one
particular read, which depends on where that read sits relative to the leaks that reach it, is a
graph question and does not live here: Ps1WorldReach
answers it, layered on this model and on the control-flow graph. measure_world() produces the
verdict and the opener positions the flow gate floods from in one walk, so the two cannot disagree
about what an opener is.
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 value model — a one-shot whole-script verdict plus the shadow set — cached in
`refinery.lib.scripts.ps1.analysis.cache.Ps1ModelCache`. Whether the world is closed at *one*
particular read, which depends on where that read sits relative to the leaks that reach it, is a
graph question and does not live here: `refinery.lib.scripts.ps1.analysis.worldflow.Ps1WorldReach`
answers it, layered on this model and on the control-flow graph. `measure_world` produces the
verdict and the opener positions the flow gate floods from in one walk, so the two cannot disagree
about what an opener is.
"""
from __future__ import annotations
import enum
from typing import NamedTuple
from refinery.lib.scripts import Node, tree_version
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.data import KNOWN_ALIAS
from refinery.lib.scripts.ps1.model import (
Ps1AccessKind,
Ps1ArrayLiteral,
Ps1AssignmentExpression,
Ps1ClassDefinition,
Ps1CommandArgument,
Ps1CommandArgumentKind,
Ps1CommandInvocation,
Ps1EnumDefinition,
Ps1FunctionDefinition,
Ps1InvokeMember,
Ps1MemberAccess,
Ps1ScopeModifier,
Ps1Script,
Ps1ScriptBlock,
Ps1StringLiteral,
Ps1TypeExpression,
Ps1Variable,
)
from refinery.lib.scripts.ps1.options import eval_is_trusted
class WorldRole(enum.Enum):
"""
What a command does to the type world and the command table. The three opening roles are kept
apart rather than collapsed into one boolean because a caller acts differently on each: a pass
deleting an alias definition has to know that the invocation blocking it is another alias
definition, which it may yet be able to delete too, and not a leak, which it never can.
`NONE` and `UNKNOWN` are the two ways of not naming a role, and they are as far apart as they
are in `refinery.lib.scripts.ps1.analysis.commands.CommandKind`: `NONE` says the command leaves
the world as it found it, `UNKNOWN` says nothing static bounds what it runs.
"""
#: Runs code supplied as data, so what it does to the world is whatever that data says.
LEAK = enum.auto()
#: Mutates the .NET type system, after which reflection no longer describes a type's members.
MUTATION = enum.auto()
#: Redefines command identity, after which a bareword no longer names what the metadata says.
IDENTITY = enum.auto()
#: Leaves both intact.
NONE = enum.auto()
#: Dispatches to whatever an expression yields, so it may be any of the above.
UNKNOWN = enum.auto()
#: 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',
})
#: The subset of the above that runs its code in the scope that called it, so that a command or
#: variable the code writes lands in this script's own tables. See `runs_code_in_the_calling_scope`.
_CALLER_SCOPE_LEAKS = frozenset({'invoke-expression'})
#: 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 subset of the above that binds one name to one command, written out in the script, and
#: nothing else — the only alias definition a pass is in a position to take out. See
#: `Ps1TypeWorld.closed_but_for_alias_bindings`.
_ALIAS_BINDING_COMMANDS = frozenset({'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')
#: The cmdlets whose subject is a provider path, so that one they are given as an expression may be
#: a path into an identity provider however it is spelled. See `may_touch_identity_provider`.
_ITEM_CMDLETS = frozenset({
'clear-item',
'copy-item',
'move-item',
'new-item',
'remove-item',
'rename-item',
'set-item',
})
#: The keywords that define a command under a name given as an argument rather than as a definition
#: node. `workflow NAME { ... }` and `configuration NAME { ... }` each introduce a command named NAME
#: that shadows a same-named cmdlet under 5.1's Function-over-Cmdlet precedence, but the parser emits
#: both as a plain invocation whose name is the keyword, so the name they take over is read off the
#: arguments the way an item cmdlet's provider path is.
_COMMAND_DEFINITION_KEYWORDS = frozenset({'workflow', 'configuration'})
def command_role(name: str) -> WorldRole:
"""
What the command `name` does to the world, or `WorldRole.NONE` when no deny-list holds it. Never
`WorldRole.UNKNOWN`: a name is by construction something this can look up, and not knowing what
an invocation runs is a fact about the invocation rather than about any name.
The lookup key is the *deny-list* reading `refinery.lib.scripts.ps1.ast.resolve_command_name`
describes, taken here rather than owed by the caller: the module and scope qualifiers dropped
and one hop through the built-in alias table, so that neither `Microsoft.PowerShell.Utility\\iex`
nor `global:iex` nor plain `iex` can dodge a table the bare `Invoke-Expression` matches. Eight
of the entries below are reachable only through that hop, so a caller handing over a name it
had not resolved would otherwise read a deny-list answer of `NONE` — the one direction a
deny-list must never fail in. Taking the key here is what makes that impossible to get wrong at
a call site. It is idempotent: no built-in alias names what another one resolves to.
This is the one place the three tables are read. `refinery.lib.scripts.ps1.analysis.commands`
asks the same question of a name it reached by following the script's own aliases — which this
module cannot follow, since the command model is built over the shadow set this one produces —
and a second reading of the tables there would be a second deny-list to keep in step.
"""
key = normalize_command_name(name.rpartition('\\')[2])
key = KNOWN_ALIAS.get(key, key).lower()
if key in _LEAK_CMDLETS:
return WorldRole.LEAK
if key in _MUTATION_CMDLETS:
return WorldRole.MUTATION
if key in _ALIAS_CMDLETS:
return WorldRole.IDENTITY
return WorldRole.NONE
class Ps1ShadowSite(NamedTuple):
"""
One statement that takes a command name over: the `name` it rebinds, keyed through
`refinery.lib.scripts.ps1.ast.normalize_command_name` like the shadow set, and the `site` node
performing the redefinition. `refinery.lib.scripts.ps1.analysis.worldflow.build_world_reach`
floods forward from these the way it floods from the openers, so a call no redefinition of its
name can precede still means the built-in the metadata describes.
"""
name: str
site: Node
class Ps1WorldMeasurement(NamedTuple):
"""
One walk's reading of *root*: the whole-run `world` verdict, the `openers` that produced it and
the `shadow_sites` where a command name is taken over, each in walk order, and the `root` and
`build_version` they were measured over. All come from the same walk, so
`world.closed_for_the_whole_run` and `not openers` are the same fact, and the names of
`shadow_sites` are exactly `world.shadowed_names` — a node one counts and the other misses
cannot exist. Held in a `refinery.lib.scripts.ps1.analysis.cache.Ps1ModelCache` slot;
`build_world_reach` floods from `openers` and `shadow_sites` and stamps `build_version` onto
the reach model so a held one notices the tree change.
The opener and site nodes are live tree references, which is why this is a cache record rather
than a field of the leaf `Ps1TypeWorld`: a slot is dropped whole on the next version bump, so
its node references are never read against a tree they no longer belong to.
"""
world: Ps1TypeWorld
openers: tuple[Node, ...]
shadow_sites: tuple[Ps1ShadowSite, ...]
root: Ps1Script
build_version: int
#: The commands that write the command table with a binding written down beside them. `Import-Alias`
#: is the aliasing cmdlet that is not one: it takes its names from a file no analysis here sees, so
#: what it does to the table is unreadable in exactly the sense an `Invoke-Expression` payload is. A
#: command outside this set that merely mentions a provider path — `Get-ChildItem alias:`,
#: `Test-Path 'function:more'` — is read as an identity opener by `touches_identity_provider`, which
#: cannot tell a read from a write, and binds nothing at all.
_NAME_BINDING_COMMANDS = (_ALIAS_CMDLETS - {'import-alias'}) | _ITEM_CMDLETS
#: The parameter spellings that carry the binding such a command performs, each a prefix of `-Name`
#: or `-Value` because PowerShell binds a parameter by any unambiguous prefix of its name. Every
#: other named parameter — `-Force`, `-Scope`, `-Option`, `-Description` — steers the binding
#: without saying what it is, and reading one as part of the binding would let a `-Force:$true` make
#: a fully written-out rebinding look unreadable.
_BINDING_PARAMETERS = frozenset({
'n',
'na',
'nam',
'name',
'v',
'va',
'val',
'valu',
'value',
})
def _runs_unreadable_code(node, role: WorldRole) -> bool:
"""
Whether the danger `role` names at `node` is that code this analysis cannot read will run — now,
for `WorldRole.LEAK` and `WorldRole.UNKNOWN`, or under a name a later statement invokes, for
`WorldRole.IDENTITY`. These are the openers
`refinery.lib.scripts.ps1.options.Ps1DeobfuscationOptions.trust_eval` excuses.
`WorldRole.MUTATION` is never one: it is a change the script performs in plain sight, and
excusing it would mean disbelieving a statement the walk can read. Neither is a command that
writes the command table with the whole binding spelled out beside it — `New-Alias Get-Date
Stop-Process`, `Set-Item alias:Out-Null Write-Host`, `Set-Item function:Get-Date { ... }`. That
is the same plain-sight change wearing the command table's clothes, and it is the one opener
nothing else covers: `_identity_redefinitions` classifies a `function` statement and a
`function:`/`alias:` variable write, so a name an aliasing or item cmdlet takes over never
reaches the shadow set, and the verdict is all that stands between such a statement and a later
call to the name it rebound.
A binding with an unreadable half is excused, name or target either way, which is where the
trade the option buys actually sits: `Set-Alias Copy-Item $t` says which name it takes over but
not what that name will run, and refusing it would keep every script whose payload dispatcher is
reached through an alias. The residual is stated rather than left silent — such a name is
trusted afterwards, and closing that needs the shadow set to record what an aliasing cmdlet
binds, which is a reading no walk here performs.
"""
if role is WorldRole.MUTATION or role is WorldRole.NONE:
return False
if role is not WorldRole.IDENTITY or not isinstance(node, Ps1CommandInvocation):
return True
if normalize_command_name(resolve_command_name(node) or '') not in _NAME_BINDING_COMMANDS:
return True
return _binds_what_the_walk_cannot_read(node)
def _binds_what_the_walk_cannot_read(cmd: Ps1CommandInvocation) -> bool:
"""
Whether any argument of `cmd` that carries the binding hides what it holds.
A parameter and the value written after it reach the tree as two arguments — a switch naming the
parameter, then a positional holding the value — so the two are paired here. A value written
after `-Name` or `-Value`, and a positional that follows no parameter at all, carry the binding;
a value written after any other parameter steers it without saying what it is, and reading one
as part of the binding lets a `-Scope 1` make a fully written-out rebinding look unreadable.
"""
pending: str | None = None
for argument in cmd.arguments:
if not isinstance(argument, Ps1CommandArgument):
if not _is_written_out(argument):
return True
continue
name = argument.name.lstrip('-').lower()
if argument.kind is Ps1CommandArgumentKind.SWITCH:
pending = name
continue
if argument.kind is Ps1CommandArgumentKind.NAMED:
carries = name in _BINDING_PARAMETERS
else:
carries = pending is None or pending in _BINDING_PARAMETERS
pending = None
if carries and not _is_written_out(argument.value):
return True
return False
def _is_written_out(value) -> bool:
"""
Whether `value` stands in the tree as something this walk reads whole: a static string, a
scriptblock whose body is written out, a list of such values, or the absent value of a switch.
Anything else — a variable, the result of a call — hides what the command holding it binds.
"""
if value is None:
return True
value = unwrap_parens(value)
if isinstance(value, Ps1ScriptBlock):
return True
if isinstance(value, Ps1ArrayLiteral):
return all(_is_written_out(element) for element in value.elements)
return string_value(value) is not None
def measure_world(root: Ps1Script, options: object | None = None) -> Ps1WorldMeasurement:
"""
Walk the whole tree once, computing the command-table verdict and every position together:
whether any node opens the world (a single opener anywhere is global and retroactive, so it
closes off the verdict), the set of command names the script redefines and the site of each
redefinition, and every opener node in walk order. The walk cannot short-circuit on the first
opener because the shadow set needs every redefinition, wherever it sits, and the floods need
every position.
An opener is yielded as the node itself, not its role. The class or enum definition among them
opens the world at no position — the engine compiles it before the first statement runs — and is
recognized by `build_world_reach`, which must fail closed on it.
Under `refinery.lib.scripts.ps1.options.Ps1DeobfuscationOptions.trust_eval` an opener
`_runs_unreadable_code` answers for is not recorded at all, so it neither opens the whole-run
verdict nor floods a position. The shadow set is untouched by the option: every site in it is a
redefinition this walk *did* read, so nothing about it rests on what unreadable code does.
"""
trusting = eval_is_trusted(options)
closed = True
closed_but_for_alias_bindings = True
shadowed: set[str] = set()
openers: list[Node] = []
shadow_sites: list[Ps1ShadowSite] = []
for node in root.walk():
redefined = _identity_redefinitions(node)
shadowed.update(record.name for record in redefined)
shadow_sites.extend(Ps1ShadowSite(record.name, node) for record in redefined)
role = _opens_world(node, redefined)
if role is WorldRole.NONE or (trusting and _runs_unreadable_code(node, role)):
continue
openers.append(node)
closed = False
if not _opens_world_only_by_binding_an_alias(node):
closed_but_for_alias_bindings = False
world = Ps1TypeWorld(closed, frozenset(shadowed), closed_but_for_alias_bindings)
return Ps1WorldMeasurement(
world, tuple(openers), tuple(shadow_sites), root, tree_version(root))
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-run verdict (`closed_for_the_whole_run`) and the set of command names the script redefines
(`command_shadowed`) — so the two cannot drift apart, and the one question asked of the pair
(`may_trust_command_name`). Held in a
`refinery.lib.scripts.ps1.analysis.cache.Ps1ModelCache` slot and passed to the effect layer.
A world nothing was measured over is spelled `Ps1TypeWorld(False, frozenset())` — open, trusting
no name — rather than by an absent object, so that "we did not look" and "we looked and it is
open" cannot become two verdicts a caller distinguishes.
"""
def __init__(
self,
closed: bool,
shadowed: frozenset[str],
closed_but_for_alias_bindings: bool | None = None,
):
"""
A verdict left unstated for `closed_but_for_alias_bindings` takes the value of `closed`,
which is the answer for a world that has no opener at all and the conservative one for a
world that has an opener this was not told the kind of.
"""
self._closed = closed
self._closed_but_for_alias_bindings = (
closed if closed_but_for_alias_bindings is None else closed_but_for_alias_bindings)
self._shadowed = shadowed
@property
def closed_but_for_alias_bindings(self) -> bool:
"""
Whether the only thing keeping this world open is that the script binds aliases — so that a
pass which deleted every `Set-Alias` would leave it closed.
A pass cannot work this out from `closed_for_the_whole_run` and its own list of what it is
about to remove, because a verdict of *open* names no reason: it would have to re-walk the
tree for every other way the world opens, which is the whole of this model restated in a
transform.
Asking here instead is one walk, and the two answers cannot disagree about what an opener is.
Only `Set-Alias` is set aside, not every command that redefines identity. `New-Alias` throws
on a name that is already bound, `Import-Alias` reads a file this analysis cannot see, and a
provider path such as `Set-Item alias:x` is not a binding this model reads at all — none of
them is something a caller is in a position to delete, so a script containing one is one
whose world stays open however many `Set-Alias` statements go.
"""
return self._closed_but_for_alias_bindings
@property
def closed_for_the_whole_run(self) -> bool:
"""
Whether the type world is closed *anywhere* the script runs: no node opens it, so a
present-member grant holds at every position. This is the whole-script verdict, position
free by construction — a question about the run entire, not about one read within it.
The flow-sensitive successor to this — whether the world is closed at one particular read,
which depends on where that read sits relative to the leaks that reach it — is not a fact
about this value object. It needs the control-flow graph, so it lives in
`refinery.lib.scripts.ps1.analysis.worldflow.Ps1WorldReach`, layered on this the way
`refinery.lib.scripts.ps1.analysis.commands.Ps1CommandModel` is layered on `shadowed_names`.
A verdict of closed here means that model grants at every position; only when this is open
does the position start to matter.
"""
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 `closed_for_the_whole_run`'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
def may_trust_command_name(self, name: str) -> bool:
"""
Whether the collected metadata still describes what the command `name` runs, so a site may
act on the name — for typing or for purity. Two things stop it describing it, and both are
this model's to answer: the script redefines the name where the walk can classify the
redefinition, or the world is open anywhere the run reaches, in which case a dot-sourced
file, an imported module, an `iex`, an item cmdlet writing the `function:` provider or an
opaque dispatch can bind *any* name to code this tree does not contain. Reading the shadow
set alone would trust every name in exactly the scripts able to rebind them, and that set
holds only the two spellings the classifier sees.
This is the whole-run verdict, position free by construction. Whether the name may be
trusted at *one particular node* — which depends on where that node sits relative to the
openers and to the redefinitions of this very name — is
`refinery.lib.scripts.ps1.analysis.worldflow.Ps1WorldReach.may_trust_command_name_at`,
layered on this verdict the way `closed_at` is layered on `closed_for_the_whole_run`. A
verdict of trusted here means that model grants at every position; only when this refuses
does the position start to matter. That short-circuit is the whole of what reads this in
the package today — every pass asks the positional query — and it is why the verdict stays
here rather than moving into the layer above it: the wider question is answered by
narrowing this one, so this one has to exist first.
Named for the question a caller actually has rather than for the shadow set, because the
answer is wider than the set: a reader who takes this for "is it redefined?" and narrows it
back to that would reopen a hole that deletes code, and no `_grant` sits in the path to
catch it.
"""
return self._closed and not self.command_shadowed(name)
@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:
"""
The whole-run verdict alone, for a caller that wants the leaf value without a cache and not the
opener positions `refinery.lib.scripts.ps1.analysis.worldflow.build_world_reach` floods from —
the tests that assert the verdict. A projection of `measure_world`, so a verdict read this way
and one the flow gate floods from are never two different answers. The cache reaches that same
projection through `refinery.lib.scripts.ps1.analysis.cache.Ps1ModelCache.world_measurement`,
not through here.
"""
return measure_world(root).world
def _opens_world_only_by_binding_an_alias(node) -> bool:
"""
Whether the sole reason `node` opens the world is that it is a `Set-Alias` — see
`Ps1TypeWorld.closed_but_for_alias_bindings`. Every other reason the same node might open it is
excluded here rather than assumed away, because a `Set-Alias` that also dispatches opaquely or
addresses a provider path is still each of those things.
"""
if not isinstance(node, Ps1CommandInvocation):
return False
if is_opaque_dispatch(node) or runs_another_script_file(node):
return False
if touches_identity_provider(node):
return False
return resolve_command_name(node) in _ALIAS_BINDING_COMMANDS
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; an item cmdlet writing a
`function:`/`alias:` provider path names the item that path addresses; a `workflow`/`configuration`
statement names the command its first argument spells.
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 isinstance(node, Ps1CommandInvocation):
keyword = _command_definition_keyword_redefinition(node)
return (keyword,) if keyword is not None else _provider_path_redefinitions(node)
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 _provider_path_redefinitions(cmd: Ps1CommandInvocation) -> tuple[_IdentityRedefinition, ...]:
"""
The command names an item cmdlet takes over by writing a `function:`/`alias:` provider path —
`Set-Item function:ForEach-Object { ... }` rebinds `ForEach-Object` exactly as
`function ForEach-Object { ... }` does, but through a path this reads out of the argument rather
than off the statement keyword. Only the item cmdlets are read this way: `Get-ChildItem alias:`
and `Test-Path 'function:x'` name the same provider and bind nothing, so a command outside
`_ITEM_CMDLETS` yields no name however it spells a provider.
The bound body is `OPAQUE_VALUE`: what a path write installs is not a scriptblock standing where
the name is declared, and the world opens on the command through `_command_opens_world` as it
always has. A computed path (`Set-Item $p { ... }`) spells no name here; it leaves the world
closed, since `touches_identity_provider` cannot read `$p` either, and catching that shape is
the `may_touch_identity_provider` residual the command model carries, not this walk.
A name an item cmdlet binds outside a provider-path string is not read: a `-Name`/`-NewName`
operand, a positional new-name (`Rename-Item function:t X`), an array element
(`Set-Item a,b { ... }`), or a path behind a `/` this does not canonicalize. A pipeline over
such a rebound iterator still folds. Measured on 5.1 and pinned in `test_aliases.py`; closing
it is the extraction-completeness increment.
"""
if normalize_command_name(resolve_command_name(cmd) or '') not in _ITEM_CMDLETS:
return ()
redefinitions: list[_IdentityRedefinition] = []
for argument in cmd.arguments:
value = argument.value if isinstance(argument, Ps1CommandArgument) else argument
text = string_value(value)
if text is None:
continue
item = _identity_provider_item_name(text)
if item is not None:
redefinitions.append(_IdentityRedefinition(
normalize_command_name(item), _IdentityBody.OPAQUE_VALUE))
return tuple(redefinitions)
def _identity_provider_item_name(path: str) -> str | None:
"""
The command name a `function:`/`alias:` provider path addresses — `ForEach-Object` for
`function:ForEach-Object`, `x` for `Alias:\\x`, `Get-Date` for
`Microsoft.PowerShell.Core\\Function::Get-Date` — or `None` for a path naming no identity
provider or no item under one. The drive is read in both spellings `touches_identity_provider`
reads it, and the item is what follows the provider's colon, cleared of the `:` a qualified path
doubles and the `\\` a drive-rooted one leads with.
"""
for spelling in (path, path.rpartition('\\')[2]):
drive, separator, item = spelling.partition(':')
if separator and drive.lower() in _IDENTITY_PROVIDERS:
item = item.lstrip('\\:')
if item:
return item
return None
def command_definition_keyword_binding(
cmd: Ps1CommandInvocation,
) -> tuple[str, Ps1ScriptBlock] | None:
"""
The command a `workflow`/`configuration` statement defines and the scriptblock body it binds to
that name, or `None` when `cmd` is not one. The defined name is the statement's first positional
argument and the body is a scriptblock standing among the arguments; a form carrying no
scriptblock defines nothing and is left alone, and one whose name is not a static literal names no
command this can read. It is the one recognizer of these keywords, shared with
`refinery.lib.scripts.ps1.analysis.callgraph` so that the shadow set and the call graph cannot
disagree on what a `workflow` defines.
"""
if resolve_command_name(cmd) not in _COMMAND_DEFINITION_KEYWORDS:
return None
positionals = [
argument.value
for argument in cmd.arguments
if isinstance(argument, Ps1CommandArgument)
and argument.kind is Ps1CommandArgumentKind.POSITIONAL
]
body = next((value for value in positionals if isinstance(value, Ps1ScriptBlock)), None)
if body is None:
return None
name = string_value(positionals[0]) if positionals else None
if name is None:
return None
return normalize_command_name(name), body
def _command_definition_keyword_redefinition(
cmd: Ps1CommandInvocation,
) -> _IdentityRedefinition | None:
"""
The identity redefinition a `workflow`/`configuration` statement performs, or `None` when `cmd`
is not one. The body is a `VISIBLE_BLOCK` for the same reason `function NAME { ... }` is — it
stands in the tree, so a mutation inside it is caught by presence and the redefinition leaves the
world closed.
"""
binding = command_definition_keyword_binding(cmd)
if binding is None:
return None
name, _ = binding
return _IdentityRedefinition(name, _IdentityBody.VISIBLE_BLOCK)
def _opens_world(node, redefined: tuple[_IdentityRedefinition, ...]) -> WorldRole:
"""
The role by which `node` leaves the type system or the command table in a state the collected
metadata no longer describes, or `WorldRole.NONE` for a node that leaves both as it found them.
`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.
The role rather than a boolean, because `measure_world` has a caller that acts differently on
each: `refinery.lib.scripts.ps1.options.Ps1DeobfuscationOptions.trust_eval` excuses an opener
that runs code nobody can read and never one that mutates the world in plain sight.
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 WorldRole.MUTATION
if isinstance(node, Ps1CommandInvocation):
return _command_opens_world(node)
if isinstance(node, Ps1InvokeMember):
if (
is_scriptblock_create(node)
or is_scriptblock_invoke(node)
or is_execution_context_invoke(node)
):
return WorldRole.LEAK
if _is_type_accelerator_mutation(node) or _is_psobject_member_mutation(node):
return WorldRole.MUTATION
return WorldRole.NONE
if isinstance(node, Ps1AssignmentExpression):
if any(record.body is not _IdentityBody.VISIBLE_BLOCK for record in redefined):
return WorldRole.IDENTITY
return WorldRole.NONE
return WorldRole.NONE
def _command_opens_world(cmd: Ps1CommandInvocation) -> WorldRole:
"""
The deny-list is read before the opaque-file test, so that a command the tables name keeps the
role they give it however it is invoked. Both spellings open the world either way, but the role
is what decides whether `_runs_unreadable_code` may excuse the node, and a dot-invoked
`Add-Type` mutates the type system in plain sight rather than running a file nobody can read.
"""
if is_opaque_dispatch(cmd):
return WorldRole.UNKNOWN
name = resolve_command_name(cmd)
if name is not None and (role := command_role(name)) is not WorldRole.NONE:
return role
if runs_another_script_file(cmd):
return WorldRole.LEAK
if name is None:
return WorldRole.NONE
return WorldRole.IDENTITY if touches_identity_provider(cmd) else WorldRole.NONE
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 both as written and after the module qualifier `resolve_command_name`
strips from a command name, because a path addresses the identical namespace through either
and the separator between them is the same character:
`Microsoft.PowerShell.Core\\Function::Get-Date` is what `function:Get-Date` is short for and is
only found after the strip, while `Alias:\\x` — which is what `Get-ChildItem Alias:` completes
to, and what a provider path most often looks like — is only found before it. Reading either
spelling alone leaves the other looking like an ordinary file path, which is the direction a
deny-list must never fail in.
**The colon is what makes it a path.** A drive qualifier is a name followed by `:`, so a bare
`alias` is an ordinary word and `Write-Output 'alias'` addresses nothing. Reading the part before
the separator without checking that there was one answers `True` for every argument that happens
to spell a provider's name, which opened the world over a string.
"""
for arg in cmd.arguments:
value = arg.value if isinstance(arg, Ps1CommandArgument) else arg
text = string_value(value)
if text is None:
continue
for spelling in (text, text.rpartition('\\')[2]):
drive, separator, _ = spelling.partition(':')
if separator and drive.lower() in _IDENTITY_PROVIDERS:
return True
return False
def runs_code_in_the_calling_scope(cmd: Ps1CommandInvocation, resolved: str | None) -> bool:
"""
Whether `cmd` runs code this analysis cannot read *in the scope it is written in*, so a command
table that code writes is this script's own. `resolved` is the command name after the caller has
followed the script's own aliases, since `Set-Alias e iex` puts one behind any spelling.
`WorldRole.LEAK` is not this question, and the difference is the scope rather than the leak.
`Invoke-Expression` and dot-sourcing run their code where they stand — measured on 5.1,
`Invoke-Expression 'Set-Alias zzq Get-Date'` leaves `zzq` bound to `Get-Date` afterwards — so
what they run may rebind any name. `Start-Job` and `Start-ThreadJob` run in another runspace,
`Invoke-Command` opens a child scope, and a script file invoked with `&` or as a bareword gets a
child scope too: a binding any of them performs is gone before the next statement here.
The dot-source test is the operator rather than the file, which is the opposite of
`runs_another_script_file`'s reason for existing: there the file is opaque however it is called,
here the operator is what decides whose tables the file writes. An inline `.{ ... }` is excluded
with the same test that excludes it there — its body stands in the tree, so whatever it binds is
read like any other statement.
"""
if isinstance(cmd.name, Ps1StringLiteral) and cmd.invocation_operator == '.':
return True
if resolved is None:
return False
return normalize_command_name(resolved.rpartition('\\')[2]) in _CALLER_SCOPE_LEAKS
def may_touch_identity_provider(cmd: Ps1CommandInvocation) -> bool:
"""
Whether `cmd` may address the `alias:` or `function:` provider — `touches_identity_provider`
widened to the item cmdlets carrying a path this cannot read.
`Set-Item $p Write-Host` addresses whatever `$p` spells, and a script that computes the drive
qualifier (`('Ali' + 'as:') + $n`) addresses it through a spelling no literal reading finds. The
item cmdlets are named because the provider path is what they take: widening every command this
way would say that any call with a variable argument may rebind a name.
Held apart from `touches_identity_provider` rather than replacing it, because the two answer
different questions and pay differently for being wrong. Opening the *type world* on this would
cost every member-read grant in a script that merely deletes a file by variable, and the
mutation deny-list this belongs to is documented as carrying a computed-path residual. Refusing
an *alias resolution* on it costs only the names that script also binds, which is the claim
`refinery.lib.scripts.ps1.analysis.commands` is making complete.
"""
if touches_identity_provider(cmd):
return True
if normalize_command_name(resolve_command_name(cmd) or '') not in _ITEM_CMDLETS:
return False
return any(
string_value(arg.value if isinstance(arg, Ps1CommandArgument) else arg) is None
for arg in cmd.arguments
)
def assigns_an_alias_name(node) -> bool:
"""
Whether `node` binds a command name by writing the `alias:` variable namespace — `${alias:x} =
'Write-Host'`, which rebinds `x` without any command being invoked at all.
This is the one binding form that is not an invocation, so a caller asking what a script does to
the alias table through `refinery.lib.scripts.ps1.analysis.commands.Ps1CommandModel.world_role`
alone never sees it. It is stated here beside the rest of the identity rules rather than in the
command model, which reads the tables and does not own them.
"""
if not isinstance(node, Ps1AssignmentExpression):
return False
return any(
variable.scope is Ps1ScopeModifier.ALIAS
for variable in assignment_target_variables(node.target)
)
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 command_role(name)-
What the command
namedoes to the world, orWorldRole.NONEwhen no deny-list holds it. NeverWorldRole.UNKNOWN: a name is by construction something this can look up, and not knowing what an invocation runs is a fact about the invocation rather than about any name.The lookup key is the deny-list reading
resolve_command_name()describes, taken here rather than owed by the caller: the module and scope qualifiers dropped and one hop through the built-in alias table, so that neitherMicrosoft.PowerShell.Utility\iexnorglobal:iexnor plainiexcan dodge a table the bareInvoke-Expressionmatches. Eight of the entries below are reachable only through that hop, so a caller handing over a name it had not resolved would otherwise read a deny-list answer ofNONE— the one direction a deny-list must never fail in. Taking the key here is what makes that impossible to get wrong at a call site. It is idempotent: no built-in alias names what another one resolves to.This is the one place the three tables are read.
refinery.lib.scripts.ps1.analysis.commandsasks the same question of a name it reached by following the script's own aliases — which this module cannot follow, since the command model is built over the shadow set this one produces — and a second reading of the tables there would be a second deny-list to keep in step.Expand source code Browse git
def command_role(name: str) -> WorldRole: """ What the command `name` does to the world, or `WorldRole.NONE` when no deny-list holds it. Never `WorldRole.UNKNOWN`: a name is by construction something this can look up, and not knowing what an invocation runs is a fact about the invocation rather than about any name. The lookup key is the *deny-list* reading `refinery.lib.scripts.ps1.ast.resolve_command_name` describes, taken here rather than owed by the caller: the module and scope qualifiers dropped and one hop through the built-in alias table, so that neither `Microsoft.PowerShell.Utility\\iex` nor `global:iex` nor plain `iex` can dodge a table the bare `Invoke-Expression` matches. Eight of the entries below are reachable only through that hop, so a caller handing over a name it had not resolved would otherwise read a deny-list answer of `NONE` — the one direction a deny-list must never fail in. Taking the key here is what makes that impossible to get wrong at a call site. It is idempotent: no built-in alias names what another one resolves to. This is the one place the three tables are read. `refinery.lib.scripts.ps1.analysis.commands` asks the same question of a name it reached by following the script's own aliases — which this module cannot follow, since the command model is built over the shadow set this one produces — and a second reading of the tables there would be a second deny-list to keep in step. """ key = normalize_command_name(name.rpartition('\\')[2]) key = KNOWN_ALIAS.get(key, key).lower() if key in _LEAK_CMDLETS: return WorldRole.LEAK if key in _MUTATION_CMDLETS: return WorldRole.MUTATION if key in _ALIAS_CMDLETS: return WorldRole.IDENTITY return WorldRole.NONE def measure_world(root, options=None)-
Walk the whole tree once, computing the command-table verdict and every position together: whether any node opens the world (a single opener anywhere is global and retroactive, so it closes off the verdict), the set of command names the script redefines and the site of each redefinition, and every opener node in walk order. The walk cannot short-circuit on the first opener because the shadow set needs every redefinition, wherever it sits, and the floods need every position.
An opener is yielded as the node itself, not its role. The class or enum definition among them opens the world at no position — the engine compiles it before the first statement runs — and is recognized by
build_world_reach, which must fail closed on it.Under
Ps1DeobfuscationOptions.trust_evalan opener_runs_unreadable_codeanswers for is not recorded at all, so it neither opens the whole-run verdict nor floods a position. The shadow set is untouched by the option: every site in it is a redefinition this walk did read, so nothing about it rests on what unreadable code does.Expand source code Browse git
def measure_world(root: Ps1Script, options: object | None = None) -> Ps1WorldMeasurement: """ Walk the whole tree once, computing the command-table verdict and every position together: whether any node opens the world (a single opener anywhere is global and retroactive, so it closes off the verdict), the set of command names the script redefines and the site of each redefinition, and every opener node in walk order. The walk cannot short-circuit on the first opener because the shadow set needs every redefinition, wherever it sits, and the floods need every position. An opener is yielded as the node itself, not its role. The class or enum definition among them opens the world at no position — the engine compiles it before the first statement runs — and is recognized by `build_world_reach`, which must fail closed on it. Under `refinery.lib.scripts.ps1.options.Ps1DeobfuscationOptions.trust_eval` an opener `_runs_unreadable_code` answers for is not recorded at all, so it neither opens the whole-run verdict nor floods a position. The shadow set is untouched by the option: every site in it is a redefinition this walk *did* read, so nothing about it rests on what unreadable code does. """ trusting = eval_is_trusted(options) closed = True closed_but_for_alias_bindings = True shadowed: set[str] = set() openers: list[Node] = [] shadow_sites: list[Ps1ShadowSite] = [] for node in root.walk(): redefined = _identity_redefinitions(node) shadowed.update(record.name for record in redefined) shadow_sites.extend(Ps1ShadowSite(record.name, node) for record in redefined) role = _opens_world(node, redefined) if role is WorldRole.NONE or (trusting and _runs_unreadable_code(node, role)): continue openers.append(node) closed = False if not _opens_world_only_by_binding_an_alias(node): closed_but_for_alias_bindings = False world = Ps1TypeWorld(closed, frozenset(shadowed), closed_but_for_alias_bindings) return Ps1WorldMeasurement( world, tuple(openers), tuple(shadow_sites), root, tree_version(root)) def build_closed_world(root)-
The whole-run verdict alone, for a caller that wants the leaf value without a cache and not the opener positions
build_world_reach()floods from — the tests that assert the verdict. A projection ofmeasure_world(), so a verdict read this way and one the flow gate floods from are never two different answers. The cache reaches that same projection throughPs1ModelCache.world_measurement, not through here.Expand source code Browse git
def build_closed_world(root: Ps1Script) -> Ps1TypeWorld: """ The whole-run verdict alone, for a caller that wants the leaf value without a cache and not the opener positions `refinery.lib.scripts.ps1.analysis.worldflow.build_world_reach` floods from — the tests that assert the verdict. A projection of `measure_world`, so a verdict read this way and one the flow gate floods from are never two different answers. The cache reaches that same projection through `refinery.lib.scripts.ps1.analysis.cache.Ps1ModelCache.world_measurement`, not through here. """ return measure_world(root).world def command_definition_keyword_binding(cmd)-
The command a
workflow/configurationstatement defines and the scriptblock body it binds to that name, orNonewhencmdis not one. The defined name is the statement's first positional argument and the body is a scriptblock standing among the arguments; a form carrying no scriptblock defines nothing and is left alone, and one whose name is not a static literal names no command this can read. It is the one recognizer of these keywords, shared withrefinery.lib.scripts.ps1.analysis.callgraphso that the shadow set and the call graph cannot disagree on what aworkflowdefines.Expand source code Browse git
def command_definition_keyword_binding( cmd: Ps1CommandInvocation, ) -> tuple[str, Ps1ScriptBlock] | None: """ The command a `workflow`/`configuration` statement defines and the scriptblock body it binds to that name, or `None` when `cmd` is not one. The defined name is the statement's first positional argument and the body is a scriptblock standing among the arguments; a form carrying no scriptblock defines nothing and is left alone, and one whose name is not a static literal names no command this can read. It is the one recognizer of these keywords, shared with `refinery.lib.scripts.ps1.analysis.callgraph` so that the shadow set and the call graph cannot disagree on what a `workflow` defines. """ if resolve_command_name(cmd) not in _COMMAND_DEFINITION_KEYWORDS: return None positionals = [ argument.value for argument in cmd.arguments if isinstance(argument, Ps1CommandArgument) and argument.kind is Ps1CommandArgumentKind.POSITIONAL ] body = next((value for value in positionals if isinstance(value, Ps1ScriptBlock)), None) if body is None: return None name = string_value(positionals[0]) if positionals else None if name is None: return None return normalize_command_name(name), body def runs_another_script_file(cmd)-
Whether
cmdruns a.ps1file 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 barewordstage2.ps1runs 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. $xis already opaque dispatch.Expand source code Browse git
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)-
Whether any argument is a literal path into the
alias:orfunction:provider, the vector that escapes a name-keyed deny-list becauseSet-Item alias:x Update-TypeDatamutates identity withoutSet-Itembeing 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 both as written and after the module qualifier
resolve_command_namestrips from a command name, because a path addresses the identical namespace through either and the separator between them is the same character:Microsoft.PowerShell.Core\Function::Get-Dateis whatfunction:Get-Dateis short for and is only found after the strip, whileAlias:\x— which is whatGet-ChildItem Alias:completes to, and what a provider path most often looks like — is only found before it. Reading either spelling alone leaves the other looking like an ordinary file path, which is the direction a deny-list must never fail in.The colon is what makes it a path. A drive qualifier is a name followed by
:, so a barealiasis an ordinary word andWrite-Output 'alias'addresses nothing. Reading the part before the separator without checking that there was one answersTruefor every argument that happens to spell a provider's name, which opened the world over a string.Expand source code Browse git
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 both as written and after the module qualifier `resolve_command_name` strips from a command name, because a path addresses the identical namespace through either and the separator between them is the same character: `Microsoft.PowerShell.Core\\Function::Get-Date` is what `function:Get-Date` is short for and is only found after the strip, while `Alias:\\x` — which is what `Get-ChildItem Alias:` completes to, and what a provider path most often looks like — is only found before it. Reading either spelling alone leaves the other looking like an ordinary file path, which is the direction a deny-list must never fail in. **The colon is what makes it a path.** A drive qualifier is a name followed by `:`, so a bare `alias` is an ordinary word and `Write-Output 'alias'` addresses nothing. Reading the part before the separator without checking that there was one answers `True` for every argument that happens to spell a provider's name, which opened the world over a string. """ for arg in cmd.arguments: value = arg.value if isinstance(arg, Ps1CommandArgument) else arg text = string_value(value) if text is None: continue for spelling in (text, text.rpartition('\\')[2]): drive, separator, _ = spelling.partition(':') if separator and drive.lower() in _IDENTITY_PROVIDERS: return True return False def runs_code_in_the_calling_scope(cmd, resolved)-
Whether
cmdruns code this analysis cannot read in the scope it is written in, so a command table that code writes is this script's own.resolvedis the command name after the caller has followed the script's own aliases, sinceSet-Alias e iexputs one behind any spelling.WorldRole.LEAKis not this question, and the difference is the scope rather than the leak.Invoke-Expressionand dot-sourcing run their code where they stand — measured on 5.1,Invoke-Expression 'Set-Alias zzq Get-Date'leaveszzqbound toGet-Dateafterwards — so what they run may rebind any name.Start-JobandStart-ThreadJobrun in another runspace,Invoke-Commandopens a child scope, and a script file invoked with&or as a bareword gets a child scope too: a binding any of them performs is gone before the next statement here.The dot-source test is the operator rather than the file, which is the opposite of
runs_another_script_file()'s reason for existing: there the file is opaque however it is called, here the operator is what decides whose tables the file writes. An inline.{ ... }is excluded with the same test that excludes it there — its body stands in the tree, so whatever it binds is read like any other statement.Expand source code Browse git
def runs_code_in_the_calling_scope(cmd: Ps1CommandInvocation, resolved: str | None) -> bool: """ Whether `cmd` runs code this analysis cannot read *in the scope it is written in*, so a command table that code writes is this script's own. `resolved` is the command name after the caller has followed the script's own aliases, since `Set-Alias e iex` puts one behind any spelling. `WorldRole.LEAK` is not this question, and the difference is the scope rather than the leak. `Invoke-Expression` and dot-sourcing run their code where they stand — measured on 5.1, `Invoke-Expression 'Set-Alias zzq Get-Date'` leaves `zzq` bound to `Get-Date` afterwards — so what they run may rebind any name. `Start-Job` and `Start-ThreadJob` run in another runspace, `Invoke-Command` opens a child scope, and a script file invoked with `&` or as a bareword gets a child scope too: a binding any of them performs is gone before the next statement here. The dot-source test is the operator rather than the file, which is the opposite of `runs_another_script_file`'s reason for existing: there the file is opaque however it is called, here the operator is what decides whose tables the file writes. An inline `.{ ... }` is excluded with the same test that excludes it there — its body stands in the tree, so whatever it binds is read like any other statement. """ if isinstance(cmd.name, Ps1StringLiteral) and cmd.invocation_operator == '.': return True if resolved is None: return False return normalize_command_name(resolved.rpartition('\\')[2]) in _CALLER_SCOPE_LEAKS def may_touch_identity_provider(cmd)-
Whether
cmdmay address thealias:orfunction:provider —touches_identity_provider()widened to the item cmdlets carrying a path this cannot read.Set-Item $p Write-Hostaddresses whatever$pspells, and a script that computes the drive qualifier (('Ali' + 'as:') + $n) addresses it through a spelling no literal reading finds. The item cmdlets are named because the provider path is what they take: widening every command this way would say that any call with a variable argument may rebind a name.Held apart from
touches_identity_provider()rather than replacing it, because the two answer different questions and pay differently for being wrong. Opening the type world on this would cost every member-read grant in a script that merely deletes a file by variable, and the mutation deny-list this belongs to is documented as carrying a computed-path residual. Refusing an alias resolution on it costs only the names that script also binds, which is the claimrefinery.lib.scripts.ps1.analysis.commandsis making complete.Expand source code Browse git
def may_touch_identity_provider(cmd: Ps1CommandInvocation) -> bool: """ Whether `cmd` may address the `alias:` or `function:` provider — `touches_identity_provider` widened to the item cmdlets carrying a path this cannot read. `Set-Item $p Write-Host` addresses whatever `$p` spells, and a script that computes the drive qualifier (`('Ali' + 'as:') + $n`) addresses it through a spelling no literal reading finds. The item cmdlets are named because the provider path is what they take: widening every command this way would say that any call with a variable argument may rebind a name. Held apart from `touches_identity_provider` rather than replacing it, because the two answer different questions and pay differently for being wrong. Opening the *type world* on this would cost every member-read grant in a script that merely deletes a file by variable, and the mutation deny-list this belongs to is documented as carrying a computed-path residual. Refusing an *alias resolution* on it costs only the names that script also binds, which is the claim `refinery.lib.scripts.ps1.analysis.commands` is making complete. """ if touches_identity_provider(cmd): return True if normalize_command_name(resolve_command_name(cmd) or '') not in _ITEM_CMDLETS: return False return any( string_value(arg.value if isinstance(arg, Ps1CommandArgument) else arg) is None for arg in cmd.arguments ) def assigns_an_alias_name(node)-
Whether
nodebinds a command name by writing thealias:variable namespace —${alias:x} = 'Write-Host'<code>, which rebinds </code>xwithout any command being invoked at all.This is the one binding form that is not an invocation, so a caller asking what a script does to the alias table through
Ps1CommandModel.world_role()alone never sees it. It is stated here beside the rest of the identity rules rather than in the command model, which reads the tables and does not own them.Expand source code Browse git
def assigns_an_alias_name(node) -> bool: """ Whether `node` binds a command name by writing the `alias:` variable namespace — `${alias:x} = 'Write-Host'`, which rebinds `x` without any command being invoked at all. This is the one binding form that is not an invocation, so a caller asking what a script does to the alias table through `refinery.lib.scripts.ps1.analysis.commands.Ps1CommandModel.world_role` alone never sees it. It is stated here beside the rest of the identity rules rather than in the command model, which reads the tables and does not own them. """ if not isinstance(node, Ps1AssignmentExpression): return False return any( variable.scope is Ps1ScopeModifier.ALIAS for variable in assignment_target_variables(node.target) )
Classes
class WorldRole (*args, **kwds)-
What a command does to the type world and the command table. The three opening roles are kept apart rather than collapsed into one boolean because a caller acts differently on each: a pass deleting an alias definition has to know that the invocation blocking it is another alias definition, which it may yet be able to delete too, and not a leak, which it never can.
NONEandUNKNOWNare the two ways of not naming a role, and they are as far apart as they are inCommandKind:NONEsays the command leaves the world as it found it,UNKNOWNsays nothing static bounds what it runs.Expand source code Browse git
class WorldRole(enum.Enum): """ What a command does to the type world and the command table. The three opening roles are kept apart rather than collapsed into one boolean because a caller acts differently on each: a pass deleting an alias definition has to know that the invocation blocking it is another alias definition, which it may yet be able to delete too, and not a leak, which it never can. `NONE` and `UNKNOWN` are the two ways of not naming a role, and they are as far apart as they are in `refinery.lib.scripts.ps1.analysis.commands.CommandKind`: `NONE` says the command leaves the world as it found it, `UNKNOWN` says nothing static bounds what it runs. """ #: Runs code supplied as data, so what it does to the world is whatever that data says. LEAK = enum.auto() #: Mutates the .NET type system, after which reflection no longer describes a type's members. MUTATION = enum.auto() #: Redefines command identity, after which a bareword no longer names what the metadata says. IDENTITY = enum.auto() #: Leaves both intact. NONE = enum.auto() #: Dispatches to whatever an expression yields, so it may be any of the above. UNKNOWN = enum.auto()Ancestors
- enum.Enum
Class variables
var LEAK-
Runs code supplied as data, so what it does to the world is whatever that data says.
var MUTATION-
Mutates the .NET type system, after which reflection no longer describes a type's members.
var IDENTITY-
Redefines command identity, after which a bareword no longer names what the metadata says.
var NONE-
Leaves both intact.
var UNKNOWN-
Dispatches to whatever an expression yields, so it may be any of the above.
class Ps1ShadowSite (name, site)-
One statement that takes a command name over: the
nameit rebinds, keyed throughnormalize_command_name()like the shadow set, and thesitenode performing the redefinition.build_world_reach()floods forward from these the way it floods from the openers, so a call no redefinition of its name can precede still means the built-in the metadata describes.Expand source code Browse git
class Ps1ShadowSite(NamedTuple): """ One statement that takes a command name over: the `name` it rebinds, keyed through `refinery.lib.scripts.ps1.ast.normalize_command_name` like the shadow set, and the `site` node performing the redefinition. `refinery.lib.scripts.ps1.analysis.worldflow.build_world_reach` floods forward from these the way it floods from the openers, so a call no redefinition of its name can precede still means the built-in the metadata describes. """ name: str site: NodeAncestors
- builtins.tuple
Instance variables
var name-
Alias for field number 0
Expand source code Browse git
class Ps1ShadowSite(NamedTuple): """ One statement that takes a command name over: the `name` it rebinds, keyed through `refinery.lib.scripts.ps1.ast.normalize_command_name` like the shadow set, and the `site` node performing the redefinition. `refinery.lib.scripts.ps1.analysis.worldflow.build_world_reach` floods forward from these the way it floods from the openers, so a call no redefinition of its name can precede still means the built-in the metadata describes. """ name: str site: Node var site-
Alias for field number 1
Expand source code Browse git
class Ps1ShadowSite(NamedTuple): """ One statement that takes a command name over: the `name` it rebinds, keyed through `refinery.lib.scripts.ps1.ast.normalize_command_name` like the shadow set, and the `site` node performing the redefinition. `refinery.lib.scripts.ps1.analysis.worldflow.build_world_reach` floods forward from these the way it floods from the openers, so a call no redefinition of its name can precede still means the built-in the metadata describes. """ name: str site: Node
class Ps1WorldMeasurement (world, openers, shadow_sites, root, build_version)-
One walk's reading of root: the whole-run
worldverdict, theopenersthat produced it and theshadow_siteswhere a command name is taken over, each in walk order, and therootandbuild_versionthey were measured over. All come from the same walk, soworld.closed_for_the_whole_runandnot openersare the same fact, and the names ofshadow_sitesare exactlyworld.shadowed_names— a node one counts and the other misses cannot exist. Held in aPs1ModelCacheslot;build_world_reachfloods fromopenersandshadow_sitesand stampsbuild_versiononto the reach model so a held one notices the tree change.The opener and site nodes are live tree references, which is why this is a cache record rather than a field of the leaf
Ps1TypeWorld: a slot is dropped whole on the next version bump, so its node references are never read against a tree they no longer belong to.Expand source code Browse git
class Ps1WorldMeasurement(NamedTuple): """ One walk's reading of *root*: the whole-run `world` verdict, the `openers` that produced it and the `shadow_sites` where a command name is taken over, each in walk order, and the `root` and `build_version` they were measured over. All come from the same walk, so `world.closed_for_the_whole_run` and `not openers` are the same fact, and the names of `shadow_sites` are exactly `world.shadowed_names` — a node one counts and the other misses cannot exist. Held in a `refinery.lib.scripts.ps1.analysis.cache.Ps1ModelCache` slot; `build_world_reach` floods from `openers` and `shadow_sites` and stamps `build_version` onto the reach model so a held one notices the tree change. The opener and site nodes are live tree references, which is why this is a cache record rather than a field of the leaf `Ps1TypeWorld`: a slot is dropped whole on the next version bump, so its node references are never read against a tree they no longer belong to. """ world: Ps1TypeWorld openers: tuple[Node, ...] shadow_sites: tuple[Ps1ShadowSite, ...] root: Ps1Script build_version: intAncestors
- builtins.tuple
Instance variables
var world-
Alias for field number 0
Expand source code Browse git
class Ps1WorldMeasurement(NamedTuple): """ One walk's reading of *root*: the whole-run `world` verdict, the `openers` that produced it and the `shadow_sites` where a command name is taken over, each in walk order, and the `root` and `build_version` they were measured over. All come from the same walk, so `world.closed_for_the_whole_run` and `not openers` are the same fact, and the names of `shadow_sites` are exactly `world.shadowed_names` — a node one counts and the other misses cannot exist. Held in a `refinery.lib.scripts.ps1.analysis.cache.Ps1ModelCache` slot; `build_world_reach` floods from `openers` and `shadow_sites` and stamps `build_version` onto the reach model so a held one notices the tree change. The opener and site nodes are live tree references, which is why this is a cache record rather than a field of the leaf `Ps1TypeWorld`: a slot is dropped whole on the next version bump, so its node references are never read against a tree they no longer belong to. """ world: Ps1TypeWorld openers: tuple[Node, ...] shadow_sites: tuple[Ps1ShadowSite, ...] root: Ps1Script build_version: int var openers-
Alias for field number 1
Expand source code Browse git
class Ps1WorldMeasurement(NamedTuple): """ One walk's reading of *root*: the whole-run `world` verdict, the `openers` that produced it and the `shadow_sites` where a command name is taken over, each in walk order, and the `root` and `build_version` they were measured over. All come from the same walk, so `world.closed_for_the_whole_run` and `not openers` are the same fact, and the names of `shadow_sites` are exactly `world.shadowed_names` — a node one counts and the other misses cannot exist. Held in a `refinery.lib.scripts.ps1.analysis.cache.Ps1ModelCache` slot; `build_world_reach` floods from `openers` and `shadow_sites` and stamps `build_version` onto the reach model so a held one notices the tree change. The opener and site nodes are live tree references, which is why this is a cache record rather than a field of the leaf `Ps1TypeWorld`: a slot is dropped whole on the next version bump, so its node references are never read against a tree they no longer belong to. """ world: Ps1TypeWorld openers: tuple[Node, ...] shadow_sites: tuple[Ps1ShadowSite, ...] root: Ps1Script build_version: int var shadow_sites-
Alias for field number 2
Expand source code Browse git
class Ps1WorldMeasurement(NamedTuple): """ One walk's reading of *root*: the whole-run `world` verdict, the `openers` that produced it and the `shadow_sites` where a command name is taken over, each in walk order, and the `root` and `build_version` they were measured over. All come from the same walk, so `world.closed_for_the_whole_run` and `not openers` are the same fact, and the names of `shadow_sites` are exactly `world.shadowed_names` — a node one counts and the other misses cannot exist. Held in a `refinery.lib.scripts.ps1.analysis.cache.Ps1ModelCache` slot; `build_world_reach` floods from `openers` and `shadow_sites` and stamps `build_version` onto the reach model so a held one notices the tree change. The opener and site nodes are live tree references, which is why this is a cache record rather than a field of the leaf `Ps1TypeWorld`: a slot is dropped whole on the next version bump, so its node references are never read against a tree they no longer belong to. """ world: Ps1TypeWorld openers: tuple[Node, ...] shadow_sites: tuple[Ps1ShadowSite, ...] root: Ps1Script build_version: int var root-
Alias for field number 3
Expand source code Browse git
class Ps1WorldMeasurement(NamedTuple): """ One walk's reading of *root*: the whole-run `world` verdict, the `openers` that produced it and the `shadow_sites` where a command name is taken over, each in walk order, and the `root` and `build_version` they were measured over. All come from the same walk, so `world.closed_for_the_whole_run` and `not openers` are the same fact, and the names of `shadow_sites` are exactly `world.shadowed_names` — a node one counts and the other misses cannot exist. Held in a `refinery.lib.scripts.ps1.analysis.cache.Ps1ModelCache` slot; `build_world_reach` floods from `openers` and `shadow_sites` and stamps `build_version` onto the reach model so a held one notices the tree change. The opener and site nodes are live tree references, which is why this is a cache record rather than a field of the leaf `Ps1TypeWorld`: a slot is dropped whole on the next version bump, so its node references are never read against a tree they no longer belong to. """ world: Ps1TypeWorld openers: tuple[Node, ...] shadow_sites: tuple[Ps1ShadowSite, ...] root: Ps1Script build_version: int var build_version-
Alias for field number 4
Expand source code Browse git
class Ps1WorldMeasurement(NamedTuple): """ One walk's reading of *root*: the whole-run `world` verdict, the `openers` that produced it and the `shadow_sites` where a command name is taken over, each in walk order, and the `root` and `build_version` they were measured over. All come from the same walk, so `world.closed_for_the_whole_run` and `not openers` are the same fact, and the names of `shadow_sites` are exactly `world.shadowed_names` — a node one counts and the other misses cannot exist. Held in a `refinery.lib.scripts.ps1.analysis.cache.Ps1ModelCache` slot; `build_world_reach` floods from `openers` and `shadow_sites` and stamps `build_version` onto the reach model so a held one notices the tree change. The opener and site nodes are live tree references, which is why this is a cache record rather than a field of the leaf `Ps1TypeWorld`: a slot is dropped whole on the next version bump, so its node references are never read against a tree they no longer belong to. """ world: Ps1TypeWorld openers: tuple[Node, ...] shadow_sites: tuple[Ps1ShadowSite, ...] root: Ps1Script build_version: int
class Ps1TypeWorld (closed, shadowed, closed_but_for_alias_bindings=None)-
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-run verdict (closed_for_the_whole_run) and the set of command names the script redefines (command_shadowed) — so the two cannot drift apart, and the one question asked of the pair (may_trust_command_name). Held in aPs1ModelCacheslot and passed to the effect layer.A world nothing was measured over is spelled
Ps1TypeWorld(False, frozenset())— open, trusting no name — rather than by an absent object, so that "we did not look" and "we looked and it is open" cannot become two verdicts a caller distinguishes.A verdict left unstated for
closed_but_for_alias_bindingstakes the value ofclosed, which is the answer for a world that has no opener at all and the conservative one for a world that has an opener this was not told the kind of.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-run verdict (`closed_for_the_whole_run`) and the set of command names the script redefines (`command_shadowed`) — so the two cannot drift apart, and the one question asked of the pair (`may_trust_command_name`). Held in a `refinery.lib.scripts.ps1.analysis.cache.Ps1ModelCache` slot and passed to the effect layer. A world nothing was measured over is spelled `Ps1TypeWorld(False, frozenset())` — open, trusting no name — rather than by an absent object, so that "we did not look" and "we looked and it is open" cannot become two verdicts a caller distinguishes. """ def __init__( self, closed: bool, shadowed: frozenset[str], closed_but_for_alias_bindings: bool | None = None, ): """ A verdict left unstated for `closed_but_for_alias_bindings` takes the value of `closed`, which is the answer for a world that has no opener at all and the conservative one for a world that has an opener this was not told the kind of. """ self._closed = closed self._closed_but_for_alias_bindings = ( closed if closed_but_for_alias_bindings is None else closed_but_for_alias_bindings) self._shadowed = shadowed @property def closed_but_for_alias_bindings(self) -> bool: """ Whether the only thing keeping this world open is that the script binds aliases — so that a pass which deleted every `Set-Alias` would leave it closed. A pass cannot work this out from `closed_for_the_whole_run` and its own list of what it is about to remove, because a verdict of *open* names no reason: it would have to re-walk the tree for every other way the world opens, which is the whole of this model restated in a transform. Asking here instead is one walk, and the two answers cannot disagree about what an opener is. Only `Set-Alias` is set aside, not every command that redefines identity. `New-Alias` throws on a name that is already bound, `Import-Alias` reads a file this analysis cannot see, and a provider path such as `Set-Item alias:x` is not a binding this model reads at all — none of them is something a caller is in a position to delete, so a script containing one is one whose world stays open however many `Set-Alias` statements go. """ return self._closed_but_for_alias_bindings @property def closed_for_the_whole_run(self) -> bool: """ Whether the type world is closed *anywhere* the script runs: no node opens it, so a present-member grant holds at every position. This is the whole-script verdict, position free by construction — a question about the run entire, not about one read within it. The flow-sensitive successor to this — whether the world is closed at one particular read, which depends on where that read sits relative to the leaks that reach it — is not a fact about this value object. It needs the control-flow graph, so it lives in `refinery.lib.scripts.ps1.analysis.worldflow.Ps1WorldReach`, layered on this the way `refinery.lib.scripts.ps1.analysis.commands.Ps1CommandModel` is layered on `shadowed_names`. A verdict of closed here means that model grants at every position; only when this is open does the position start to matter. """ 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 `closed_for_the_whole_run`'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 def may_trust_command_name(self, name: str) -> bool: """ Whether the collected metadata still describes what the command `name` runs, so a site may act on the name — for typing or for purity. Two things stop it describing it, and both are this model's to answer: the script redefines the name where the walk can classify the redefinition, or the world is open anywhere the run reaches, in which case a dot-sourced file, an imported module, an `iex`, an item cmdlet writing the `function:` provider or an opaque dispatch can bind *any* name to code this tree does not contain. Reading the shadow set alone would trust every name in exactly the scripts able to rebind them, and that set holds only the two spellings the classifier sees. This is the whole-run verdict, position free by construction. Whether the name may be trusted at *one particular node* — which depends on where that node sits relative to the openers and to the redefinitions of this very name — is `refinery.lib.scripts.ps1.analysis.worldflow.Ps1WorldReach.may_trust_command_name_at`, layered on this verdict the way `closed_at` is layered on `closed_for_the_whole_run`. A verdict of trusted here means that model grants at every position; only when this refuses does the position start to matter. That short-circuit is the whole of what reads this in the package today — every pass asks the positional query — and it is why the verdict stays here rather than moving into the layer above it: the wider question is answered by narrowing this one, so this one has to exist first. Named for the question a caller actually has rather than for the shadow set, because the answer is wider than the set: a reader who takes this for "is it redefined?" and narrows it back to that would reopen a hole that deletes code, and no `_grant` sits in the path to catch it. """ return self._closed and not self.command_shadowed(name) @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 closed_but_for_alias_bindings-
Whether the only thing keeping this world open is that the script binds aliases — so that a pass which deleted every
Set-Aliaswould leave it closed.A pass cannot work this out from
closed_for_the_whole_runand its own list of what it is about to remove, because a verdict of open names no reason: it would have to re-walk the tree for every other way the world opens, which is the whole of this model restated in a transform. Asking here instead is one walk, and the two answers cannot disagree about what an opener is.Only
Set-Aliasis set aside, not every command that redefines identity.New-Aliasthrows on a name that is already bound,Import-Aliasreads a file this analysis cannot see, and a provider path such asSet-Item alias:xis not a binding this model reads at all — none of them is something a caller is in a position to delete, so a script containing one is one whose world stays open however manySet-Aliasstatements go.Expand source code Browse git
@property def closed_but_for_alias_bindings(self) -> bool: """ Whether the only thing keeping this world open is that the script binds aliases — so that a pass which deleted every `Set-Alias` would leave it closed. A pass cannot work this out from `closed_for_the_whole_run` and its own list of what it is about to remove, because a verdict of *open* names no reason: it would have to re-walk the tree for every other way the world opens, which is the whole of this model restated in a transform. Asking here instead is one walk, and the two answers cannot disagree about what an opener is. Only `Set-Alias` is set aside, not every command that redefines identity. `New-Alias` throws on a name that is already bound, `Import-Alias` reads a file this analysis cannot see, and a provider path such as `Set-Item alias:x` is not a binding this model reads at all — none of them is something a caller is in a position to delete, so a script containing one is one whose world stays open however many `Set-Alias` statements go. """ return self._closed_but_for_alias_bindings var closed_for_the_whole_run-
Whether the type world is closed anywhere the script runs: no node opens it, so a present-member grant holds at every position. This is the whole-script verdict, position free by construction — a question about the run entire, not about one read within it.
The flow-sensitive successor to this — whether the world is closed at one particular read, which depends on where that read sits relative to the leaks that reach it — is not a fact about this value object. It needs the control-flow graph, so it lives in
Ps1WorldReach, layered on this the wayPs1CommandModelis layered onshadowed_names. A verdict of closed here means that model grants at every position; only when this is open does the position start to matter.Expand source code Browse git
@property def closed_for_the_whole_run(self) -> bool: """ Whether the type world is closed *anywhere* the script runs: no node opens it, so a present-member grant holds at every position. This is the whole-script verdict, position free by construction — a question about the run entire, not about one read within it. The flow-sensitive successor to this — whether the world is closed at one particular read, which depends on where that read sits relative to the leaks that reach it — is not a fact about this value object. It needs the control-flow graph, so it lives in `refinery.lib.scripts.ps1.analysis.worldflow.Ps1WorldReach`, layered on this the way `refinery.lib.scripts.ps1.analysis.commands.Ps1CommandModel` is layered on `shadowed_names`. A verdict of closed here means that model grants at every position; only when this is open does the position start to matter. """ return self._closed 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 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 — mirroringclosed_for_the_whole_run'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 `closed_for_the_whole_run`'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 def may_trust_command_name(self, name)-
Whether the collected metadata still describes what the command
nameruns, so a site may act on the name — for typing or for purity. Two things stop it describing it, and both are this model's to answer: the script redefines the name where the walk can classify the redefinition, or the world is open anywhere the run reaches, in which case a dot-sourced file, an imported module, aniex, an item cmdlet writing thefunction:provider or an opaque dispatch can bind any name to code this tree does not contain. Reading the shadow set alone would trust every name in exactly the scripts able to rebind them, and that set holds only the two spellings the classifier sees.This is the whole-run verdict, position free by construction. Whether the name may be trusted at one particular node — which depends on where that node sits relative to the openers and to the redefinitions of this very name — is
Ps1WorldReach.may_trust_command_name_at(), layered on this verdict the wayclosed_atis layered onclosed_for_the_whole_run. A verdict of trusted here means that model grants at every position; only when this refuses does the position start to matter. That short-circuit is the whole of what reads this in the package today — every pass asks the positional query — and it is why the verdict stays here rather than moving into the layer above it: the wider question is answered by narrowing this one, so this one has to exist first.Named for the question a caller actually has rather than for the shadow set, because the answer is wider than the set: a reader who takes this for "is it redefined?" and narrows it back to that would reopen a hole that deletes code, and no
_grantsits in the path to catch it.Expand source code Browse git
def may_trust_command_name(self, name: str) -> bool: """ Whether the collected metadata still describes what the command `name` runs, so a site may act on the name — for typing or for purity. Two things stop it describing it, and both are this model's to answer: the script redefines the name where the walk can classify the redefinition, or the world is open anywhere the run reaches, in which case a dot-sourced file, an imported module, an `iex`, an item cmdlet writing the `function:` provider or an opaque dispatch can bind *any* name to code this tree does not contain. Reading the shadow set alone would trust every name in exactly the scripts able to rebind them, and that set holds only the two spellings the classifier sees. This is the whole-run verdict, position free by construction. Whether the name may be trusted at *one particular node* — which depends on where that node sits relative to the openers and to the redefinitions of this very name — is `refinery.lib.scripts.ps1.analysis.worldflow.Ps1WorldReach.may_trust_command_name_at`, layered on this verdict the way `closed_at` is layered on `closed_for_the_whole_run`. A verdict of trusted here means that model grants at every position; only when this refuses does the position start to matter. That short-circuit is the whole of what reads this in the package today — every pass asks the positional query — and it is why the verdict stays here rather than moving into the layer above it: the wider question is answered by narrowing this one, so this one has to exist first. Named for the question a caller actually has rather than for the shadow set, because the answer is wider than the set: a reader who takes this for "is it redefined?" and narrows it back to that would reopen a hole that deletes code, and no `_grant` sits in the path to catch it. """ return self._closed and not self.command_shadowed(name)