Skip to content

Workspace-Controlled Hooks and Custom Tools Execute Without Trust Confirmation #154

Description

@glmgbj233

Summary

Dirac automatically consumes executable workspace configuration from a repository without a workspace-trust decision, first-use confirmation, or content-change confirmation. We found two deterministic paths that do not depend on prompt injection, model behavior, or model-tool approval:

  1. A committed executable .diracrules/hooks/TaskStart runs when a normal task starts while the default hooks setting is enabled.
  2. A committed .dirac/tools/<name>/tool.ts is transpiled and dynamically imported during ordinary workspace UI-state refresh before the user-tool disabled-by-default check is applied, so module top-level code executes even when the tool remains disabled.

Both behaviors were reproduced with harmless marker files at 55629b5, release v0.4.33 (626448e), and current upstream master (a9c7a3c) on 2026-08-07.

Impact and Preconditions

The affected code can execute JavaScript or launch a child process in the Dirac extension host context, with the privileges and environment of the local user.

The hook path is not triggered by opening a directory alone. The minimum trigger is opening/selecting the repository as a Dirac workspace and starting a normal task while hooks remain enabled. The custom-tool path has a lower trigger: extension activation with a workspace root available and normal workspace state publication. The victim does not need to enable or invoke the custom tool.

Finding 1: TaskStart Workspace Hook

Source-to-sink evidence

  • src/core/storage/hooksStorage.ts:21-48 derives .diracrules/hooks from persisted workspace roots.
  • src/core/hooks/HookRegistry.ts:34-74 discovers an executable file named after the lifecycle event; Windows accepts TaskStart.ps1.
  • src/core/task/LifecycleManager.ts:116-139 calls executeHook({ hookName: "TaskStart" }) before the agent loop.
  • src/core/hooks/hook-factory.ts:293-331 creates the runner and assigns the workspace cwd.
  • src/core/hooks/HookProcess.ts:95-106 passes the discovered path to child_process.spawn().
  • src/shared/storage/state-keys.ts:222 defaults hooksEnabled to true, and src/core/hooks/hooks-utils.ts:6-7 treats an unset value as enabled.

There is no workspace-trust, hook digest, first-use approval, or changed-content confirmation between discovery and spawn().

Reproduction

In a disposable repository, create this executable file:

mkdir -p .diracrules/hooks
cat > .diracrules/hooks/TaskStart <<'EOF'
#!/usr/bin/env node
const fs = require("node:fs");
fs.writeFileSync(
  ".dirac-taskstart-marker.json",
  JSON.stringify({ hook: "TaskStart", cwd: process.cwd(), executed: true })
);
process.stdout.write(JSON.stringify({ cancel: false }));
EOF
chmod +x .diracrules/hooks/TaskStart

Open/select the repository in Dirac and start a normal new task. Expected result: .dirac-taskstart-marker.json is created with "executed": true and the repository path as cwd. Repeat the same task-start operation in a control repository without .diracrules/hooks/TaskStart; the marker is not created.

Finding 2: Disabled Workspace Custom Tool Is Imported

Source-to-sink evidence

  • src/core/task/tools/discovery/ToolDiscoveryService.ts:61-101 scans .dirac/tools and loads manifest-backed tools.
  • src/core/task/tools/registry/refreshToolRegistry.ts:19-42 scans workspace tools during a user-tool refresh.
  • src/core/task/tools/discovery/UserToolLoader.ts:41-81 reads, compiles, imports, and only then validates module exports.
  • src/core/task/tools/discovery/UserToolLoader.ts:126-179 calls ts.transpileModule() and evaluates the cached file through dynamic import().
  • src/core/controller/ui/assembleToolState.ts:7-22 requests the refresh with includeUserTools: true.
  • src/core/controller/index.ts:101-104 requests state publication after workspace initialization.
  • At 55629b5, src/core/task/tools/registry/ToolRegistry.ts:119-126 returns false for a non-builtin tool without an explicit override, but only after discovery/import. Latest master retains the same ordering at UserToolLoader.ts:41-83 and ToolRegistry.ts:215-224.

Reproduction

In a disposable repository, create this valid workspace tool:

mkdir -p .dirac/tools/marker_tool
cat > .dirac/tools/marker_tool/dirac-tool.json <<'EOF'
{
  "schemaVersion": 1,
  "id": "marker_tool",
  "name": "marker_tool",
  "scope": "workspace",
  "entry": "tool.ts",
  "createdBy": "dirac"
}
EOF
cat > .dirac/tools/marker_tool/tool.ts <<'EOF'
import { writeFileSync } from "node:fs";

writeFileSync(
  ".dirac-tool-import-marker.json",
  JSON.stringify({ phase: "module-top-level", imported: true })
);

export const spec = {
  id: "marker_tool",
  name: "marker_tool",
  description: "A harmless marker tool"
};

export function create() {
  return {
    spec() { return spec; },
    supportedSurfaces() { return ["all"]; },
    async processCall() { return "ok"; }
  };
}
EOF

Open the repository in a VS Code window with Dirac installed and allow normal workspace initialization/state publication to complete. Expected result: .dirac-tool-import-marker.json is created with "phase": "module-top-level", even though marker_tool remains disabled by default and is never invoked. The same initialization on a control repository without .dirac/tools/marker_tool does not create the marker.

Dynamic Verification

The marker harness in poc/dirac-workspace-config-dynamic-repro/ ran the real HookDiscoveryCache, HookRegistry, HookFactory, HookProcess, refreshToolRegistryForWorkspace, ToolDiscoveryService, UserToolLoader, and ToolRegistry in temporary workspaces. It produced the following for all three checked revisions:

hook.markerBefore = false
hook.markerAfter.hook = "TaskStart"
tool.markerBefore = false
tool.markerAfter.phase = "module-top-level"
tool.toolRegistered = true
tool.toolEnabledByDefault = false

The harness used only temporary marker files, did not read credentials, did not make network requests, and did not launch the full VS Code UI or contact a provider. The configuration reproductions above are the minimal product-level triggers; the harness provides an isolated source-level confirmation with no user state.

Suggested Fix

For workspace hooks, require explicit trust before discovery reaches execution, bind approval to the canonical workspace, hook path, resolved target, and content digest, and repeat the check immediately before spawn().

For workspace custom tools, parse only declarative metadata while the tool is disabled. Do not transpile or dynamically import tool.ts until the user explicitly enables that specific workspace tool. Re-prompt when the manifest, source content, or symlink target changes.

Explicitly setting the global hooks setting to false prevents the hook path, but it is an opt-out rather than a default trust boundary. The custom-tool disabled state prevents later model exposure/invocation but does not protect the earlier module import.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions