Skip to content

customPoliciesEnabled: false is a silent no-op — never propagated by readMergedHooksConfig #590

Description

@chhhee10

Summary

customPoliciesEnabled: false does not disable convention-discovered custom policies. They load and enforce regardless. The failproofai config wizard toggle that writes this value therefore has no effect on enforcement.

Cause

readMergedHooksConfig hand-builds its return object from four keys and omits customPoliciesEnabled:

// src/hooks/hooks-config.ts:96-101
return {
  enabledPolicies: [...enabledSet],
  ...(Object.keys(mergedParams).length > 0 ? { policyParams: mergedParams } : {}),
  ...(customPoliciesPath !== undefined ? { customPoliciesPath } : {}),
  ...(llm !== undefined ? { llm } : {}),
};

HooksConfig declares the field (policy-types.ts:95), and the read side is wired up correctly:

  • handler.ts:248customPoliciesEnabled: config.customPoliciesEnabled — always undefined
  • custom-hooks-loader.ts:202const conventionEnabled = opts?.customPoliciesEnabled !== falseundefined !== false is true

So the disable branch at custom-hooks-loader.ts:260 is unreachable in practice.

Reproduction

T=$(mktemp -d); mkdir -p $T/.failproofai/policies
cat > $T/.failproofai/policies-config.json <<'JSON'
{ "enabledPolicies": [], "customPoliciesEnabled": false }
JSON
cat > $T/.failproofai/policies/canary-policies.mjs <<'JS'
import { customPolicies, deny } from "failproofai";
customPolicies.add({
  name: "canary", description: "fires if convention policies load",
  match: { events: ["PreToolUse"] },
  fn: async () => deny("CANARY FIRED"),
});
JS
echo '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"echo hi"},"session_id":"t","transcript_path":"/dev/null","cwd":"'"$T"'"}' \
  | HOME=$T npx -y failproofai --hook PreToolUse

Expected: no output (policy disabled). Actual: permissionDecision: "deny" with CANARY FIRED.

Impact

Two directions, both bad:

  • A user who turns custom policies off via failproofai config still has them enforcing, with no indication.
  • Anyone reading the config reasonably concludes policies are inert when they are live. This misdiagnosis is easy to make and hard to catch, since the flag is only ever written explicitly as false, which reads as deliberate.

configure-wizard.ts:492 reads the value back via readHooksConfig() (raw, global scope) rather than the merged reader, so the wizard's own round-trip looks consistent while enforcement ignores it.

Fix

Propagate the key, first scope that defines it winning, consistent with customPoliciesPath and llm:

const customPoliciesEnabled =
  project.customPoliciesEnabled ?? local.customPoliciesEnabled ?? global_.customPoliciesEnabled;

...and spread it into the return when defined. Worth a regression test in __tests__/ asserting a policy does not fire when the flag is false — this is exactly the silent-failure class findSkippedPolicyFiles was added to eliminate.

Found while blind-testing a policy-authoring skill: an agent reading the config concluded the repo's three policy files were inert when they were in fact enforcing.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions