Skip to content

Project-local formatter is executed during Pi extension startup without workspace trust or approval #191

Description

@glmgbj233

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:

  1. Have the AFT Pi extension installed or otherwise enabled in Pi.
  2. Start Pi with the attacker-controlled repository as Pi's current working directory.
  3. 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:

cd "$POC_DIR"
pi

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:

AFT_POC_RUFF_EXECUTED

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:

  1. packages/aft-bridge/src/paths.ts:50-62 discovers the repository-controlled project config at <project>/.cortexkit/aft.jsonc.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. crates/aft/src/format.rs:363-374 resolves <project>/node_modules/.bin/<command> before PATH.
  8. 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:

  1. Do not execute a project-local formatter/checker merely to produce a startup availability warning for an untrusted project.
  2. Require an explicit first-use approval before probing or invoking a project-selected formatter/checker, and display the canonical executable path plus exact arguments.
  3. Bind approval to the canonical workspace, user/session, launcher surface, revision or project-config digest, resolved executable identity, symlink target, and executable content hash.
  4. Revalidate the effective command and executable immediately before every spawn, and invalidate approval after formatter config, symlink, or executable-content changes.
  5. Prefer user-selected tools or a controlled tool cache. If project-local tools remain supported, make their execution opt-in for untrusted projects.
  6. 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions