Short description
The AFT Pi extension automatically executes a repository-controlled node_modules/.bin/ruff with --version during eager bridge startup when .cortexkit/aft.jsonc selects ruff as the Python formatter. This happens before any model request, tool call, edit, or formatter approval.
What happened?
Expected behavior
Project configuration should not cause an executable from an untrusted repository to run merely because the user starts Pi with the AFT extension enabled. A formatter availability check should either use a trusted/user-selected tool, remain non-executable for untrusted project configuration, or require a clear first-use approval that identifies the resolved executable and arguments.
Observed behavior
On AFT main revision 5799f36c16e6ed5327eb1f849fd336ae3985d8f0, the following repository-controlled inputs are enough to start a local process:
.cortexkit/aft.jsonc
src/probe.py
node_modules/.bin/ruff
The project config sets formatter.python to ruff. When Pi is started from that repository with @cortexkit/aft-pi installed, the Pi extension eagerly warms the AFT bridge. AFT configures the project, scans source files, resolves <project>/node_modules/.bin/ruff before PATH, and invokes that file with --version while checking whether the configured formatter is available.
The process starts before a Pi session exists and before any model request or agent tool call. No workspace trust decision, formatter-specific approval, project-config digest, executable-content hash, symlink-target check, or command preview is performed on this path.
This is deterministic project configuration execution, not prompt injection. It is also narrower than "opening a directory in a file manager executes code": the minimum trigger is starting Pi with the AFT Pi extension enabled and the malicious repository as its current working directory.
Impact
The repository-local executable runs with the operating-system privileges of the user running Pi and receives the effective AFT/Pi process environment. A malicious repository can therefore execute arbitrary local code when the victim starts Pi in the repository, subject to the prerequisites below. The demonstrated probe only writes a marker, but the same process boundary could be used to read or modify accessible files, use inherited environment variables, or make network requests.
Preconditions and trigger
The affected user must:
- Have the AFT Pi extension installed or otherwise enabled in Pi.
- Start Pi with the attacker-controlled repository as Pi's current working directory.
- Use a non-home project directory; the extension skips eager warmup when its cwd is the user's home directory.
The repository must contain a Python source file, a project config selecting ruff, and an executable at node_modules/.bin/ruff. The executable can be force-added to Git even when the repository's normal .gitignore ignores node_modules/; AFT does not verify that this path came from a package-manager installation or a lockfile.
No model provider request, prompt, tool invocation, file edit, or explicit formatter action is required after Pi starts.
Reproduction
The following is a benign, isolated reproduction. Run it only in a throwaway checkout and temporary directory.
1. Build the affected revision
git clone https://github.com/cortexkit/aft.git
cd aft
git checkout 5799f36c16e6ed5327eb1f849fd336ae3985d8f0
bun install --frozen-lockfile
bun run build
cargo build --release
Install the Pi extension using the normal Pi workflow if it is not already installed:
pi install npm:@cortexkit/aft-pi
2. Create a throwaway repository
POC_DIR="$(mktemp -d)"
mkdir -p "$POC_DIR/.cortexkit" "$POC_DIR/src" "$POC_DIR/node_modules/.bin"
cat > "$POC_DIR/.cortexkit/aft.jsonc" <<'EOF'
{
"formatter": { "python": "ruff" },
"search_index": false,
"semantic_search": false,
"callgraph_store": false
}
EOF
printf '%s\n' 'print("safe probe")' > "$POC_DIR/src/probe.py"
cat > "$POC_DIR/node_modules/.bin/ruff" <<'EOF'
#!/bin/sh
printf '%s\n' 'AFT_POC_RUFF_EXECUTED' > "$(dirname "$0")/../../aft-poc-marker.txt"
printf '%s\n' 'ruff 0.1.2'
EOF
chmod 755 "$POC_DIR/node_modules/.bin/ruff"
git -C "$POC_DIR" init
git -C "$POC_DIR" add .cortexkit/aft.jsonc src/probe.py
git -C "$POC_DIR" add -f node_modules/.bin/ruff
git -C "$POC_DIR" commit -m 'formatter probe fixture'
The shim writes aft-poc-marker.txt in the temporary repository and prints a valid minimum Ruff version. It does not read credentials, access the network, or invoke a model.
3. Trigger Pi startup
Start Pi with the AFT Pi extension enabled from the temporary repository:
The exact Pi invocation can vary depending on the local extension setup; the important condition is that process.cwd() is POC_DIR and @cortexkit/aft-pi is loaded. Pi may be exited immediately after startup; no prompt needs to be submitted.
4. Observe the result
test -f "$POC_DIR/aft-poc-marker.txt" && cat "$POC_DIR/aft-poc-marker.txt"
Observed result:
For the negative control, repeat with the same Python file and the same executable shim but remove .cortexkit/aft.jsonc before starting Pi. The marker is not created because the explicit project formatter candidate is absent.
Dynamic verification
This behavior was dynamically reproduced on Linux x64 at 5799f36 in two isolated ways:
BridgePool.getBridge(projectRoot).send("status", {}) cold-started the bridge and created the marker with the project config; the same shim without the project config did not create it.
- The actual
@cortexkit/aft-pi default export was loaded with an isolated fake Pi host and isolated home/config/cache directories. Its eager startup path created AFT_POC_PI_STARTUP_RUFF_EXECUTED before any model or tool call.
The dynamic checks used only temporary workspaces, a local marker, and a harmless ruff 0.1.2 shim. They did not read credentials, make network requests, or invoke a model.
Source-level evidence
The following paths and line locations refer to revision 5799f36c16e6ed5327eb1f849fd336ae3985d8f0:
packages/aft-bridge/src/paths.ts:50-62 discovers the repository-controlled project config at <project>/.cortexkit/aft.jsonc.
packages/pi-plugin/src/index.ts:355-421 loads the current working directory's project config before bridge setup. packages/pi-plugin/src/config.ts:1311-1327 reads the project config into the project tier.
packages/pi-plugin/src/config.ts:1151-1193 and crates/aft/src/config_resolve.rs:615-657 preserve and merge project formatter and checker maps. The project-tier stripping logic removes other security-sensitive fields but does not remove these executable selections.
packages/pi-plugin/src/index.ts:774-836 intentionally performs eager startup for non-home process.cwd(), obtains the project bridge, and sends status without a session_id. This is before any user session or model/tool interaction.
packages/aft-bridge/src/bridge.ts:706-759 auto-sends configure before the first non-version bridge command, so the eager status request configures the project.
crates/aft/src/commands/configure.rs:2180-2223 starts the background source-file walk and missing-tool detection. crates/aft/src/commands/configure.rs:1068-1069 makes an explicit per-language formatter sufficient to trigger the formatter availability check even when format_on_edit is false.
crates/aft/src/format.rs:363-374 resolves <project>/node_modules/.bin/<command> before PATH.
crates/aft/src/format.rs:546-584 resolves ruff and executes the resolved path with --version via Command/output().
The existing AFT binary fingerprint protects the AFT binary itself; it does not fingerprint or approve the repository-local formatter selected by project configuration. Pi's ordinary edit/tool approval is also too late because this probe runs during bridge startup.
Why this is a security boundary issue
Formatter support is expected functionality, and running a user-selected formatter after an explicit edit can be valid. The issue is that the formatter declaration and executable are repository-controlled, while the startup version probe is automatic and has no separate authorization. A repository can replace the expected package-manager shim with arbitrary executable content while retaining the same formatter name.
The project file is not merely prompt text: its value reaches a process-launch sink independently of model behavior. The observed marker is created before any model request or tool call.
Suggested remediation
Please consider the following defenses:
- Do not execute a project-local formatter/checker merely to produce a startup availability warning for an untrusted project.
- Require an explicit first-use approval before probing or invoking a project-selected formatter/checker, and display the canonical executable path plus exact arguments.
- Bind approval to the canonical workspace, user/session, launcher surface, revision or project-config digest, resolved executable identity, symlink target, and executable content hash.
- Revalidate the effective command and executable immediately before every spawn, and invalidate approval after formatter config, symlink, or executable-content changes.
- Prefer user-selected tools or a controlled tool cache. If project-local tools remain supported, make their execution opt-in for untrusted projects.
- Apply the same authorization boundary to the post-edit formatter/checker path; approving a file edit should not silently approve an unrelated executable selected by repository configuration.
Diagnostics
Plugin version
No response
AFT binary version
No response
Platform
linux x64
Log output (optional)
Short description
The AFT Pi extension automatically executes a repository-controlled
node_modules/.bin/ruffwith--versionduring eager bridge startup when.cortexkit/aft.jsoncselectsruffas the Python formatter. This happens before any model request, tool call, edit, or formatter approval.What happened?
Expected behavior
Project configuration should not cause an executable from an untrusted repository to run merely because the user starts Pi with the AFT extension enabled. A formatter availability check should either use a trusted/user-selected tool, remain non-executable for untrusted project configuration, or require a clear first-use approval that identifies the resolved executable and arguments.
Observed behavior
On AFT
mainrevision5799f36c16e6ed5327eb1f849fd336ae3985d8f0, the following repository-controlled inputs are enough to start a local process:The project config sets
formatter.pythontoruff. When Pi is started from that repository with@cortexkit/aft-piinstalled, the Pi extension eagerly warms the AFT bridge. AFT configures the project, scans source files, resolves<project>/node_modules/.bin/ruffbeforePATH, and invokes that file with--versionwhile checking whether the configured formatter is available.The process starts before a Pi session exists and before any model request or agent tool call. No workspace trust decision, formatter-specific approval, project-config digest, executable-content hash, symlink-target check, or command preview is performed on this path.
This is deterministic project configuration execution, not prompt injection. It is also narrower than "opening a directory in a file manager executes code": the minimum trigger is starting Pi with the AFT Pi extension enabled and the malicious repository as its current working directory.
Impact
The repository-local executable runs with the operating-system privileges of the user running Pi and receives the effective AFT/Pi process environment. A malicious repository can therefore execute arbitrary local code when the victim starts Pi in the repository, subject to the prerequisites below. The demonstrated probe only writes a marker, but the same process boundary could be used to read or modify accessible files, use inherited environment variables, or make network requests.
Preconditions and trigger
The affected user must:
The repository must contain a Python source file, a project config selecting
ruff, and an executable atnode_modules/.bin/ruff. The executable can be force-added to Git even when the repository's normal.gitignoreignoresnode_modules/; AFT does not verify that this path came from a package-manager installation or a lockfile.No model provider request, prompt, tool invocation, file edit, or explicit formatter action is required after Pi starts.
Reproduction
The following is a benign, isolated reproduction. Run it only in a throwaway checkout and temporary directory.
1. Build the affected revision
git clone https://github.com/cortexkit/aft.git cd aft git checkout 5799f36c16e6ed5327eb1f849fd336ae3985d8f0 bun install --frozen-lockfile bun run build cargo build --releaseInstall the Pi extension using the normal Pi workflow if it is not already installed:
2. Create a throwaway repository
The shim writes
aft-poc-marker.txtin the temporary repository and prints a valid minimum Ruff version. It does not read credentials, access the network, or invoke a model.3. Trigger Pi startup
Start Pi with the AFT Pi extension enabled from the temporary repository:
The exact Pi invocation can vary depending on the local extension setup; the important condition is that
process.cwd()isPOC_DIRand@cortexkit/aft-piis loaded. Pi may be exited immediately after startup; no prompt needs to be submitted.4. Observe the result
Observed result:
For the negative control, repeat with the same Python file and the same executable shim but remove
.cortexkit/aft.jsoncbefore starting Pi. The marker is not created because the explicit project formatter candidate is absent.Dynamic verification
This behavior was dynamically reproduced on Linux x64 at
5799f36in two isolated ways:BridgePool.getBridge(projectRoot).send("status", {})cold-started the bridge and created the marker with the project config; the same shim without the project config did not create it.@cortexkit/aft-pidefault export was loaded with an isolated fake Pi host and isolated home/config/cache directories. Its eager startup path createdAFT_POC_PI_STARTUP_RUFF_EXECUTEDbefore any model or tool call.The dynamic checks used only temporary workspaces, a local marker, and a harmless
ruff 0.1.2shim. They did not read credentials, make network requests, or invoke a model.Source-level evidence
The following paths and line locations refer to revision
5799f36c16e6ed5327eb1f849fd336ae3985d8f0:packages/aft-bridge/src/paths.ts:50-62discovers the repository-controlled project config at<project>/.cortexkit/aft.jsonc.packages/pi-plugin/src/index.ts:355-421loads the current working directory's project config before bridge setup.packages/pi-plugin/src/config.ts:1311-1327reads the project config into the project tier.packages/pi-plugin/src/config.ts:1151-1193andcrates/aft/src/config_resolve.rs:615-657preserve and merge projectformatterandcheckermaps. The project-tier stripping logic removes other security-sensitive fields but does not remove these executable selections.packages/pi-plugin/src/index.ts:774-836intentionally performs eager startup for non-homeprocess.cwd(), obtains the project bridge, and sendsstatuswithout asession_id. This is before any user session or model/tool interaction.packages/aft-bridge/src/bridge.ts:706-759auto-sendsconfigurebefore the first non-version bridge command, so the eagerstatusrequest configures the project.crates/aft/src/commands/configure.rs:2180-2223starts the background source-file walk and missing-tool detection.crates/aft/src/commands/configure.rs:1068-1069makes an explicit per-language formatter sufficient to trigger the formatter availability check even whenformat_on_editis false.crates/aft/src/format.rs:363-374resolves<project>/node_modules/.bin/<command>beforePATH.crates/aft/src/format.rs:546-584resolvesruffand executes the resolved path with--versionviaCommand/output().The existing AFT binary fingerprint protects the AFT binary itself; it does not fingerprint or approve the repository-local formatter selected by project configuration. Pi's ordinary edit/tool approval is also too late because this probe runs during bridge startup.
Why this is a security boundary issue
Formatter support is expected functionality, and running a user-selected formatter after an explicit edit can be valid. The issue is that the formatter declaration and executable are repository-controlled, while the startup version probe is automatic and has no separate authorization. A repository can replace the expected package-manager shim with arbitrary executable content while retaining the same formatter name.
The project file is not merely prompt text: its value reaches a process-launch sink independently of model behavior. The observed marker is created before any model request or tool call.
Suggested remediation
Please consider the following defenses:
Diagnostics
Plugin version
No response
AFT binary version
No response
Platform
linux x64
Log output (optional)