Skip to content

v0.4.0-preview0016

Pre-release
Pre-release

Choose a tag to compare

@raandree raandree released this 30 Sep 07:05

[v0.4.0-preview0016]

Security

  • Gate every tool class from one policy. Set-ShpToolPolicy gains Url, Mcp
    and Tool rule kinds beside Read, Write and Shell, each matched
    against a resolved form rather than the string the model supplied: a
    normalised address, the alias/tool a call will dispatch under, and an exact
    tool name. A deny beats every matching allow. Name the
    RestrictedUnattended trust profile to enforce all six kinds at once, so an
    unattended run may do only what is listed. An Mcp rule gates tool identity,
    not the arguments inside the call.

  • Keep a Copilot credential out of a backend that is not Copilot. An
    alternative backend - -ApiBase, the session context,
    $env:SHELLPILOT_API_BASE, or a caller-owned request transport - now
    resolves no GitHub host, reads no OAuth token from any source, and exchanges
    no Copilot session token, in Invoke-Shp and Request-ShpEmbedding alike. A
    pipeline pointed at its own endpoint therefore needs no GitHub sign-in at
    all, and Test-ShpCiReadiness reports TokenSource as NotRequired
    instead of raising a standing issue. The Copilot backend is unchanged.

  • Refuse a Skill or Instruction body whose bytes changed after the caller
    approved it. Every file that shapes the model's behaviour is fingerprinted
    when it is catalogued and re-checked when it is loaded, so a body swapped
    between the description the caller read and the content the model receives is
    denied with a reason instead of injected. The result carries
    ResourceProvenance - source root, relative path, hash, size, trust and
    validation state - for every load attempt, so it is possible to establish
    after a surprising answer exactly which bytes the model was given. Nothing is
    discovered: every root is still one the caller named.

  • Bound a Subagent to what dispatched it. Invoke-ShpSubagent can only narrow
    the tool policy, redaction policy, tool visibility and budget it inherited,
    never widen them, and depth, fan-out, concurrency and duration are capped
    before any credential work. A child returns its answer and evidence rather
    than its transcript, so its exploration never enters the parent's context
    window, and a handed-back capability cannot arrive without the controls it
    was given.

  • Decide a Tool call, or contain it, from outside the model. -ToolCallControl
    is consulted before dispatch and after a result exists and may allow, deny
    with a reason, or modify the arguments or the result; -ExecutionContract
    wraps covered dispatch for a caller who supplies containment of their own.
    Each stage can only narrow: a control is asked only about a call the Tool
    policy already allowed, and arguments a control rewrote are re-checked
    against the policy before dispatch. Both take a scriptblock or a command
    name, are validated before the first request, and are never discovered from
    disk. Neither is a sandbox.

  • Reach a remote MCP server only where it was approved to be reached. A
    Streamable HTTP attachment validates its endpoint at registration and again
    before every request and every redirect, requires HTTPS unless a loopback
    opt-in is given, refuses embedded credentials, refuses an address that is not
    publicly routable, and pins the approved address set so a later answer -
    redirect target or re-resolution - cannot move the connection. The body,
    stream events and redirect chain are capped. Authorization is only what the
    caller supplies through a header or a per-request credential callback: a 401
    is reported by name rather than answered with whatever token is in reach, and
    nothing is cached or written to disk.

  • Correct embedding backend precedence and prevent a Copilot Session token
    from reaching a keyless alternative backend. Environment-selected backends
    now use the shared resolver. See embeddings.

  • Apply protected environment-assignment checks to colon-bound PowerShell
    parameter arguments as well as separate arguments.

  • Limit run_command to a minimal environment and refuse execution-sensitive
    literal environment assignments before child startup, even without a Tool
    policy. See command environment.

  • Protect Unix edit_file staging from creation. Apply the source file
    mode when creating the empty temporary file, before any content is copied,
    so a private source is not temporarily exposed through default permissions.

  • Keep edit_file on the target approved by the tool policy. Repointing
    the original directory alias after authorization no longer redirects the
    edit to a different file. Confirmation names the authorized target. Refuse
    special files again after staging so a named-pipe swap cannot block the
    final content check. External filesystem races remain outside the tool's
    guarantees.

  • Tool policy refuses paths when link resolution fails. A missing runtime
    API or filesystem error no longer leaves an unresolved path eligible for
    Read or Write access. This prevents a junction inside an allowed directory
    from bypassing rules for its destination on an unsupported runtime.

  • A disabled tool can no longer be executed. -DisableTerminal,
    -DisableFileAccess, -DisableBrowsing, -DisableUserPrompts and
    -DisableTodoList removed a tool from the set offered to the model, but the
    dispatch switch matched built-in tool names unconditionally — so a model that
    named a disabled tool anyway, from its own priors or from a replayed history,
    had it run. -DisableTerminal bounded what was advertised and nothing about
    what executed.

    Dispatch now refuses any built-in that this call did not offer, before the
    tool runs. The refusal reuses the existing tool-policy path: the tool.call
    event carries policy = denied, the reason names the disabled tool, the call
    appears on ToolCallsDenied, and the model receives {"denied": "..."} so it
    can choose another route instead of failing the turn. The offered set is
    derived from the assembled tool list rather than re-tested against each
    switch, so a tool added later cannot be offered under one condition and
    dispatched under another.

  • Register-ShpTool refuses a built-in tool name. Dispatch matches built-in
    names before it consults the user tool table, so registering run_command,
    read_file or any other built-in produced a tool that was advertised to the
    model and then silently ignored while the built-in ran instead — with the
    caller believing it had replaced it. An attached MCP server has always been
    refused a colliding name; a local registration now fails the same way, loudly
    and at registration time. Choose a distinct -ToolName.

Added

  • Grade agent behaviour without spending anything. Invoke-ShpEval runs a case
    suite through the real Tool-calling loop with the model replaced by a
    scripted transport, so it sends no request, reads no credential and exchanges
    no token, and a behaviour regression is caught by the ordinary test gate
    rather than by a credentialed job. Outcome, trajectory and cost are graded
    separately, because an agent that reached the right answer by running a
    forbidden command has still failed. A grader this module does not implement
    is an error rather than a pass, reliability is reported over repeated trials
    instead of a single green run, and credentialed live canaries are skipped
    unless they are explicitly asked for.

  • Add Invoke-ShpSubagent for dispatching part of a task to a child turn with
    its own model, reasoning effort, tool set and system prompt, read from an
    agent definition file the caller names. The child's answer and evidence come
    back, never its transcript, so a broad exploration costs the parent an answer
    instead of a conversation. The whole tree shares one budget ledger.

  • Add -ToolCallControl and -ExecutionContract to Invoke-Shp and
    Invoke-ShpBatch. A control receives one typed, versioned, independent
    request per Tool call - identifiers that correlate with the Event stream, the
    tool and its provenance, the original and effective arguments, and the result
    in the post phase - and answers with a decision. Every decision is recorded
    on the result's ToolCallDecisions as a receipt and as a tool.decision
    Event, so an unattended run can show afterwards what was approved, what was
    rewritten and what was refused.

  • Add Get-ShpContextReport to price a call before sending it, and
    Invoke-Shp -ContextReport / Invoke-ShpBatch -ContextReport to attribute a
    call after it. The report breaks the context window down by source - system
    prompt, instructions, skills, tool schemas, attachments, history, this turn -
    so hitting a context limit says what filled it, and the saving from deferred
    tool loading is measurable rather than asserted. The pre-call report sends no
    request and needs no credential.

  • Add Compress-ShpChat -Focus to steer compaction: one short instruction
    naming what the compression must try to keep, applied as a drop-order
    preference over whole exchanges. Anchors, the estimator and the default are
    unchanged, and omitting -Focus compacts exactly as before.

  • Add -ToolResultSpillRoot to Invoke-Shp and Invoke-ShpBatch so an
    oversized Tool result is written in full to a caller-named directory and the
    model receives a window plus the path, instead of a truncated result it
    cannot ask for the rest of. One seam covers every producer, and the spilled
    content composes with read_file's existing offset and limit.

  • Add Save-ShpChat, Restore-ShpChat and Get-ShpChatCheckpoint to save a
    conversation, inspect its checkpoints, resume it, and roll it back to an
    earlier turn, plus Invoke-Shp -SaveChatPath to checkpoint a turn as it
    completes. Content is written only to a path the caller names - never
    discovered, never defaulted - redaction is applied on write, and retention is
    the caller's. A resumed session therefore replays redacted history, so the
    model may answer differently than it did before.

  • Add -TraceParent to Invoke-Shp and Invoke-ShpBatch, and
    ConvertTo-ShpOtelTrace beside them. One run carries a derived trace
    identity that a Subagent and an outgoing MCP request inherit, so nested work
    lands under the span that dispatched it, and an Event stream converts into
    OpenTelemetry-compatible spans for an existing dashboard without writing a
    translator. Content capture is off by default and opts in explicitly; the
    module itself connects to no collector.

  • Attach a remote MCP server over Streamable HTTP with
    Register-ShpMcpServer -Url, alongside the existing stdio attachment and
    speaking both protocol eras over the same code. Supply fixed headers, or a
    per-request credential callback for a token that must be minted fresh.
    Get-ShpMcpServer reports the URL, the approved address, whether it is
    loopback, the header names and whether a callback is configured - never a
    header value and never the callback. Every stdio attachment behaves exactly
    as before.

  • Report what a structured reply and an MCP tool result actually conformed to.
    A declared JSON Schema is validated locally against a documented subset, and
    the outcome is reported as valid, invalid or explicitly unchecked rather than
    assumed. Every Tool call, result and Event record carries a provenance stamp
    naming the tool's origin and trust, and a server's own annotations are
    recorded without ever being treated as a capability claim.

  • Add opt-in -DeferredToolLoading to Invoke-Shp and Invoke-ShpBatch for
    Turn-local User/MCP schema search with search_tools. Keep fixed built-ins
    and explicit -Tool selections eager, with exclusion winning. Reserve the
    search_tools registration name and add deferred availability/loading result
    members. This is a schema-cost option, not authorization, containment, or
    prompt-injection defense. See deferred loading.

  • Add explicit -GitHubHost, Session context, and SHELLPILOT_GITHUB_HOST
    routing for GitHub Enterprise Cloud on GHE.com, with validated HTTPS origins,
    host-specific Session-token caching, and readiness reporting. Preserve
    GitHub.com defaults and the bounded child transport allowlist. See
    enterprise host configuration.

  • Add the per-call Invoke-Shp -Mode Plan read-only preset, intersecting caller
    filters and the unchanged session Tool policy. Withhold mutation, terminal,
    User, MCP, and interactive-question tools. See
    Plan mode and its limits.

  • Add exact -Tool and -ExcludeTool visibility filters to Invoke-Shp and
    Invoke-ShpBatch, covering built-in, User, and MCP names with exclusion
    winning. Withdrawn tools retain the existing denial contract. See
    tool visibility.

  • Add Set-ShpRedactionPolicy -SecretEnvironmentVariable for current literal
    values in outgoing text and Event records, with names-only reporting and
    batch/job replay. See named secret redaction.

  • Add the maintainer-selected MIT license and document stable and
    prerelease Gallery installation. Bundle the same license
    text in built modules and packages.

  • Add explicit estimated budgets for trusted owned requests. Opt into
    RequestBudgetMode provider-estimate for non-refundable reservations and
    upward reconciliation; default verified counters keep their meaning.
    Add the bounded Claude child provider/counting path, pre-generation host
    admission, and secret-free reported/partial Usage. No automatic retries,
    Model substitution, or invoice guarantee is implied. See
    the transport contract.

  • Add conditional admission limits to owned Invoke-Shp transport.
    RequestLimits and a trusted RequestTokenCounter reserve input, maximum
    output, and Engine-priced cost before dispatch. Unknown or failed Usage
    retains its reservation and stays explicitly unknown in results, events,
    and summaries. No verified Copilot counter or complete child runtime is
    supplied; ordinary calls are unchanged. See
    the contract and limitations.

  • Add optional host transport groundwork to Invoke-Shp. Use
    NoAutomaticRetry to disable transport and API-shape resends, or
    RequestTransport for a credentialless Tool-calling loop with a trusted
    caller-owned transport. Ordinary calls are unchanged. Hard request admission,
    process isolation, and a trusted transport process are not supplied by these
    options. See the contract and limitations.

  • Add edit_file for targeted changes to existing files. Replace one
    exact, case-sensitive oldString with newString, refusing zero matches or
    multiple matches with recovery guidance. Preserve the BOM, encoding and
    unchanged line endings for UTF-8 and BOM-marked UTF-16/UTF-32; refuse
    malformed or unsupported text instead of converting it. The tool is disabled
    by -DisableFileAccess, and reports the intended edit without writing under
    -WhatIf.

    When a tool policy is set, an edit needs both a Read() and a Write() rule
    covering the target, and a deny in either kind refuses it. Match counts
    disclose file content even when the replacement is identical, so Write()
    alone would turn the tool into a way to confirm guesses about a file the
    policy never granted read access to. write_file and create_directory are
    unchanged and still need only Write().

    Only regular, seekable files are eligible, and input and output are each
    capped at 8 MiB including the BOM. Unix file types are checked directly,
    avoiding a hang when formatted permissions misidentify a named pipe as a
    regular file. The edit is staged in the target's own
    directory, flushed, checked against the file's current bytes, and then put in
    place with a backup of the original. A native replacement failure that moves
    the original away retains the backup and reports recoveryPath for manual
    recovery instead of deleting the remaining copy. Cleanup is best effort.
    Detected concurrent changes are refused; the final check and replacement
    are not a compare-and-swap guarantee.
    See README.md.

  • glob_files and grep_files let the model search without a shell.
    glob_files finds files by name pattern under a directory; grep_files
    searches their contents and returns only the path, line number and matching
    line, leaving read_file to read around a hit. Both are offered with the
    other file tools and withdrawn by -DisableFileAccess.

    Both are governed by the existing Read() rules of Set-ShpToolPolicy, and
    that is the point: until now the only way to make the model find something
    was run_command, so a policy tight enough to be worth setting had to grant
    Shell(...) — far more reach than searching needs. Set-ShpToolPolicy -Rule 'Read(./**)' is now enough to let the model locate a file by name or by
    content while run_command stays denied.

    Every returned hit is policy-checked, not just the search root, because a
    glob rooted at an allowed directory can still match a path that resolves,
    through a link, to somewhere no rule covers. An excluded hit is counted in
    excludedByPolicy rather than dropped silently. Both results are bounded —
    files examined, matches returned, and characters returned — and set
    truncated when any cap bites, so the model narrows the pattern instead of
    overflowing the context window.

  • ConvertTo-ShpAnnotation surfaces structured findings in CI. Pipe a
    ShellPilot.Result from Invoke-Shp -JsonSchema, or any plain finding
    object, into the cmdlet to produce GitHub Actions annotations, Azure DevOps
    task.logissue commands, or readable text. Level, Path, Line,
    Column, Title, and Message are matched case-insensitively and can be
    redirected with -PropertyMap; an unknown or missing level is always a
    warning. Vendor-specific escaping keeps newlines and delimiters from
    corrupting a workflow command. Output stays on the success stream unless
    -Emit writes it to the host, and -Summary appends a Markdown table to
    $env:GITHUB_STEP_SUMMARY when available.
    See specs/028-ci-annotations.md.

  • Invoke-Shp -EventStream <path> writes a headless JSONL event stream. A
    CI log collector reads lines, not prose: everything the module said about a
    running turn was aimed at a person, so a nineteen-iteration turn that was
    refused twice by the tool policy, retried once on an expired session token
    and then stopped on -MaxBudgetUSD left one object saying
    BudgetExceeded = $true and nothing about the shape of the failure. The
    stream appends one JSON object per line - turn.start, model.request,
    usage, reasoning (one per streamed chunk under -ShowThinking),
    tool.call, tool.result, todo, retry, error, final - each carrying
    schemaVersion, a monotonic sequence, an ISO 8601 UTC timestamp, a
    type and a flat data object. Pass - to write the records to the
    Information stream instead of a file. Every line is appended whole, so a run
    killed mid-turn still leaves a file that parses up to its last complete line;
    a later call appending to that valid stream continues the sequence. Every
    string payload goes through the same redaction seam the request body does, so
    a secret a tool printed does not reach the stream verbatim; a run_command
    tool-call record names the tool and the policy decision but never the command
    line. The complete streamed reasoning trace is redacted before it is divided
    back into Event records, so an SSE boundary cannot split a secret around the
    redaction seam; partial reasoning is retained before a retry or error.
    Transient HTTP and network-outage retries from the shared request wrapper are
    recorded with attempt, delay and status data, and an invented ask_user call
    in a non-interactive turn records its denied Tool call and terminal error
    before the call stops. -DisableProgressEvents no longer switches this off -
    the two sinks are gated independently.
    See specs/027-headless-event-stream.md.

  • Invoke-Shp -AsJob and Invoke-ShpBatch -AsJob run a call in the
    background.
    Both return a thread job whose Receive-Job resolves to the
    same ShellPilot.Result / ShellPilot.BatchResult objects the synchronous
    call returns - the same process, so nothing is serialised into a
    Deserialized.* copy. The job runspace inherits no module state, so the
    session context, session defaults, cached model limits, tool policy,
    redaction policy and registered tools are replayed into it and the module is
    imported by path. Invoke-Shp -AsJob is seeded from a snapshot of the
    session conversation and stays stateless from there, because a job that
    finishes at an arbitrary time must not race the caller's next call. The CI
    entitlement gate is still evaluated at the call site, so a refused backend
    fails where you typed it rather than in the background of a green build. An
    event stream is honoured: -AsJob does not silently turn it off.
    See specs/027-headless-event-stream.md.

  • Invoke-Shp now redacts secrets before they leave the runner. A CI job
    feeds the model diffs, build logs and attachments produced by untrusted
    pull-request content, and nothing scrubbed them before now - a leaked token
    in a log became a token sent to a third party. Immediately before each
    round-trip, the prompt, every inlined -Attachment, and every tool result
    (run_command, read_file, fetch_url, an MCP tool, a user-defined tool)
    is scanned for six built-in shapes - GitHub tokens, AWS access key ids, PEM
    private-key blocks, JWTs, basic-auth URL credentials, and connection-string
    password fields - and a match is replaced with a stable, named placeholder
    such as [redacted:github-token], never simply deleted. The result reports
    Redactions: pattern name and count only, never the matched value.
    Set-ShpRedactionPolicy / Get-ShpRedactionPolicy / Clear-ShpRedactionPolicy
    add custom patterns on top of the built-ins, in the same Name(Pattern)
    shape Set-ShpToolPolicy already uses, and the custom policy travels to
    every Invoke-ShpBatch worker the same way the tool policy does. Redaction
    is on by default; pass -DisableRedaction to send a call verbatim. Only the
    model's own reply is exempt - it was generated from input already redacted
    before it was sent, so it cannot reflect a secret it was never shown, and a
    -JsonSchema reply still parses onto ContentObject exactly as it would
    with redaction off.
    See specs/026-egress-redaction.md.

  • An unattended run is now a supported, deliberate profile rather than an
    accident.
    Invoke-Shp, Invoke-ShpBatch and Initialize-Shp take
    -NonInteractive, on automatically when $env:CI is truthy and overridable
    with -NonInteractive:$false. It withdraws ask_user, refuses -Confirm
    instead of silently answering it yes, and refuses the device-code flow before
    the browser launch and the clipboard write - because a prompt on a runner does
    not fail, it burns the job's whole timeout and then fails for the wrong
    reason. A model that calls ask_user anyway ends the turn with
    ShpNonInteractivePrompt rather than continuing on an answer nobody gave.
    See specs/025-ci-profile.md.

  • In CI, the default Copilot backend is refused unless you opt in. That
    backend reaches the Copilot endpoints with the public VS Code client id, on
    the token owner's personal entitlement - fine for a shell, a decision for a
    pipeline. Configure an OpenAI-compatible endpoint instead, or set
    SHELLPILOT_ALLOW_COPILOT_BACKEND_IN_CI. The error carries the id
    ShpCopilotBackendInCi and names both remedies, and it is raised before
    the token exchange so nothing is spent proving the point.
    A warning was the obvious alternative and is the wrong shape: nobody reads a
    warning in a green build, which is the whole finding behind -FailOn.

  • $env:SHELLPILOT_API_BASE and $env:SHELLPILOT_API_KEY are read as
    backend defaults, below an explicit -ApiBase and the session context and
    above the built-in Copilot endpoint - so a pipeline points ShellPilot at its
    own endpoint with the variables it already injects.

  • Test-ShpCiReadiness reports the whole resolved profile - token source,
    backend, interactive capability, Ready, and a list of named issues - without
    sending a chat request or exchanging a token. The three things an unattended
    run needs are decided by three different precedence chains with silent
    fallbacks, so a misconfigured job otherwise fails minutes later at the first
    Invoke-Shp with whichever chain gave out first. No secret is returned: the
    credential and the API key are reported by source only, and the endpoint has
    any URL credentials redacted.

Changed

  • Fail -FailOn SchemaMismatch on a reply that parsed but did not conform.
    The condition previously fired only when the parse itself failed, so a reply
    that was valid JSON and wrong shape passed a gate armed against exactly that.
    It now fires on a failed parse or on an established schema violation, and
    never on a schema outside the local validator's subset - an unchecked schema
    is reported as unchecked rather than counted either way. A caller who armed
    this condition against a non-conforming reply will start seeing it stop.

  • Record what a Tool call actually ran with. The executed-call record and the
    tool.call Event now carry the effective arguments - the ones in force
    after a decision control rewrote them - rather than the ones the model first
    emitted, and each receipt on ToolCallDecisions carries a hash of both the
    original and the effective arguments, so a rewrite is provable without
    copying the arguments into a second place. Nothing changes for a call no
    control touched.

  • Require explicit -CommandEnvironmentVariable names on Invoke-Shp and
    Invoke-ShpBatch for variables outside the minimal child environment.
    Scripts relying on implicit inheritance must opt in; naming a credential
    deliberately makes it available to the child. This is not containment.

  • Raise the required PowerShell version from 7.0 to 7.4. Upgrade the host
    before importing this version. Unix staging uses FileStreamOptions with
    UnixCreateMode to protect content from creation, available in PowerShell
    7.4's .NET 8 runtime. Tool policy path resolution also requires
    ResolveLinkTarget(). Unix regular-file checks use UnixStat.ItemType,
    not the display-oriented UnixMode string.

  • An alternative backend (-ApiBase) no longer carries the Copilot session
    token.
    It previously fell back to that token whenever no ApiKey was
    configured. That was reachable before only through Set-ShpContext; reading
    ApiBase from the environment would have let anything able to set a variable
    on a runner redirect a live Copilot credential to a host of its choosing.
    With a key the request carries the key; without one it carries no
    Authorization header at all, which is what a local server expects anyway.
    A URL's credentials are also redacted from the result's Endpoint member.

  • A pipeline step can now fail when the call did not deliver. A budget
    overrun was a Write-Warning plus a BudgetExceeded property, so an
    unattended run exited 0 on a truncated or abandoned answer and wrote the
    half-finished artifact anyway. Invoke-Shp -FailOn turns five named outcomes
    into terminating errors: BudgetExceeded, Truncated, ToolIterationLimit,
    NoContent and SchemaMismatch.
    Each carries a distinct, documented FullyQualifiedErrorId
    (ShpBudgetExceeded,Invoke-Shp and so on) so a wrapper branches on the
    condition instead of matching an English message - which is what
    MaxToolIterations forced, having used its own message text as the error id.
    Omitting -FailOn changes nothing, and the turn's side effects are
    unchanged either way: the call is evaluated last, after the result is built,
    the usage row written and the session chat updated, so -FailOn decides only
    whether the call ends with a result or with an error. The whole
    ShellPilot.Result rides on ErrorRecord.TargetObject, so a catch block
    still knows what the abandoned turn cost.
    ShellPilot never sets $LASTEXITCODE and never calls exit - a module that
    terminates its host cannot be composed - so the exit code stays the caller's
    job; the comment-based help carries the try/catch plus exit 1 wrapper.
    See specs/024-pipeline-failure-semantics.md.

  • Invoke-ShpBatch -FailOn and -FailBatchOnAnyItem. The same five
    conditions apply per item, and a tripped one never aborts the batch: the item
    reports Success = $false with the branchable ErrorRecord intact while every
    other item runs to completion. -FailBatchOnAnyItem raises one terminating
    error afterwards, ShpBatchItemsFailed,Invoke-ShpBatch, carrying a
    ShellPilot.BatchSummary with TotalCount, SucceededCount, FailedCount,
    SkippedCount and the Failed results themselves.

  • ShellPilot can authenticate without a browser and without a token file.
    Initialize-Shp was device-code only and Get-ShpSessionToken opened with an
    unconditional Test-Path throw, so a CI runner - no profile, no browser -
    could not sign in at all. Two in-memory sources now exist:
    Set-ShpContext -GitHubToken for a caller that already holds the value, and
    $env:SHELLPILOT_GITHUB_TOKEN for a pipeline that injects it as a secret.
    Neither is ever written to disk; Initialize-Shp remains the only code path
    that writes a token file.
    One private resolver owns the order - explicit -TokenPath, then the session
    context, then the environment variable, then the default token file - because
    the module's existing rule is that any option family with more than one source
    resolves in exactly one place, and a credential is the worst place to have two
    call sites disagree. The session token is supplied to Invoke-ShpBatch
    workers too, which inherit nothing.
    A set-but-empty SHELLPILOT_GITHUB_TOKEN is rejected, not skipped. That
    state is what a pipeline produces when its secret fails to expand, and falling
    through to the token file would authenticate the run as whoever last signed in
    on that machine - green build, wrong identity. The token is masked as *** by
    Get-ShpContext, matching -ApiKey, and a verbose line names which source
    supplied it and never the value.
    See specs/023-non-interactive-token.md.

  • Invoke-Shp -Attachment takes a file of any format. -Image was the only
    attachment surface and accepts images only, so everything else had no route to
    the model at all - naming the path in the prompt works for a text file and
    fails for anything else, because read_file decodes text and a .msg comes
    back as 2,257 characters of raw bytes.
    Each file is classified by content, not extension, and routed three ways:
    an image joins the vision path; a text file of any encoding is decoded and
    inlined, capped with the usual truncation marker; and a binary file is
    described rather than decoded - the model is given the absolute path, the
    size, the format identified from the file's magic number, and a hex preview of
    the head.
    ShellPilot deliberately converts nothing. A converter table would need a
    dependency and a new extractor per format, against a module whose stated
    constraint is no runtime dependencies - while the model already has read_file
    and run_command and lacked only the first bytes. Live-verified on the .msg
    that motivated this, with neither Outlook nor Python present: given
    d0 cf 11 e0 a1 b1 1a e1 the model recognised the OLE2 compound file and
    decoded the sender, subject, timestamp, flight number and booking code itself
    in four iterations.
    Attachment content is inserted into the user message and framed as data,
    never as a system instruction, because a document is untrusted content
    (spec 019's threat model). The payload is kept out of the replayed history, so
    a continuation records that files were attached without resending them. The
    result reports Attachments.
    See specs/022-file-attachments.md.

  • Register-ShpMcpServer, Get-ShpMcpServer and Unregister-ShpMcpServer
    attach MCP (Model Context Protocol) servers so their tools are offered to the
    model alongside the built-ins and any registered user tools. Opt out for one
    call with Invoke-Shp -DisableMcp; nothing is offered until you attach a
    server, so the default posture is unchanged.
    Both protocol eras were supported over stdio from the start, and over the
    remote transport added in this release.
    Verified from the
    specification rather than from memory, which changed the design: the current
    revision 2026-07-28 removed the initialize handshake entirely - a modern
    request is stateless and carries its protocol version and client capabilities
    in _meta, with server/discover as a mandatory RPC - while nearly every
    server in the field still expects the handshake. The client therefore probes
    with server/discover and falls back to initialize on any other error or a
    timeout, never keyed to one error code. Both eras were negotiated live, and
    the real Azure MCP Server 2.0.5 turned out to be a legacy server - which
    settles whether supporting both was worth it.
    See specs/021-mcp-server-support.md.

  • Tool names are namespaced mcp_<alias>_<tool> using the alias you chose,
    not the server's self-reported name, which the protocol says nothing
    verifies. The sanitiser is written to a measured constraint, not a guessed
    one: the Copilot endpoint enforces ^[a-zA-Z0-9_-]{1,128}$, so a dot - which
    MCP explicitly permits in a tool name - becomes _. This matters because a
    rejected name identifies the offending tool only by its index in the
    request, and the resulting 400 is masked by the chat-to-responses fallback,
    surfacing as "model ... does not support Responses API" - a true statement
    about an entirely different problem.

  • Get-ShpTool now lists MCP tools next to user tools, with a new Origin
    column (User or Mcp) and the contributing Server. Invoke-Shp reports
    McpEnabled, McpServersAvailable, McpToolsAvailable and McpToolsCalled,
    and an MCP call emits the same ToolCall progress record as every other tool,
    so a host renders it identically.

Fixed

  • Update the price table to the GitHub Copilot rates published on 2026-09-30,
    so every chat Model the service advertises reports CostUSD and Credits.
    Add claude-opus-5.5, claude-sonnet-5.5, gpt-6-astra, gpt-6-luna,
    gpt-6-sol, gpt-6.1-sol, gemini-3.7-flash, gemini-3.8-flash,
    grok-4.6, grok-4.7 and mai-code-1.1-flash, plus the published but not
    yet advertised claude-fable-5.1 and kimi-k3. Lower gpt-5.6-sol to
    4.00 / 0.40 / 5.00 / 20.00 USD per million input / cached-input /
    cache-write / output tokens (long context 8.00 / 0.80 / 10.00 / 30.00), and
    apply the promotional gemini-3.6-flash rate of 0.75 / 0.075 / 3.75 through
    2026-12-31. claude-sonnet-5 keeps 2.00 / 0.20 / 2.50 / 10.00, because the
    page no longer announces the 2026-09-01 increase. gpt-5.6-sol-fast is
    internal only and has no published rate, so it stays unpriced. Models the
    page no longer lists keep their last published rate. See
    Models and pricing.

  • Restore minimum-runtime CI child-process tests with checksum-verified native
    PowerShell 7.4.19 archives, and keep packaged-license checks exact across
    Windows and Linux by checking out the root license with LF line endings.
    See CI run 34147749896.

  • Keep explicit enterprise model lookups from replacing shared session limits,
    and ignore cached model limits tagged for a different GitHub host.

  • Synchronize the source manifest with every public
    command and retain a source-level regression against missing exports. The
    manifest covered 35 commands when this was fixed and covers 42 today; the
    regression is what keeps the two in step.

  • A failed batch item no longer reports zero cost. Invoke-ShpBatchItem
    built its result from the ErrorRecord alone, so a call that threw
    contributed nothing to the batch spend accumulator. That was invisible while
    every failure was an HTTP refusal that had cost little, but an
    Invoke-Shp -FailOn stop is a completed, billed turn - a sweep of truncated
    replies would have overrun -MaxBatchBudgetUSD silently. A failing item now
    recovers its result from TargetObject and keeps Model, FinishReason,
    Usage and CostUSD. Content is still withheld from an unsuccessful item.

Security

  • An MCP server is a third-party process running with your privileges, and
    there is no sandbox.
    Its tool names and descriptions are untrusted input the
    model reads on every round-trip, and its results are untrusted content. Three
    controls follow, each stated with its limits:
    • Nothing is discovered. No scan of the working directory, .vscode or a
      user profile. Register-ShpMcpServer -Path reads a file you name. A
      configuration file is a command line, and a command line is arbitrary code.
    • The tool list is frozen at registration. The client opens no
      subscriptions/listen stream, so it receives no
      notifications/tools/list_changed and a server cannot add or alter tools
      after you approved them. Refreshing is an explicit -Force. This does not
      stop a server changing its behaviour.
    • The child environment is built, not inherited. Unlike run_command,
      which deliberately inherits the whole block for compatibility, an MCP child
      starts from a minimal base plus exactly the variables you name in
      -Environment, so an ambient $env: credential is not handed to somebody
      else's process.
  • When MCP support first landed, Set-ShpToolPolicy could not gate an MCP
    call, and that was stated rather than implied.
    Its rules matched resolved
    filesystem paths and leading command tokens; a tool call has neither.
    Demonstrated in one live Turn under Read(<repo>/**): the built-in
    read_file was denied with a reason and the MCP tool call ran. Superseded
    in this release by the Mcp rule kind
    , which gates the alias and tool name
    a call dispatches under - see the entry above. Two things still hold: a
    policy that scopes read_file to one directory says nothing about an
    attached filesystem server, and no rule matches inside the call's JSON
    arguments. Reducing reach at attachment with -ToolName remains worthwhile.
  • The injection path was measured, not asserted. With a hostile instruction in a
    tool description only, the model read a decoy credentials file and passed
    its contents to the third-party server as a tool argument - the server's own
    log confirms receipt. The same run with -DisableFileAccess read nothing and
    leaked nothing.
  • A configuration entry asking for sandboxEnabled warns and still starts:
    a configuration written for a sandboxing host is exactly the one you want to
    reuse. The gap is surfaced twice rather than being fatal once - a warning that
    says the server is starting unsandboxed, plus SandboxRequested on the server
    record, because a warning scrolls away and a property does not. An entry
    carrying an unresolved ${...} variable is refused, because starting the
    literal text would run a different command than the file describes.
  • Get-ShpMcpServer reports environment variable names only, never values.

Known limits

  • stdio was the only transport when MCP support first landed. Superseded in
    this release
    : a guarded Streamable HTTP attachment now exists, with the
    endpoint, address set, body, stream events and redirects all bounded. The MCP
    Authorization framework is still not implemented - a remote attachment
    carries only the credentials the caller supplies, and a 401 is reported
    rather than answered.

  • Invoke-ShpBatch does not use attached servers and warns once. A worker
    runspace inherits nothing, so replaying an attachment would start one copy of
    every server per worker.

  • A server that exits is marked Faulted rather than restarted automatically.
    The specification says a client SHOULD restart one; automatic respawn of
    third-party code inside an unattended loop turns one crash into a crash loop
    nobody is watching. Re-attach with Register-ShpMcpServer -Force.

  • Resources, prompts, sampling, elicitation and roots are out of scope for this
    version. Client capabilities are declared empty, so a modern server needing
    one gets a protocol error naming it rather than hanging.

  • The cached OAuth token is now protected at rest, closing open decision #5.
    On Windows it is DPAPI-encrypted for your account; on every platform the file
    is restricted to the current user. Measured before the change, the real file
    was 40 bytes of clear text with the profile's inherited ACL, readable by
    BUILTIN\Administrators as well as by you.
    The file is self-describing - SHPv1:DPAPI:... or SHPv1:NONE:... - and
    Initialize-Shp reports which protection it applied, because a scheme that
    silently degrades to clear text is worse than clear text. On Linux and macOS
    there is no DPAPI equivalent without a dependency, so the scheme is NONE
    and file permissions (mode 600) are the only control; that is stated in the
    file and on screen rather than implied.
    No runtime dependency was added - ConvertTo-SecureString is built in.
    SecretManagement was considered and rejected: SecretStore prompts to unlock,
    which would break every unattended run, and configuring it not to prompt
    reduces it to file permissions while costing this module its empty dependency
    list. This buys protection against another principal on the machine; it does
    not protect against code running as you, and no candidate scheme would.
    See specs/020-encrypted-token-storage.md.

  • An existing clear-text token file keeps working and is upgraded in place by
    Initialize-Shp without re-authenticating
    , so nobody needs a browser just
    to gain protection. A protected file that cannot be decrypted - typically one
    copied from another machine or account - throws with an actionable message
    naming Initialize-Shp -Force, rather than sending a ciphertext to the
    service as a bearer token.

  • Set-ShpToolPolicy, Get-ShpToolPolicy and Clear-ShpToolPolicy scope what
    the unsandboxed file and shell tools may reach, so an unattended run can be
    given the access it needs instead of the caller's entire filesystem and shell.
    Rules are written Read(./src/**), Write(./out/**), Shell(git status),
    with a leading ! to deny; any matching deny beats every matching allow.
    Nothing changes until you set a policy - and once you do, the model is
    denied by default. Refusals are reported on the result as ToolCallsDenied,
    and the policy travels into every Invoke-ShpBatch worker, because a worker
    inherits no module state and would otherwise be the one unguarded path.
    This closes a real gap rather than a symmetry: -Confirm is interactive only
    so an unattended run never prompts, and -DisableFileAccess /
    -DisableTerminal are all-or-nothing, so the only safe unattended setting was
    "all tools off".
    See specs/019-tool-access-policy.md.

  • Paths are matched on the absolute, link-resolved path rather than the
    string the model supplied, so neither a .. segment nor a directory junction
    walks out of an allowed root, and patterns are anchored so a rule for out
    cannot match outsider. run_command is matched on whole leading tokens and
    refuses any shell metacharacter whatever the rules say, because
    git status; curl ... would otherwise pass a rule that only ever meant
    git status. A Shell rule constrains which program runs, not what it does;
    the spec states that limit and the others plainly.

  • Invoke-Shp -RetryDelaySec, and -TimeoutSec, -MaxRetryCount,
    -RetryDelaySec and -NetworkOutageToleranceSec on Get-ShpModel and
    Request-ShpEmbedding. All four now resolve identically everywhere - explicit
    parameter, then Set-ShpContext, then the built-in default - and apply to
    every request the module makes, including the OAuth-to-session token
    exchange, /models and embeddings. Those three previously called the retry
    wrapper with the module's own defaults, so Set-ShpContext -TimeoutSec 10 -MaxRetryCount 0 silently did not apply to them; and Invoke-Shp resolved
    its options after the token exchange had already run, so an explicit
    -TimeoutSec never reached the one request that gates every other one. There
    is deliberately no exemption for the auth handshake: the exchange is cached,
    so -MaxRetryCount 0 costs at most one un-retried attempt per session, and
    outage tolerance is a separate option, so a dropped connection during auth is
    still ridden out.

  • Compress-ShpChat drops the oldest exchanges from the running session
    conversation so a session that has outgrown the model's context window becomes
    usable again without discarding it. Until now the only ways out were
    Clear-ShpChat and a stateless -History call, and both throw the whole
    conversation away. Invoke-Shp writes the conversation back only when a call
    succeeds, so a refusal leaves it pinned and every later call is refused
    identically - measured at 0 successes in 108 retries. Measured against the
    live service, dropping the single oldest exchange was enough to restore a
    pinned session, so discarding all of it was never necessary. The first
    exchange (usually the task definition) and the newest exchange are kept,
    exchanges are dropped as whole user/assistant pairs, -WhatIf reports the
    plan without changing anything, and the returned report says exactly what
    went. Conversation turns are never elided automatically: a tool result is
    scaffolding the model produced for itself, but a user turn is something the
    user said, and a model answering from a silently truncated history can
    confidently contradict it.
    See specs/018-conversation-history-overflow.md.

  • Invoke-Shp now warns before sending once the context guard has elided
    every tool result it may and the conversation is still over budget, naming
    Compress-ShpChat. It is phrased as a fact about the guard rather than a
    prediction about the service, because ConvertTo-ShpTokenCount was measured
    at +30% / -12% against the service's own token count and is not accurate
    enough to refuse a call on. The warning fires once per turn, not once per tool
    iteration.

  • The context-window guard now sizes itself from the model in use. Left unset,
    -MaxContextWindowTokens resolves in four steps - the parameter, then
    Set-ShpContext -MaxContextWindowTokens, then the model's own advertised
    limits, then the built-in 900000 - and the resolved figure and the step that
    produced it are reported on the result as ContextBudget and
    ContextBudgetSource. The third step is not simply the advertised context
    window: that figure covers prompt plus completion, so the model's output
    allowance is reserved first and a 10% margin taken from what remains.
    Measured against the live service, claude-haiku-4.5 advertises a 200000
    window with a 64000 output cap and refuses a prompt at 136000 - exactly
    200000 - 64000 - so a margin on the advertised window alone would still have
    missed it. No advertised pair on offer resolves above 900000, so this can only
    ever tighten an existing caller's guard, never loosen it.
    See specs/017-context-window-budget-from-model.md.

  • Get-ShpModel records each model's advertised context window and output cap
    in a session cache as a side effect. That cache is what lets the guard resolve
    a real window with no request of its own: a turn is a loop, so consulting
    /models per turn would add a round-trip to calls that are otherwise local.
    Until something fills it - Get-ShpModel, or Get-ShpModelName, which calls
    it - the guard uses the fallback, which is no model's real window and is too
    permissive for 22 of the 36 models that advertise one. Run Get-ShpModel once
    per session to fix that. Invoke-ShpBatch copies the cache to every worker,
    and Initialize-Shp discards it on re-auth.

  • A model that is absent from a model list that was fetched now warns once per
    model per session that the guard is running on the fallback, in the same
    spirit as Priced / PriceTableKey making an unpriced call observable. A
    cache that has simply never been populated - the default state of every
    session - stays quiet.

  • Get-ShpUsage now records calls that failed, not only the ones that
    succeeded. A failed call carries Success $false and the failure message on
    Error, plus whatever spend its completed round-trips had already incurred.
    Both halves of that matter: a success rate computed from the old log was 100%
    by construction, because only successes were in it; and a turn is a loop of
    billable round-trips, so a turn refused on its third round-trip really was
    charged for the first two and reported nothing. Only a call that reached the
    API is recorded - a parameter combination rejected before any request was
    never a call. Invoke-ShpBatch inherits this, so a failed batch item now
    shows up in Get-ShpUsage too.
    See specs/016-failed-call-usage-accounting.md.

  • Get-ShpUsage -Summary gained Succeeded, Failed, TotalDurationMs,
    MeanDurationMs, FirstCall, LastCall and ElapsedMs, and the ByModel
    breakdown gained Succeeded, Failed and DurationMs. ElapsedMs is
    wall-clock between the first and last call and is deliberately not the sum
    of DurationMs: under Invoke-ShpBatch the calls overlap, so the sum can far
    exceed the elapsed time and the ratio between them is the speed-up the batch
    bought.

  • Get-ShpUsage -Since and -Before filter by time window, so one phase of a
    run can be summarised without clearing the log between phases. Both apply to
    the records and to -Summary. There is deliberately no -GroupBy:
    Get-ShpUsage returns the records, so Group-Object already groups by any
    field, and ByModel is pre-aggregated only because that split is the common
    case.

  • Invoke-ShpBatch runs many independent prompts concurrently and returns one
    ShellPilot.BatchResult per input, carrying the answer, that item's usage and
    cost, and - when the call failed - the error. -ThrottleLimit bounds how many
    calls are in flight (default 4, deliberately conservative), and prompts can be
    piped in as plain strings or as objects with Prompt and an optional Id.
    Three guarantees are the point of it. Every item is stateless: a batch never
    reads or writes the session conversation, so it cannot reproduce the
    accumulation that makes a serial loop of Invoke-Shp calls grow until the
    model refuses it with model_max_prompt_tokens_exceeded. Failures are
    isolated: one failed call never aborts the batch and nothing is written to the
    error stream, because an error raised from a worker obeys the caller's
    $ErrorActionPreference and would destroy every result under Stop; check
    Success and Error on the results, and a single summary warning names how
    many did not complete. And identity is carried: results arrive in completion
    order, so every one has Index, Id and the original InputObject.
    -MaxBatchBudgetUSD caps the whole run as a gate on dispatch - calls already
    in flight are never cancelled. Streaming, ask_user and progress events are
    off for every item, for reasons the help gives. The model, sampling
    parameters, -ResponseFormat / -JsonSchema, -SkillPath,
    -InstructionRoot, the isolation switches and the connection options are all
    forwarded, and per-item usage is merged into Get-ShpUsage.
    See specs/015-batch-execution.md.

  • Invoke-Shp -MaxContextWindowTokens and Set-ShpContext -MaxContextWindowTokens set the token budget above which a turn elides its
    oldest tool results, with the usual precedence of explicit parameter, then
    session context, then the built-in default. The built-in 900000 is a fallback
    rather than any model's real window - claude-haiku-4.5 is 136000 - so the
    guard could never fire for a smaller model; set this from the model's own
    MaxContextWindowTokens (see Get-ShpModel) to make it fire when it should.
    0 disables the guard. The default is unchanged, so existing calls behave
    exactly as before. Note this bounds tool results only: the session
    conversation is never elided, so a long loop of calls still needs
    Clear-ShpChat or -History.

  • Invoke-Shp now explains a rejection it cannot recover from. A service reply
    of model_max_prompt_tokens_exceeded is emitted as a warning naming the real
    cause - every -Prompt call continues the session conversation, so a loop of
    calls grows until it no longer fits - and the two remedies. Left unexplained
    this reads as a bare 400 and gets mistaken for rate limiting.

  • Invoke-Shp -Temperature, -TopP and -Seed control the model's sampling,
    so a call that has to be reproducible (grading or judging in an evaluation
    harness) can pin itself with -Temperature 0 and a variance measurement can
    fix its operating point instead of inheriting the backend default. Each field
    is omitted from the request body entirely when the parameter is not passed, so
    existing calls are unchanged. -Temperature is validated against 0..2 and
    -TopP against 0..1 before the request is sent, and a model that rejects a
    field fails the call rather than having the field silently dropped.
    The values used are reported on the result as Temperature, TopP and
    Seed. See specs/014-sampling-parameters.md.

  • Invoke-Shp and Get-ShpCostEstimate results carry Priced and
    PriceTableKey, so a call the price table cannot cost is no longer
    indistinguishable from a free one. PriceTableKey stays populated even when
    nothing matched, naming the key that was looked up and missed, and the first
    call for an unpriced model warns once per session rather than once per tool
    iteration. CostUSD and Credits are unchanged and still null, never 0,
    when no rate is found. Priced is also recorded on each Get-ShpUsage entry.

  • Resolve-ShpError explains the last error in the session and suggests a fix.
    It takes an error record ($Error[0] by default, or from the pipeline), sends
    the message, exception type, category, target, failing command line and script
    stack trace to the model, and returns the usual Invoke-Shp result. Every
    tool is disabled unless -EnableTools is passed, so diagnosing an error
    cannot touch the machine.

  • Invoke-Shp supports ShouldProcess. -WhatIf dry-runs a whole agent turn -
    the model still plans and calls tools, but write_file, create_directory,
    run_command and user-registered tools are skipped and told they were not
    approved - and -Confirm prompts before each of those calls. Default
    behaviour is unchanged.

  • Invoke-Shp -MaxBudgetUSD stops the tool-calling loop once the turn's
    estimated spend passes the cap, and the result carries a new BudgetExceeded
    flag.

  • Invoke-Shp -AppendSystemPrompt adds inline system instructions in either
    parameter set, so a file-driven system prompt can still be topped up for a
    single call.

  • Invoke-Shp -AllowPrivateNetwork opts the fetch_url tool back in to
    loopback, link-local and private addresses.

  • Start-ShpChat gained the /models, /history, /retry and /usage
    commands. /retry drops the last exchange and resends the previous prompt.

  • The price table supports a long-context tier. An entry may carry a
    LongContext block with a Threshold in input tokens plus its own rates, and
    the cost breakdown now reports Tier and TiersUsed.

Changed

  • Set-ShpContext -TimeoutSec's documented built-in default was wrong. The
    help said 100 seconds; the module variable holding it was never read and the
    real default is 0, meaning no explicit timeout - the shared HttpClient is
    built with an infinite timeout so a long streamed turn is not cut off
    mid-response. The documentation is corrected and the dead variable removed. No
    behaviour changed, because the 100 had never applied to anything.

  • The model_max_prompt_tokens_exceeded warning now explains that a failed call
    is not written back, so the conversation stays pinned and every retry fails
    identically, and it names Compress-ShpChat alongside Clear-ShpChat and
    -History.

  • Get-ShpUsage -Summary's Calls and CostUSD will report different
    numbers for a session in which something failed.
    Calls now counts calls
    attempted; it previously counted records, and only successes were recorded,
    so it meant "calls that succeeded". Read Succeeded for the old number.
    CostUSD now includes the spend of turns that failed after one or more
    billable round-trips, which was previously dropped. Both are corrections
    rather than regressions - that money was really spent, and those calls were
    really made - but a caller reading Calls as a success count must move to
    Succeeded. Nothing changes for a session in which nothing failed.

  • The HTTP retry backoff is now jittered: half the exponential delay plus a
    random amount up to the other half. A purely deterministic backoff
    synchronises under concurrency - several Invoke-ShpBatch workers refused by
    the same 429 would sleep identical durations and re-fire together, recreating
    the burst that caused the refusal. A RetryDelaySec of 0 still yields
    exactly 0, so no existing call path changes.

Fixed

  • An oversized image is now re-encoded to fit instead of failing the call.
    The request-body guard below was correct but unhelpful: an ordinary phone
    photo is already over the 5 MiB ceiling, so -Image refused a perfectly
    normal attachment and told the caller to go and resize it.
    Resolution is the last thing given up, and that order was measured. A
    photographed departure board read correctly at every size from 512px to
    4096px - but a scanned page of 9pt text was refused as illegible below 1024px
    and, at 1568px, returned a confidently wrong file number (4-C 1137/26
    for 4 C 1187/26). A silently wrong answer is worse than a refusal, so JPEG
    quality is reduced at full resolution first and dimensions change only when
    compression alone cannot reach the budget. On the photo that prompted this,
    quality alone freed 35% (3,943,304 to 2,580,566 bytes) with all 4096x3072
    pixels kept.
    A warning always states what it cost, and a dimension change warns separately
    that small text may no longer be legible. Re-encoding needs an in-box image
    codec, so it is Windows-only; elsewhere the call is refused with its sizes
    named, as before.

  • Invoke-Shp -Image no longer accepts a file that is not an image. Any
    path was read and embedded as a base64 data URI, with an unknown extension
    falling back to application/octet-stream - so attaching a .msg, .pdf or
    .docx alongside a screenshot sent a payload no vision model can see, at its
    full base64 weight, and the only symptom was a refusal from the service. The
    extension is now checked against the types a vision model reads (.bmp,
    .gif, .jpeg, .jpg, .png, .webp) and anything else is rejected by
    name before the call. The rejection also says what to do instead, because the
    answer differs by file: a text file needs no attachment at all - naming
    its path in the prompt lets the read_file tool fetch it - while a binary
    document (.msg, .pdf, .docx, .xlsx) has to be converted to text first,
    since read_file reads text and would otherwise hand the model raw bytes.

  • An oversized image is now refused locally instead of as a bare
    413 Request Entity Too Large.
    The ceiling is a gateway limit measured
    live rather than assumed: binary-searched against the real endpoint, a body of
    5,235,612 bytes was accepted and 5,237,612 was refused, putting it at exactly
    5 MiB. Because base64 costs 4 bytes per 3, an image much over ~3.5 MB on disk
    cannot fit, which is easy to hit with an unedited phone photo. -Image now
    sums the encoded size of every local attachment - an http(s) URL is exempt,
    being sent by reference - and throws before the round-trip with each file's
    size on disk, its encoded size, the budget, and what to do about it.

  • A 413 that does survive now says how large the request was and how large
    it may be.
    The gateway's body is the bare phrase Request Entity Too Large,
    which names neither figure, and the model never saw the request - so the
    existing context-window advice does not apply and no token count explains it.
    Both HTTP senders now append the byte count actually sent and the ceiling. The
    other 413, model_max_prompt_tokens_exceeded, carries its own JSON error
    object and is left untouched.

    Turn resolved its short-lived Copilot session token once, before the loop, and
    then reused that one credential for every iteration - so an agentic Turn that
    ran longer than the token's lifetime failed part-way through (observed at
    iteration 41) with IDE token expired: unauthorized: token expired. The
    sign-in was never the problem, and resending the prompt was not a recovery: it
    started a brand-new Turn at iteration 1 and threw away all the completed work.
    Invoke-Shp now re-resolves the session token before every iteration, which
    costs nothing until it is needed because a still-valid token is served from
    the in-memory cache without a round-trip. A 401 that still slips through is
    recovered once by exchanging a fresh token and retrying the same iteration -
    the answer is not duplicated, the iteration counter does not advance, and
    usage is not double-counted. A revoked or unauthorized GitHub OAuth token
    still fails, once, with a message naming Initialize-Shp, and an alternative
    backend authenticating with your own API key never has its Authorization
    header rewritten.

  • The session-token safety margin is now 300 seconds instead of 60, so a token
    is never handed to a request it cannot outlive. A single tool iteration can
    take minutes for a reasoning model working through a large tool result, so a
    token with 61 seconds left used to be served and then die on the next request.
    The cost is at most one extra token exchange per five minutes of an otherwise
    cached session.

  • run_command now runs the command it was given. Every unescaped double
    quote was being stripped before the child shell saw it, so the model's
    Write-Output "set to $env:X" ran as four bare arguments and
    --pretty=format:"%h %s" reached git as two. The command line was handed to
    Start-Process -ArgumentList as one array element; PowerShell joins that array
    into a single string, and the native argument parser then eats the quotes. It
    failed plausibly - the first example returned exit code 0 with output that
    looks like output - and the returned envelope echoed the command that was sent
    rather than the one that ran, so every transcript and CommandsRun entry
    recorded a command that never executed. The child is now started through
    System.Diagnostics.ProcessStartInfo with an ArgumentList, which quotes each
    element the way the platform requires, so the command is passed through
    verbatim and what the envelope reports is what ran. Single-quoted commands,
    the JSON envelope, MaxChars capping, the timeout envelope, the process-tree
    kill, the default working directory and the workingDirectory override are all
    unchanged, as is the environment the child inherits.

  • Streamed Invoke-Shp requests now use the same retry wrapper as buffered
    requests. HTTP 429/5xx responses are bounded by MaxRetryCount, and a true
    no-response transport failure uses NetworkOutageToleranceSec. The classifier
    reads the streaming sender's structured StatusCode before its bare
    HttpRequestException type, so a permanent 400 fails after one attempt instead
    of burning the network-outage budget. If reading an error body itself fails,
    the known status is preserved and both response and request are still disposed.

  • Invoke-Shp -History @() now genuinely starts from nothing. -History is
    documented as stateless, but an empty array is falsy and the check tested
    truthiness, so an explicitly empty history silently fell through to seeding
    the call from the session conversation - the opposite of what was asked for.
    Binding is now the test, matching the module's rule for every other optional
    value whose type has a meaningful default.

  • The streaming sender now carries the same structured error as the buffered
    one. Streaming is the Invoke-Shp default, so this was the common path on
    which a caller still had to match substrings in an exception message. A
    non-success status from Invoke-ShpStreamRequest now raises an ErrorRecord
    with the body on ErrorDetails.Message and a ShellPilot.HttpErrorDetail on
    TargetObject, which is also the only programmatic home the status has on
    that path - HttpRequestException carries no response. The exception type and
    the message wording are unchanged.

  • A failed buffered request now hands the service's error to the caller as
    data instead of only as text. Invoke-ShpHttpRequest raises a built error
    record rather than a bare exception, so $_.ErrorDetails.Message carries the
    response body the way Invoke-RestMethod does, and $_.TargetObject carries
    a ShellPilot.HttpErrorDetail with StatusCode, the service's own ErrorCode
    and Param, its Message, the whole raw Body and the RequestUri - so a
    script can branch on the error code without matching substrings in an
    exception string. Invoke-Shp's own catch block has always read
    $_.ErrorDetails.Message first, a member that until now was never populated
    and silently fell through on every failure. The exception object and the live
    response it carries are unchanged, so the 429/5xx retry and the network-outage
    budget classify a failure exactly as before, and the failure is now identified
    by the stable ShpHttpRequestFailed,Invoke-ShpHttpRequest error id instead of
    by an error id that repeated the whole message. Because ErrorDetails.Message
    replaces the record's display text, it is capped like the exception message;
    TargetObject.Body keeps the body whole. This covers every buffered call -
    Invoke-Shp -DisableStreaming, every /responses turn, /models, the token
    exchange and embeddings; streamed requests expose the same contract through
    their sender.

  • The streaming sender no longer quotes an unbounded error body. A non-success
    status from Invoke-ShpStreamRequest put the service's whole response into the
    exception message, so a 5xx from an intermediate proxy could turn an entire
    HTML page into an error message. It is now capped at the same 2000 characters
    with the same ...[truncated, original N chars] marker as the buffered
    sender. The wording is deliberately unchanged: that exception carries no
    response, so its URI and status exist only in the text.

  • A failed non-streaming request now reports what the service objected to. The
    buffered sender read the error response body and then discarded it, so a
    rejected request surfaced only as
    Response status code does not indicate success: 400 (Bad Request). The
    service's own explanation is now quoted after the status line as
    Response body: ..., capped at 2000 characters with the usual
    ...[truncated, original N chars] marker. The exception type and the response
    it carries are unchanged, so the 429/5xx retry and the network-outage budget
    still classify a failure exactly as before. This also revives the API-shape
    fallbacks in Invoke-Shp, which match the error text for store,
    unsupported_api_for_model, invalid_request_body and reasoning /
    summary: they were unreachable whenever the reply was buffered, so for
    example Invoke-Shp -Model gpt-5.5 -DisableStreaming failed on a bare 400
    instead of falling back to /responses the way the streaming path already did.

  • Invoke-Shp now changes the API shape at most once per turn. Both shape
    fallbacks rewind the iteration counter before retrying, so -MaxToolIterations
    never bounded them, and a service that refused /chat/completions and
    /responses with the same code could bounce a turn between the two
    indefinitely - one billable request per hop. The first switch is still made;
    the second refusal now surfaces as an error.

  • Corrected the GPT-5.6 rates against the published GitHub Copilot billing
    table. gpt-5.6-luna was charged 5x its real rate (now 0.20/0.02/0.25/1.20
    per 1M tokens) and gpt-5.6-terra 25% over (now 2.00/0.20/2.50/12.00); all
    three GPT-5.6 models bill a cache write that the table previously recorded as
    $null, so cache-write tokens were costed as free.

  • Added the missing grok-4.5 rate, so xAI calls are no longer unpriced.

  • The fetch_url tool no longer reaches private networks. Every URL, including
    each redirect target, is checked before the request: only http and https
    are allowed, and host names must resolve to publicly routable addresses.
    Loopback, link-local (including the 169.254.169.254 cloud metadata address),
    RFC 1918, carrier-grade NAT, 0.0.0.0/8, multicast and their IPv6 equivalents
    are refused, as are IPv4-mapped forms and names that fail to resolve.
    Redirects are followed manually, up to five hops, so a public URL can no
    longer bounce the model into the host's own network.

  • Corrected the gpt-5.6 rates, which shipped as placeholders. gpt-5.6-luna
    was charged five times its real rate (now 1.00 / 0.10 / 6.00 USD per million
    input / cached-input / output tokens) and gpt-5.6-terra twice
    (now 2.50 / 0.25 / 15.00). gpt-5.6-sol was already correct.

  • Cost is now calculated per round-trip instead of on the turn totals. A model's
    long-context rate is selected by a single request's input size, so a turn made
    of several smaller round-trips is no longer at risk of being priced as one
    oversized request, and a genuinely oversized request is no longer under-priced
    at the default rate. Added the published thresholds and long-context rates for
    gpt-5.4, gpt-5.5, gpt-5.6-luna, gpt-5.6-sol, gpt-5.6-terra and
    gemini-3.1-pro.

  • Added the missing price-table entries for models the service advertises:
    gemini-3-flash-preview, gemini-3.1-pro-preview, gemini-3.6-flash and
    mai-code-1-flash-picker, which all reported empty cost and credit fields.
    Also added published rates for claude-fable-5, claude-opus-4.8-fast and
    kimi-k2.7-code.

  • Request bodies are now serialised with a stable key order. PowerShell
    hashtables have no defined enumeration order and .NET randomises string
    hashing per process, so the same payload could serialise differently between
    runs and defeat backend prompt caching.

  • Invoke-Shp and Get-ShpCostEstimate now report CostUSD and Credits for
    claude-opus-5 and claude-sonnet-5. Neither model had an entry in
    data/PriceTable.psd1, and the price lookup matches the model id exactly, so
    every call using them returned empty cost, credit and cost-breakdown fields.
    The rates are the published ones: Opus 5 at 5.00 / 0.50 / 6.25 / 25.00 USD per
    million input / cached-input / cache-write / output tokens, and Sonnet 5 at
    its introductory 2.00 / 0.20 / 2.50 / 10.00 (the standard
    3.00 / 0.30 / 3.75 / 15.00 takes effect on 2026-09-01).