diff --git a/tests/test_security_guards.py b/tests/test_security_guards.py index 9f9297e..47eacbc 100644 --- a/tests/test_security_guards.py +++ b/tests/test_security_guards.py @@ -92,6 +92,20 @@ class PermissionGuardTests(GuardRepoFixture): self.assertEqual(result.returncode, 1) self.assertIn("invalid JSON", result.stdout) + def test_malformed_settings_shape_fails_cleanly(self): + for data, message in [ + ([], "top-level JSON value must be an object"), + ({"permissions": []}, "permissions must be an object"), + ({"permissions": {"allow": "Bash(*)"}}, "permissions.allow must be a list of strings"), + ({"permissions": {"allow": [1]}}, "permissions.allow must be a list of strings"), + ]: + with self.subTest(data=data): + self.settings.write_text(json.dumps(data)) + result = run_guards(self.root) + self.assertEqual(result.returncode, 1) + self.assertIn(message, result.stdout) + self.assertNotIn("Traceback", result.stderr) + class GitignoreGuardTests(GuardRepoFixture): def test_each_missing_personal_data_rule_fails(self): @@ -134,6 +148,18 @@ class ManifestGuardTests(GuardRepoFixture): self.assertEqual(result.returncode, 1) self.assertIn("trustedDependencies", result.stdout) + def test_malformed_manifest_shape_fails_cleanly(self): + for data, message in [ + ([], "top-level JSON value must be an object"), + ({"name": "example-cli", "scripts": []}, "scripts must be an object"), + ]: + with self.subTest(data=data): + self.write_manifest(data) + result = run_guards(self.root) + self.assertEqual(result.returncode, 1) + self.assertIn(message, result.stdout) + self.assertNotIn("Traceback", result.stderr) + def test_benign_scripts_pass(self): self.write_manifest( {"name": "example-cli", "scripts": {"start": "bun run src/cli.ts", "test": "bun test", "typecheck": "tsc --noEmit"}} diff --git a/tools/security_guards.py b/tools/security_guards.py index 22802f6..4eb783f 100644 --- a/tools/security_guards.py +++ b/tools/security_guards.py @@ -65,7 +65,17 @@ def check_permissions() -> None: except (OSError, json.JSONDecodeError) as exc: errors.append(f".claude/settings.json: unreadable or invalid JSON: {exc}") return - allow = data.get("permissions", {}).get("allow", []) + if not isinstance(data, dict): + errors.append(".claude/settings.json: top-level JSON value must be an object") + return + permissions = data.get("permissions", {}) + if not isinstance(permissions, dict): + errors.append(".claude/settings.json: permissions must be an object") + return + allow = permissions.get("allow", []) + if not isinstance(allow, list) or not all(isinstance(entry, str) for entry in allow): + errors.append(".claude/settings.json: permissions.allow must be a list of strings") + return for entry in allow: if entry not in ALLOWED_PERMISSIONS: errors.append( @@ -110,7 +120,14 @@ def check_package_manifests() -> None: except (OSError, json.JSONDecodeError) as exc: errors.append(f"{relpath}: unreadable or invalid JSON: {exc}") continue - bad = FORBIDDEN_SCRIPTS & set(data.get("scripts", {})) + if not isinstance(data, dict): + errors.append(f"{relpath}: top-level JSON value must be an object") + continue + scripts = data.get("scripts", {}) + if not isinstance(scripts, dict): + errors.append(f"{relpath}: scripts must be an object") + continue + bad = FORBIDDEN_SCRIPTS & set(scripts) if bad: errors.append( f"{relpath}: lifecycle script(s) {sorted(bad)} are forbidden - they execute "