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:248 → customPoliciesEnabled: config.customPoliciesEnabled — always undefined
custom-hooks-loader.ts:202 → const conventionEnabled = opts?.customPoliciesEnabled !== false — undefined !== 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.
Summary
customPoliciesEnabled: falsedoes not disable convention-discovered custom policies. They load and enforce regardless. Thefailproofai configwizard toggle that writes this value therefore has no effect on enforcement.Cause
readMergedHooksConfighand-builds its return object from four keys and omitscustomPoliciesEnabled:HooksConfigdeclares the field (policy-types.ts:95), and the read side is wired up correctly:handler.ts:248→customPoliciesEnabled: config.customPoliciesEnabled— alwaysundefinedcustom-hooks-loader.ts:202→const conventionEnabled = opts?.customPoliciesEnabled !== false—undefined !== falseistrueSo the disable branch at
custom-hooks-loader.ts:260is unreachable in practice.Reproduction
Expected: no output (policy disabled). Actual:
permissionDecision: "deny"withCANARY FIRED.Impact
Two directions, both bad:
failproofai configstill has them enforcing, with no indication.false, which reads as deliberate.configure-wizard.ts:492reads the value back viareadHooksConfig()(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
customPoliciesPathandllm:...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 classfindSkippedPolicyFileswas 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.