From 1cc9bd37ed406d2525f425c2a14419a3fc6107a2 Mon Sep 17 00:00:00 2001 From: Thomas Brewer Date: Thu, 19 Feb 2026 07:20:41 -0500 Subject: [PATCH 1/3] chore(openspec): import tasks for prevent-delete-seeded-entities --- .../changes/prevent-delete-seeded-entities/tasks.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openspec/changes/prevent-delete-seeded-entities/tasks.md b/openspec/changes/prevent-delete-seeded-entities/tasks.md index fff2886..fc485be 100644 --- a/openspec/changes/prevent-delete-seeded-entities/tasks.md +++ b/openspec/changes/prevent-delete-seeded-entities/tasks.md @@ -1,13 +1,13 @@ ## 1. Seeded Type Helper -- [ ] 1.1 Add `seeded_type?/1` function to `Brain.Seeds` that checks membership against `entity_types/0` +- [x] 1.1 Add `seeded_type?/1` function to `Brain.Seeds` that checks membership against `entity_types/0` ## 2. Delete Guard -- [ ] 2.1 Add `:protected_entity_type` guard to `DeleteEntity` action — before calling `Brain.delete/3`, check if the entity type is seeded and the operation would remove the type schema; return `{:error, :protected_entity_type}` if so -- [ ] 2.2 Format `:protected_entity_type` error in `DeleteEntity` as `"Cannot delete protected entity type: "` +- [x] 2.1 Add `:protected_entity_type` guard to `DeleteEntity` action — before calling `Brain.delete/3`, check if the entity type is seeded and the operation would remove the type schema; return `{:error, :protected_entity_type}` if so +- [x] 2.2 Format `:protected_entity_type` error in `DeleteEntity` as `"Cannot delete protected entity type: "` ## 3. Tests -- [ ] 3.1 Add unit tests for `Brain.Seeds.seeded_type?/1` — true for all 6 seeded types, false for custom types -- [ ] 3.2 Add action tests for `DeleteEntity` — rejection of seeded type schema deletion, success for normal entity deletion within seeded types, success for custom type deletion +- [x] 3.1 Add unit tests for `Brain.Seeds.seeded_type?/1` — true for all 6 seeded types, false for custom types +- [x] 3.2 Add action tests for `DeleteEntity` — rejection of seeded type schema deletion, success for normal entity deletion within seeded types, success for custom type deletion From b766cc20161a020af73a781344e26fbb6f5e22b1 Mon Sep 17 00:00:00 2001 From: Thomas Brewer Date: Thu, 19 Feb 2026 07:26:07 -0500 Subject: [PATCH 2/3] feat(prevent-delete-seeded-entities): implement changes --- lib/goodwizard/brain/seeds.ex | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/goodwizard/brain/seeds.ex b/lib/goodwizard/brain/seeds.ex index b51916b..398b09e 100644 --- a/lib/goodwizard/brain/seeds.ex +++ b/lib/goodwizard/brain/seeds.ex @@ -27,6 +27,11 @@ defmodule Goodwizard.Brain.Seeds do @spec entity_types() :: [String.t()] def entity_types, do: @entity_types + @doc "Returns true when the given type is one of the seeded entity types." + @spec seeded_type?(term()) :: boolean() + def seeded_type?(type) when is_binary(type), do: type in entity_types() + def seeded_type?(_type), do: false + @doc """ Seeds all default schemas to disk if they don't already exist. From 760d19e2adac8c36562a9ebb90b30173f8e821a9 Mon Sep 17 00:00:00 2001 From: Thomas Brewer Date: Thu, 19 Feb 2026 07:38:32 -0500 Subject: [PATCH 3/3] fix(prevent-delete-seeded-entities): address review findings --- lib/goodwizard/actions/brain/delete_entity.ex | 23 ++++++++--- lib/goodwizard/actions/brain/helpers.ex | 5 +++ .../actions/brain/brain_actions_test.exs | 39 +++++++++++++++++++ test/goodwizard/brain/seeds_test.exs | 13 +++++++ 4 files changed, 75 insertions(+), 5 deletions(-) diff --git a/lib/goodwizard/actions/brain/delete_entity.ex b/lib/goodwizard/actions/brain/delete_entity.ex index 030df39..0c272df 100644 --- a/lib/goodwizard/actions/brain/delete_entity.ex +++ b/lib/goodwizard/actions/brain/delete_entity.ex @@ -16,18 +16,31 @@ defmodule Goodwizard.Actions.Brain.DeleteEntity do ] alias Goodwizard.Actions.Brain.Helpers + alias Goodwizard.Brain.Seeds @impl true @spec run(map(), map()) :: {:ok, map()} | {:error, String.t()} def run(params, context) do workspace = Helpers.workspace(context) - case Goodwizard.Brain.delete(workspace, params.entity_type, params.id) do - :ok -> - {:ok, %{message: "Entity #{params.id} deleted from #{params.entity_type}"}} + if protected_seeded_schema_delete?(params.entity_type, params.id) do + {:error, Helpers.format_error({:protected_entity_type, params.entity_type})} + else + case Goodwizard.Brain.delete(workspace, params.entity_type, params.id) do + :ok -> + {:ok, %{message: "Entity #{params.id} deleted from #{params.entity_type}"}} - {:error, reason} -> - {:error, Helpers.format_error(reason)} + {:error, reason} -> + {:error, Helpers.format_error(reason)} + end end end + + defp protected_seeded_schema_delete?(entity_type, id) do + Seeds.seeded_type?(entity_type) and schema_delete_target?(entity_type, id) + end + + defp schema_delete_target?(entity_type, id) do + id in [entity_type, "schema", "__schema__", "#{entity_type}.json"] + end end diff --git a/lib/goodwizard/actions/brain/helpers.ex b/lib/goodwizard/actions/brain/helpers.ex index ae69971..ab4280a 100644 --- a/lib/goodwizard/actions/brain/helpers.ex +++ b/lib/goodwizard/actions/brain/helpers.ex @@ -123,9 +123,14 @@ defmodule Goodwizard.Actions.Brain.Helpers do def format_error(:path_traversal), do: "Invalid path" def format_error(:body_too_large), do: "Body exceeds maximum size" def format_error(:update_locked), do: "Entity is locked by another operation" + def format_error(:protected_entity_type), do: "Cannot delete protected entity type" def format_error(:enoent), do: "File not found" def format_error(:eacces), do: "Permission denied" def format_error({:duplicate_id, _id}), do: "Duplicate entity ID" + + def format_error({:protected_entity_type, entity_type}), + do: "Cannot delete protected entity type: #{entity_type}" + def format_error({:parse_error, _file, _reason}), do: "Failed to parse entity file" def format_error({:schema_resolution_error, _msg}), do: "Schema resolution failed" diff --git a/test/goodwizard/actions/brain/brain_actions_test.exs b/test/goodwizard/actions/brain/brain_actions_test.exs index 900e428..b11e2ff 100644 --- a/test/goodwizard/actions/brain/brain_actions_test.exs +++ b/test/goodwizard/actions/brain/brain_actions_test.exs @@ -136,6 +136,15 @@ defmodule Goodwizard.Actions.Brain.BrainActionsTest do end describe "DeleteEntity" do + test "rejects deleting a seeded type schema target", %{workspace: workspace, context: context} do + init_brain(context) + + assert {:error, "Cannot delete protected entity type: people"} = + DeleteEntity.run(%{entity_type: "people", id: "__schema__"}, context) + + assert File.exists?(Path.join([workspace, "brain", "schemas", "people.json"])) + end + test "deletes an existing entity", %{context: context} do {:ok, created} = CreateEntity.run(%{entity_type: "people", data: %{"name" => "Jack"}, body: ""}, context) @@ -152,6 +161,36 @@ defmodule Goodwizard.Actions.Brain.BrainActionsTest do init_brain(context) assert {:error, _} = DeleteEntity.run(%{entity_type: "people", id: "nonexistent"}, context) end + + test "deletes an entity in a non-seeded custom type", %{context: context} do + init_brain(context) + + schema = %{ + "type" => "object", + "properties" => %{ + "id" => %{"type" => "string"}, + "title" => %{"type" => "string"}, + "created_at" => %{"type" => "string"}, + "updated_at" => %{"type" => "string"} + }, + "required" => ["id", "title"], + "additionalProperties" => true + } + + assert {:ok, _} = SaveSchema.run(%{entity_type: "widgets", schema: schema}, context) + + assert {:ok, created} = + CreateEntity.run( + %{entity_type: "widgets", data: %{"title" => "Widget 1"}, body: ""}, + context + ) + + assert {:ok, %{message: msg}} = + DeleteEntity.run(%{entity_type: "widgets", id: created.id}, context) + + assert msg =~ "deleted" + assert {:error, _} = ReadEntity.run(%{entity_type: "widgets", id: created.id}, context) + end end describe "ListEntities" do diff --git a/test/goodwizard/brain/seeds_test.exs b/test/goodwizard/brain/seeds_test.exs index 601502c..738934f 100644 --- a/test/goodwizard/brain/seeds_test.exs +++ b/test/goodwizard/brain/seeds_test.exs @@ -206,6 +206,19 @@ defmodule Goodwizard.Brain.SeedsTest do assert length(Seeds.entity_types()) == length(@expected_types) end end + + describe "seeded_type?/1" do + test "returns true for all seeded entity types" do + for type <- Seeds.entity_types() do + assert Seeds.seeded_type?(type), "Expected #{type} to be treated as seeded" + end + end + + test "returns false for non-seeded values" do + refute Seeds.seeded_type?("widgets") + refute Seeds.seeded_type?(nil) + end + end end defmodule Goodwizard.BrainTest do