Skip to content

feat: Phase AI credential proxy for coding agents, harness integrations and relay - #322

Draft
nimish-ks wants to merge 112 commits into
mainfrom
feat--agents
Draft

nimish-ks wants to merge 112 commits into
mainfrom
feat--agents

Conversation

@nimish-ks

Copy link
Copy Markdown
Member

Summary

Phase AI lets coding agents use third-party services (HTTP APIs, AWS, PostgreSQL) through workflow-scoped Connections approved in the Console, without ever receiving a live credential. The agent's process gets decoys; a local proxy swaps them for the real credential on the way out, enforces the approved Connections, redacts responses, and blocks calls that would mint new credentials.

What's in here

  • phase ai enable | disable | status, phase ai run | exec | serve, and a hook-managed proxy daemon (internal/aiproxy, internal/airuntime).
  • Harness integrations: Claude Code and OpenCode receive the Phase environment automatically; Cursor, Codex and Devin run managed commands explicitly through phase ai exec; VS Code Copilot gets the skill.
  • Two skills installed per harness: phase-cli-ai-relay (proxy, Connections, requests) and phase-cli-app-secrets (app secrets; the previous phase-cli skill is migrated in place).
  • phase relay start: a TLS relay for sandboxes that cannot run the CLI. One curl line bootstraps a sandbox, and the relay survives Console session refreshes.
  • Proxy primitives in pkg/proxy: a per-installation CA (90-day validity, renewed automatically, trusted per user with a single OS dialog), the process environment compiler, credential injection, SigV4 re-signing, and the credential-minting block.
  • The Console Agent API client lives in internal/agentsdk; the CLI stays on golang-sdk v2.4.1.
  • phase auth --mode token accepts Agent tokens, which are scoped to phase ai and phase relay.

Environment variables

Read by the CLI: PHASE_AI_ENABLED, CLAUDE_ENV_FILE, PHASE_CURSOR_SESSION_ID; PHASE_SERVICE_TOKEN now also accepts Agent tokens.

Set for managed processes: PHASE_AI_ENABLED, PHASE_AI_CA_BUNDLE, HTTP_PROXY/HTTPS_PROXY, NO_PROXY, NODE_EXTRA_CA_CERTS, AWS_CA_BUNDLE, PGSSLROOTCERT, the replacement-root variables when a complete bundle exists, plus each Connection's decoys and configuration.

Ports and files

Proxy on 127.0.0.1:9543, PostgreSQL adapter on 127.0.0.1:9544 (a relay binds 0.0.0.0 with PostgreSQL on the next port). Runtime state lives under ~/.phase/ai/, configuration in ~/.phase/ai.json. The proxy's control API is documented in src/internal/aiproxy/CONTROL_API.md.

Testing

go test ./... is green and every commit builds on its own. Verified end to end: Claude Code, Cursor, Codex and OpenCode locally; S3 uploads and special-character object keys; the relay from a container with only curl, psql and the AWS CLI, including a forced Console session recycle.

Docs site updates follow in a separate PR.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Deploying phase-cli-install-script with  Cloudflare Pages  Cloudflare Pages

Latest commit: 89841bb
Status: ✅  Deploy successful!
Preview URL: https://2740958f.phase-cli-install-script.pages.dev
Branch Preview URL: https://feat--agents.phase-cli-install-script.pages.dev

View logs

… and guard ECR Public

The minting block resolved one operation per request, so a harmless
X-Amz-Target or query Action could mask the form-body Action a
query-protocol service actually executes. Every selector the request
carries is now checked and a match on any of them refuses the call. ECR
Public GetAuthorizationToken joins the rule table.
… the relay

A request refused for a missing capability is recorded with the method,
host and path the client chose. A credential-shaped value there failed
redaction validation and took the proxy down, which anyone able to reach
the listener could trigger. Those fields are now replaced with fixed
placeholders; proxy-written fields still fail closed.
…ycle errors visible

Requests read off a decrypted CONNECT tunnel carried a background context,
so a stalled upstream round trip held shutdown until it answered. They now
follow the engine lifecycle. When the final audit flush fails, the flush
error is joined to the lifecycle error rather than replacing it, so an
expired or revoked session is still recognised by the recycle paths.
…meter statuses

The adapter dropped the client's startup parameters (options,
application_name, client_encoding, DateStyle) and answered with a made-up
server_version and encoding. It now forwards the client's settings to the
upstream, apart from the managed identity and replication, replays the
server's own ParameterStatus messages, refuses replication connections
explicitly and keeps the backend key hidden.
The spool may re-send a durable event on the next flush tick before its
acknowledgement lands, which the slower Windows runner exposed.
…inting selectors

Request-derived audit fields now follow Console's own limits (method as a
short identifier, host bounded and control-free, path bounded), so a
denial for an unauthenticated request can never be refused by Console and
block later batches until the spool fills. The minting check refuses a
request with a repeated X-Amz-Target instead of ignoring it, and reads any
non-JSON body on a guarded service as a form whatever its Content-Type.
…credential expiry in discover checks

A forwarded client setting the upstream refuses (unknown parameter,
invalid value, privileged option) is now relayed with its SQLSTATE and a
bounded message instead of a generic rejection. The discover-versus-
runtime comparison no longer includes the credential expiry, which rotates
on every refresh for assumed roles. The joined shutdown error is limited
to session lifecycle errors.
…test

The spool may re-send a durable event before its acknowledgement lands,
which the macOS and Windows runners exposed.
@nimish-ks nimish-ks self-assigned this Oct 20, 2026
@nimish-ks
nimish-ks marked this pull request as draft October 20, 2026 12:48
nimish-ks and others added 9 commits October 20, 2026 20:12
The hook daemons cover harnesses on this machine and phase relay start
covers remote sandboxes, both with a session lifecycle that recycles in
place. serve was the last caller of the lifecycle that could not, and its
printed environment handoff duplicated what phase ai run already injects
into its child. The clipboard helpers stay for the relay handoff.
Nothing ever pointed a JVM at the generated PKCS12 file: JAVA_TOOL_OPTIONS
is protected and left to the user, and the relay bootstrap already tells
Java users to import the CA themselves. The store files, their plumbing
through CAPaths and the prepared bundle, and the only third-party module
the branch added go with it.
…brary

crypto/pbkdf2 has been in the standard library since Go 1.24.
…ead of failing enable

Stock installs of every common Linux distribution ship without NSS
certutil, and the trust prompt defaults to yes, so phase ai enable created
the CA and then aborted before installing anything. enable now checks for
the tool first: without it the offer is skipped with the distribution's
package name, an explicit --trust-ca fails with that hint, and the rest of
the setup completes. Command-line tools and SDKs never depended on the
user trust store; it matters for browsers and for Go programs on macOS
and Windows.

CI gains an end-to-end enable job on the three runner OSes and inside
stock Fedora, Debian, Ubuntu, Alpine and Arch containers, covering the
skip, the explicit-flag failure, the interactive prompt and the NSS
trust path once certutil is installed.
Docker's seccomp profile refuses the syscall filter pacman installs for
its download user, so the package install failed before the test ran.
…ust attempts

grep -q under pipefail closed the pipe before the CLI finished writing,
which made the idempotency check fail on the Ubuntu runner. The macOS
and Windows trust attempts always blocked on the consent dialog until
their timeout, so they are gone; the unit tests cover those paths.
…s and report the refresh

The hook daemon recycles its backend session within one refresh interval
of a Connection or credential edit in the Console, but it built a fresh
proxy engine per cycle, so the proxy capability and the memory of earlier
decoy generations were lost: a shell or process started before the edit
failed with an invalid-credential error afterwards even once the edit was
correct. The daemon now serves every cycle on one engine, as the relay
does, and wrapped harness commands recognise a new cycle by its start time.

The hook's status line and phase ai status now say when the session was
refreshed and why, so an agent retries the command that failed around the
change instead of giving up or switching credential sources.
phase ai enable failed on Windows whenever an older Phase AI CA was still
trusted in the current-user Root store:

    inspect and rotate Phase AI CA in Windows current-user Root store:
    exit status 1: Remove-Item : The operation is on user root store and
    UI is not allowed.

The rotation script deleted obsolete roots with Remove-Item on the Cert:
drive, but trustWindows runs PowerShell with -NonInteractive, and Windows
PowerShell's certificate provider refuses every removal from
CurrentUser\Root when -NonInteractive is on its command line.

The PowerShell step is now read-only: it prints PHASE_CA_OBSOLETE with
the SHA-1 thumbprint of each older Phase-owned root, and trustWindows
removes each one with certutil -user -delstore before adding the new CA
with -addstore, as before. Thumbprints must be 40 hex characters, since
-delstore also matches store indexes and common names. The Windows trust
notice now mentions the confirmation shown for each removed root.
nimish-ks and others added 29 commits October 28, 2026 18:48
A session got its lease only when the bounded kick saw the daemon ready.
When startup outlasted both SessionStart's and the first prompt's waits,
the daemon published with no session lease and its lease monitor stopped
it after the idle grace, while that prompt was still running.

The kick now also registers the lease while a live daemon is starting,
bound to the generation in its ownership lock, which is the generation
the daemon's lease monitor counts.
Enable stopped the hook daemon before installing adapters but saved the
new Workflow only afterwards. A prompt in an open session during that
window restarted the daemon on the previous configuration, and heartbeats
then kept it alive because they never compare scope fingerprints.

Enable now holds an OS file lock from before it stops the daemon until it
has saved the configuration, and claiming the runtime lock refuses while
that lock is held. A session's next prompt after enable starts a daemon
on the new configuration. This also keeps a daemon from running the
shared hook executable while enable replaces it.
The OpenCode plugin exports the hook daemon's environment, including
PHASE_AI_ENABLED=1, into its shells. A Claude started from one took that
for an environment from phase ai run: SessionStart installed no loader and
registered no lease, and the heartbeat returned early. Once the daemon was
replaced, Claude kept the old proxy capability, and with the parent's
lease gone the daemon could idle out under it.

The environment the daemon publishes now carries PHASE_AI_HOOK_RUNTIME, a
digest of its proxy URL. The Claude hooks treat an inherited environment
as a launcher's only when that marker is absent or does not match the
inherited proxy, as when phase ai run replaced it; otherwise they install
the loader and lease as usual, and the loader sources the daemon's file
over the inherited values.
The refresh lookup checked only the three records before a recycled
session_ready. With three or more backend retries in between, the
environment_changed record fell out of reach, and the hook notice and
phase ai status described a Console change as a scheduled renewal.

The lookup now walks back past retries (and rejected daemon starts) to
the record that began the recycle.
Windows cannot replace a file while a reader has it open, so readers
polling without pause could keep every save's rename waiting. Four
readers a millisecond apart still catch a truncating write every run.
A session whose hooks ran while an established daemon was recycling saw no
runtime state and a lock older than the startup window, so it was reported
neither ready nor starting and registered no lease. With no other leases,
the daemon could idle out after republishing, during that session's work.

Health inspection now treats a recent RecyclingAt in the lock like a recent
startup, so the kick registers the lease against the lock's generation.
The refresh test counted "guard" across the whole config, so a temporary
directory containing that word made it fail although the user handler was
kept and exactly one guard was installed. It now parses the hooks and
counts handlers that run the rendered guard command.
The plugin's location follows OPENCODE_CONFIG_DIR and XDG_CONFIG_HOME,
and enable, refresh and disable each recomputed it from the current
environment. Run without OpenCode's environment, disable left the plugin
in place, and a later runtime for another harness exported the shared
Phase environment into OpenCode's shells through it. Enabling another
harness likewise installed a second plugin at the default root and left
the real one stale.

ai.json now records each OpenCode plugin path. Enable refreshes the
recorded plugins and adds the current root only when OpenCode itself is
enabled or nothing is recorded yet; disable removes the recorded plugins,
falling back to the current root for configs written by earlier builds.
The default path is made absolute so a relative override is recorded
independently of the working directory.
phase ai exec --connection kept the inherited value of every variable the
selected plan left unchanged from the runtime plan. After a shell sourced
an .env file, ambient PGHOST and PGPASSWORD therefore survived an explicit
selection and psql went around the selected Phase endpoint.

Only CA and trust-store paths keep their inherited form now; credentials,
configuration, adapter endpoints and routing always come from the
selection.
…ed rule

Path-scoped host rules matched the request path with a trailing slash
cleaned off, so a rule for /allowed also bound /allowed/ to the Connection.
S3 treats those as different object keys.

The rule now matches the path as sent, trailing slash included; paths that
cleaning would otherwise change are still refused.
The credential-change guard read every dollar-quoted string as SQL, so a
statement that only selects or stores text such as $$CREATE ROLE example$$
was refused as a credential change.

A dollar-quoted string is now read as code only where it runs: the body of
a DO block or of CREATE FUNCTION or CREATE PROCEDURE. Other strings are
data.
The credential-change guard still let statically written password changes
through. A -- comment ended only at LF, so "-- note\rALTER ROLE ...
PASSWORD" reached the server, which also ends comments at CR. DO blocks
and function bodies written as '...' strings were skipped unread. With
standard_conforming_strings off, a backslash before a quote ends an
ordinary string earlier than the guard assumed, hiding the statement that
followed.

The guard now reads SQL a token at a time as the server's lexer does:
comments end at CR or LF, and the value of a code body is read in any form
(dollar-quoted, '...', E'...' and U&'...' with UESCAPE, including strings
continued across a newline) before it is checked. SQL that contains a
backslash is checked a second time with backslash escapes in ordinary
strings, since the proxy cannot see that setting. Code nested more than
four DO blocks or function bodies deep is refused instead of passing
unread.
A config written before plugin paths were recorded still made disable
and refresh look only at the current environment's OpenCode root. Run
without the OPENCODE_CONFIG_DIR used at install time, disable left the
plugin active and refresh wrote a second one at the default root.

With nothing recorded, both now look for Phase-owned plugins in the
current root and in the root of each recorded skill, falling back to the
current root's path when none is found. Foreign files are never matched.
Enable kept new OpenCode plugin paths in memory and saved them only after
every adapter had been installed. When a later adapter failed, the plugin
stayed installed but unrecorded, and disable left it active.

Enable now adds each plugin path to the saved ai.json before writing the
plugin, and disable removes recorded plugins even when OpenCode is not
among the saved harnesses.
…enCode plugin

Redaction fails closed on an ai.json this build cannot read, and the file may hold a newer CLI's settings, so recording a plugin path now refuses instead of overwriting it.
Enable recorded a plugin path before InstallOpenCodePlugin refused a
foreign file there. Disable then treated that file as a Phase plugin and
failed on every run while the record stayed in ai.json.

The ownership check is now its own function, and enable runs it before
recording the path; the record still precedes any write.
…eSQL token

The credential-change guard split U&"plpgsql" into three tokens, so in
DO LANGUAGE U&"plpgsql" '...' the body no longer followed the language
name and was not read as code. A UESCAPE clause whose operand was an E''
or dollar-quoted string was split off the same way.

U&"..." identifiers are now read as one token like U&'...' strings, and a
UESCAPE clause with any string-constant operand is read with either.
The classifier rebuilt an eval or sh -c script from words that had lost
their quoting. An expansion the outer shell runs first, such as
$'\x3b ...', "$VAR", a glob or ~, could add a separator to the script,
so ordinary work hidden in it ran as an automatically approved Phase
call.

A nested script containing $, a backtick, %, a glob character or ~ is now
treated as opaque and stays ambient, as a script starting with $ already
was.
One phase.invalid URL made the whole call Phase-managed, so further URLs
in it, including transfers after curl --next, ran with the Phase
environment and automatic approval. curl -O also hid the URL after it,
because short options were compared without case.

A curl or wget call is now managed only when every operand is a Phase
target and it reads no config or URL file (curl -K/--config, wget
-i/--input-file and -e/--execute). Short curl options are compared
case-sensitively, and the documented request options (-X, --json,
--proxy, --max-time) keep their values out of the target check. Every
argument is now inspected, so a word that does not parse as a host,
such as ^, is reported as no Phase target instead of panicking.
Treating the & after > or < as descriptor duplication also covered
cmd's ^> and ^<, which are literal characters, so the & after them still
separates commands. A command such as
curl http://phase.invalid/v1/status -o out^>& calc ran calc as part of an
automatically approved Phase call.

The & is now read as part of a redirection only when the > or < before
it is not caret-escaped.
The scope tokenizer read a backslash-escaped quote as POSIX does and a
cmd /c script's single quotes as protective, but cmd and PowerShell parse
neither that way. A command relying on either could smuggle an ordinary
command past the separator scan and be auto-run with the Phase environment.

Flag a backslash-escaped " inside double quotes as ambiguous so the command
cannot be proved plain, and leave a cmd /c script carrying a single quote
ambient. Both now stay unmanaged.
curl and wget read implicit config (~/.curlrc, ~/.wgetrc) before their
arguments, and an inline HOME, CURL_HOME, XDG_CONFIG_HOME, WGETRC or
SYSTEM_WGETRC assignment can point them at a file that adds another transfer.
The argv-only scan had stripped those leading assignments, so such a call was
still promoted and run with the Phase environment.

Detect a config-redirecting assignment ahead of a curl or wget invocation,
including through env and sudo, and keep the call unmanaged.
The curl and wget scan looked at each argument in isolation, so an option
value consumed by an earlier option (curl -H -o file://...) was skipped as if
it were still a value, hiding a second transfer; -- was not honoured; wget
abbreviations such as --inp and --config slipped past the input/config checks;
and a letter inside an attached value (-oKeyData.json, -HX-Api-Key) was read as
a -K/-i config switch, leaving valid Phase calls ambient.

Walk the arguments in order against a small allow-list, consuming each option's
value the way curl and wget do and honouring --. A call is managed only when
every operand and --url is phase.invalid and every option is recognised;
anything else, including -K, -T and unknown options, stays unmanaged.
Harnesses hand the hook the command as the agent typed it, and the skill tells
agents to call curl directly, so the managed path never needs to look inside a
wrapper, interpreter, substitution or quoted script. Trying to made the scan
keep pace with cmd, PowerShell, curl and wget syntax kept leaving gaps: an
empty quoted argument let a later option swallow a real target, a nested
command substitution classified before the Windows-quote check, and a
config-redirecting assignment survived wrapper unwrapping.

Reduce shellCommandRequiresPhase to a single simple command: the client must be
curl, curl.exe or wget as the first word (no leading assignment, no wrapper),
with no separators, pipes, subshells, command substitutions, heredocs or
interpreters, no empty or $/backtick-bearing word, only allow-listed options,
every target phase.invalid, and only the redirections already accepted. The
tokenizer now preserves empty quoted words so they cannot be dropped. The
codex, cursor and devin guards check the mixed-command denial before this
narrower recognition, matching the OpenCode guard, so mixed command lines are
still denied.

Closes the residual HRN-2 (empty quoted argument), HRN-15 (nested marker ahead
of the quote check) and HRN-16 (config env through option-aware wrappers): each
is now simply not a single plain call and stays ambient.
…iptor

The scope tokenizer dropped any all-digit word adjoining > or <, even when
it was quoted or escaped, so an option lost its real value and swallowed the
next operand instead, letting a non-Phase transfer pass as a Phase call.

Mark a word as quoted when a quote or backslash contributes to it, and keep a
quoted word as an argument before a redirection.
PowerShell accepts typographic single and double quotes as string
delimiters, but the scope tokenizer read them as ordinary characters. A
command could close a string with one and run ordinary work that the
tokenizer still saw as part of a quoted header value.

Leave any command carrying one of those quotes ambient.
The plain-call check rejected $ and backtick expansions but not pathname
expansion, so an unquoted *, ? or [ in an option value could expand into
several arguments after classification and add a non-Phase operand.

Record an unquoted, unescaped glob character on each token and leave any
call carrying one ambient. Quoted or escaped glob characters stay managed.
Windows refuses to open ai.json or config.json for the instant a save renames its replacement over the file, so a concurrent read briefly returned nothing. Reads now retry for up to about 100ms on Windows; a missing file is not retried.
feat(ai): accept key-bearing v1 Agent tokens

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants