James Read 3 meses atrás
pai
commit
d0075a7a8d
63 arquivos alterados com 4097 adições e 339 exclusões
  1. 116 0
      .github/SECURITY_ADVISORY_DUPLICATES.md
  2. 5 1
      Makefile
  3. 31 1
      config.yaml
  4. 1 0
      docs/modules/ROOT/nav.adoc
  5. 4 4
      docs/modules/ROOT/pages/action_execution/aftercompletion.adoc
  6. 80 0
      docs/modules/ROOT/pages/args/input_checklist.adoc
  7. 3 3
      docs/modules/ROOT/pages/args/types.adoc
  8. 89 2
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts
  9. 0 0
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js
  10. 15 1
      frontend/resources/vue/ActionButton.vue
  11. 156 0
      frontend/resources/vue/components/ChoiceChecklist.vue
  12. 17 1
      frontend/resources/vue/components/ChoiceCombobox.vue
  13. 181 0
      frontend/resources/vue/components/EntityDefinitionSection.vue
  14. 96 0
      frontend/resources/vue/components/EntityInstancesTable.vue
  15. 65 0
      frontend/resources/vue/components/EntityListFilter.vue
  16. 54 0
      frontend/resources/vue/utils/choiceChecklistHelpers.js
  17. 46 0
      frontend/resources/vue/utils/choiceChecklistHelpers.test.mjs
  18. 9 0
      frontend/resources/vue/utils/entityRoutes.js
  19. 15 0
      frontend/resources/vue/utils/justificationTemplate.js
  20. 3 1
      frontend/resources/vue/utils/needsArgumentForm.js
  21. 21 0
      frontend/resources/vue/utils/prefilledArguments.js
  22. 39 0
      frontend/resources/vue/utils/prefilledArguments.test.mjs
  23. 3 2
      frontend/resources/vue/utils/rerunArguments.js
  24. 2 2
      frontend/resources/vue/utils/rerunArguments.test.mjs
  25. 6 2
      frontend/resources/vue/views/ActionDetailsView.vue
  26. 112 26
      frontend/resources/vue/views/ArgumentForm.vue
  27. 8 41
      frontend/resources/vue/views/EntitiesView.vue
  28. 69 39
      frontend/resources/vue/views/EntityDetailsView.vue
  29. 6 2
      integration-tests/Makefile
  30. 3 3
      integration-tests/tests/checkbox/config.yaml
  31. 178 0
      integration-tests/tests/checklist/checklist.mjs
  32. 40 0
      integration-tests/tests/checklist/config.yaml
  33. 2 0
      integration-tests/tests/checklist/entities/rooms.yaml
  34. 21 1
      proto/olivetin/api/v1/olivetin.proto
  35. 289 0
      scripts/devcheck.py
  36. 177 109
      service/gen/olivetin/api/v1/olivetin.pb.go
  37. 153 49
      service/internal/api/api.go
  38. 154 0
      service/internal/api/api_entities_list.go
  39. 162 0
      service/internal/api/api_entities_list_test.go
  40. 130 0
      service/internal/api/api_entity_related_actions.go
  41. 270 0
      service/internal/api/api_entity_related_actions_test.go
  42. 54 1
      service/internal/api/api_justification.go
  43. 87 10
      service/internal/api/api_justification_test.go
  44. 1 1
      service/internal/api/api_log_arguments.go
  45. 20 2
      service/internal/api/api_log_arguments_test.go
  46. 102 4
      service/internal/api/api_test.go
  47. 32 0
      service/internal/auth/otoauth2/restapi_auth_oauth2.go
  48. 66 0
      service/internal/auth/otoauth2/restapi_auth_oauth2_test.go
  49. 55 0
      service/internal/config/checklist.go
  50. 58 0
      service/internal/config/checklist_test.go
  51. 30 4
      service/internal/config/config.go
  52. 13 0
      service/internal/config/config_reloader.go
  53. 58 0
      service/internal/config/justification_compat_test.go
  54. 105 0
      service/internal/config/sanitize.go
  55. 22 0
      service/internal/config/sanitize_test.go
  56. 182 14
      service/internal/executor/arguments.go
  57. 236 0
      service/internal/executor/arguments_test.go
  58. 52 6
      service/internal/executor/executor.go
  59. 63 0
      service/internal/executor/executor_test.go
  60. 1 1
      service/internal/executor/justification.go
  61. 5 5
      service/internal/executor/justification_test.go
  62. 23 0
      service/internal/executor/log_arguments_test.go
  63. 1 1
      var/macos/config.yaml

+ 116 - 0
.github/SECURITY_ADVISORY_DUPLICATES.md

@@ -0,0 +1,116 @@
+# Security Advisory Duplicates — Maintainer Guide
+
+This document lists known duplicate security advisory clusters for [OliveTin/OliveTin](https://github.com/OliveTin/OliveTin). When triaging new reports, check here and [open advisories](https://github.com/OliveTin/OliveTin/security/advisories) before accepting.
+
+**Duplicate policy:** the earliest reporter on the canonical advisory receives primary credit. Later reporters are credited on the canonical advisory when closed as duplicates. See [SECURITY.md](../SECURITY.md).
+
+## Triage checklist
+
+1. Search open advisories for the same component and attack path.
+2. Match against clusters below.
+3. If duplicate: close the newer advisory, link to canonical, add reporter to canonical credits.
+4. If unique: accept, patch on a private branch, reassess CVSS with OliveTin context (see SECURITY.md — OliveTin is intentional RCE by design).
+5. Merge fix to `next`, publish advisory, credit reporters in advisory body (not commit message).
+
+---
+
+## shellAfterCompleted command injection
+
+**Canonical:** [GHSA-vc6p-m6vx-6cwq](https://github.com/OliveTin/OliveTin/security/advisories/GHSA-vc6p-m6vx-6cwq) — reporter **knight-yagami** (2026-03-04)
+
+Untrusted command output or template variables interpolated into `shellAfterCompleted` and executed via `sh -c`.
+
+| GHSA | Reporter | Status | Notes |
+|------|----------|--------|-------|
+| GHSA-vc6p-m6vx-6cwq | knight-yagami | closed (canonical) | Original report |
+| GHSA-v5gc-hqpq-227p | 0xkakash1 | closed (duplicate) | Output template variant |
+| GHSA-m7wr-wj5j-7459 | Ryu7zz | duplicate | Webhook `exec` → output → `shellAfterCompleted` |
+| GHSA-cjxm-x848-6vmc | Yesuhei | duplicate | Missing shell safety on after-completion |
+| GHSA-j9p9-36jc-2v8w | anushkavirgaonkar | duplicate | Same root cause |
+
+**Fix:** shell-quote `output`/`exitCode` before template render; block `shellAfterCompleted` for webhook-tagged actions.
+
+**CVSS note:** requires admin-configured `shellAfterCompleted` and attacker influence on output — typically PR:H not PR:N.
+
+---
+
+## OAuth2 state map memory exhaustion (DoS)
+
+**Canonical:** [GHSA-xpxj-f2fm-rqch](https://github.com/OliveTin/OliveTin/security/advisories/GHSA-xpxj-f2fm-rqch) — reporter **knight-yagami** (2026-03-04)
+
+Unauthenticated `/oauth/login` grows `registeredStates` without TTL or cap.
+
+| GHSA | Reporter | Status | Notes |
+|------|----------|--------|-------|
+| GHSA-xpxj-f2fm-rqch | knight-yagami | closed (canonical) | Original report |
+| GHSA-cj96-c55v-2f3c | Dredsen | duplicate | Same unbounded map |
+
+**Fix:** TTL sweep (match 15-minute cookie MaxAge), max map size, cleanup on failed callback.
+
+**CVSS note:** unauthenticated DoS — reported 7.5 is appropriate.
+
+---
+
+## URL argument type — unrestricted URI schemes (SSRF / file read)
+
+**Canonical:** [GHSA-45pc-w4ph-hrq4](https://github.com/OliveTin/OliveTin/security/advisories/GHSA-45pc-w4ph-hrq4) — reporter **fg0x0** (2026-03-09)
+
+`url` type accepts `file://`, `gopher://`, etc. Blocked in `shell:` mode but still validated weakly for `exec:` actions.
+
+| GHSA | Reporter | Status | Notes |
+|------|----------|--------|-------|
+| GHSA-45pc-w4ph-hrq4 | fg0x0 | closed (canonical) | Original report |
+| GHSA-cchg-25m4-q6rj | anushkavirgaonkar | duplicate | Same scheme validation gap |
+
+**Fix:** allowlist `http`/`https` in `typeSafetyCheckUrl`.
+
+**CVSS note:** admin must configure `exec:` action passing URL to external tool — PR:H.
+
+---
+
+## Custom `regex:` argument type in shell actions
+
+**Canonical:** [GHSA-xc5w-4v5w-7x65](https://github.com/OliveTin/OliveTin/security/advisories/GHSA-xc5w-4v5w-7x65) — reporter **Ayantaker** (2026-05-06)
+
+`regex:` types not in shell denylist; partial `MatchString` allows injection suffixes.
+
+| GHSA | Reporter | Status | Notes |
+|------|----------|--------|-------|
+| GHSA-xc5w-4v5w-7x65 | Ayantaker | canonical | Missing denylist entry |
+| GHSA-gvxq-7gvp-4ggr | anushkavirgaonkar | duplicate | Unanchored partial match |
+
+**Fix:** deny `regex:` in shell mode; enforce full-string match for custom regex types.
+
+---
+
+## Shell denylist incomplete (post CVE-2026-27626)
+
+**Canonical:** [GHSA-c26w-h42g-jfp9](https://github.com/OliveTin/OliveTin/security/advisories/GHSA-c26w-h42g-jfp9) — reporter **sec-reex** (2026-07-03)
+
+CVE-2026-27626 added `password` to denylist only; `html`, `confirmation`, and choiceless `checkbox` still skip validation and are allowed in `shell:` actions.
+
+**Fix:** extend `checkShellArgumentSafety` denylist.
+
+---
+
+## StartActionAndWait logs ACL bypass
+
+**Canonical:** [GHSA-jm28-2wcr-qf3h](https://github.com/OliveTin/OliveTin/security/advisories/GHSA-jm28-2wcr-qf3h) — reporter **offset** (2026-03-12)
+
+`StartActionAndWait` / `StartActionByGetAndWait` return full log output without `logs` ACL check.
+
+No known duplicates.
+
+**Fix:** apply `isLogEntryAllowed` before returning `LogEntry`.
+
+**CVSS note:** requires authenticated user with `exec` but not `logs` — typically 4.3–5.3 not 6.5.
+
+---
+
+## Easy to confuse (not duplicates)
+
+| Topic | Advisories | Distinction |
+|-------|------------|-------------|
+| OAuth2 state DoS vs OAuth2 auth bypass | GHSA-xpxj vs GHSA-3v7p | DoS fills state map; bypass spoofs `authHttpHeaderUsername` |
+| `shellAfterCompleted` vs direct `shell` injection | GHSA-vc6p vs GHSA-49gm | Second-order via output vs first-order argument injection |
+| `ValidateArgumentType` enumeration | GHSA-f637 vs GHSA-x6q3 | Same issue; GHSA-f637 published |

+ 5 - 1
Makefile

@@ -57,6 +57,7 @@ devrun: compile
 devcontainer: compile podman-image podman-container
 
 webui-dist:
+	$(call delete-files,webui)
 	$(MAKE) -wC frontend dist
 	mv frontend/dist webui
 
@@ -72,4 +73,7 @@ clean:
 config-tool:
 	cd service && go run cmd/config-tool/main.go
 
-.PHONY: proto service windows-resources windows-msi
+devcheck:
+	python3 scripts/devcheck.py $(ARGS)
+
+.PHONY: proto service windows-resources windows-msi devcheck

+ 31 - 1
config.yaml

@@ -133,7 +133,8 @@ actions:
   # Docs: https://docs.olivetin.app/args/input_confirmation.html
   - title: Delete old backups
     icon: ashtonished
-    justification: true
+    # A single space requires justification with no prefilled template (empty disables it).
+    justification: " "
     shell: rm -rf /opt/oliveTinOldBackups/ && sleep 5
     arguments:
       - type: html
@@ -143,6 +144,29 @@ actions:
       - type: confirmation
         title: Are you sure?!
 
+  # Checklist arguments let users pick multiple predefined options. Selected
+  # values are passed to the action as a comma-separated string.
+  #
+  # Docs: https://docs.olivetin.app/args/input_checklist.html
+  - title: Backup selected directories
+    icon: backup
+    shell: 'echo "Backing up: {{ directories }}"'
+    arguments:
+      - name: directories
+        title: Directories to back up
+        type: checklist
+        description: Select one or more directories to include in the backup.
+        choices:
+          - title: Documents
+            value: documents
+          - title: Photos
+            value: photos
+          - title: Music
+            value: music
+          - title: Videos
+            value: videos
+        default: documents,photos
+
   # This is an action that runs a script included with OliveTin, that will
   # download themes. You will still need to set theme "themeName" in your config.
   #
@@ -299,6 +323,12 @@ entities:
   # Docs: https://docs.olivetin.app/entities/intro.html
   - file: entities/servers.yaml
     name: server
+    icon: ssh
+    properties:
+      - name: hostname
+        title: Hostname
+      - name: ip
+        title: IP
 
   - file: entities/containers.json
     name: container

+ 1 - 0
docs/modules/ROOT/nav.adoc

@@ -73,6 +73,7 @@
 ** xref:args/regex.adoc[Input: Regex]
 ** xref:args/password.adoc[Input: Password]
 ** xref:args/input_checkbox.adoc[Input: Checkbox/Boolean]
+** xref:args/input_checklist.adoc[Input: Checklist]
 ** xref:args/input_dropdown.adoc[Input: Dropdown]
 ** xref:args/input_datetime.adoc[Input: Date & Time]
 ** xref:args/input_confirmation.adoc[Input: Confirmation]

+ 4 - 4
docs/modules/ROOT/pages/action_execution/aftercompletion.adoc

@@ -10,13 +10,13 @@ actions:
   - title: Check date and send notification via apprise
     icon: date
     shell: date
-    shellAfterCompleted: "apprise -c /config/apprise.yml -t 'Notification: Backup script completed' -b 'The backup script completed with code {{ exitCode}}. The log is: \n {{ output }} '"
+    shellAfterCompleted: "apprise -c /config/apprise.yml -t 'Notification: Backup script completed' -b \"$(printf 'Backup completed with exit code %s. Log: %s' {{ exitCode }} {{ output }})\""
 ----
 
-When running shellAfterCompleted, you *cannot* use argument values - they are not passed to the command. However the following special arguments are defined; 
+When running shellAfterCompleted, you *cannot* use argument values - they are not passed to the command. However the following special arguments are defined;
 
-* `{{ exitCode }}` - The exit code of the previous shell command
-* `{{ output }}` - The standard output of the previous shell command
+* `{{ exitCode }}` - The exit code of the previous shell command. OliveTin substitutes this with the `EXITCODE` environment variable when running `shellAfterCompleted`, so shell metacharacters in the value cannot break quoting.
+* `{{ output }}` - The standard output of the previous shell command. OliveTin substitutes this with the `OUTPUT` environment variable when running `shellAfterCompleted`, so shell metacharacters in command output cannot be executed. You can also reference `$OUTPUT` directly in your `shellAfterCompleted` command. Do not place these placeholders inside single-quoted shell arguments; single quotes prevent `$OUTPUT` and `$EXITCODE` from expanding after substitution.
 * `{{ .Arguments.ot_executionTrackingId }}` - The unique execution tracking id for this execution (version 3k; in 2k use `{{ ot_executionTrackingId }}`)
 * `{{ .Arguments.ot_username }}` - The username of the user who started the execution (version 3k; in 2k use `{{ ot_username }}`). May be `guest` or `cron` for unauthenticated or automated runs.
 

+ 80 - 0
docs/modules/ROOT/pages/args/input_checklist.adoc

@@ -0,0 +1,80 @@
+[#checklist]
+= Input: Checklist
+
+The `checklist` type argument renders multiple checkboxes from predefined `choices`. Users can select one or more options, and the selected values are passed to your action as a comma-separated string.
+
+[source,yaml]
+----
+actions:
+  - title: Backup selected directories
+    shell: echo "Backing up: {{ directories }}"
+    arguments:
+      - name: directories
+        title: Directories to back up
+        type: checklist
+        choices:
+          - title: Documents
+            value: documents
+          - title: Photos
+            value: photos
+          - title: Music
+            value: music
+        default: documents,photos
+----
+
+When the example above runs with Documents and Photos selected, the shell command becomes:
+
+[source,shell]
+----
+echo "Backing up: documents,photos"
+----
+
+== Select all / Select none
+
+The web interface includes **Select all** and **Select none** controls above the checkbox list.
+
+== Empty selections
+
+If no options are selected, the argument value is an empty string. Use `rejectNull: true` when at least one selection is required.
+
+[source,yaml]
+----
+arguments:
+  - name: directories
+    type: checklist
+    rejectNull: true
+    choices:
+      - value: documents
+      - value: photos
+----
+
+== Choice values
+
+Choice `value` fields must not contain commas, because commas are used to join multiple selections together.
+
+Each `title` is shown in the web interface. If a submitted segment matches a choice `title`, OliveTin maps it to the corresponding `value` before validation, matching the behaviour of xref:args/input_checkbox.adoc[checkbox] arguments with choices.
+
+== Using Entities
+
+Checklist options can be generated from entities, using the same pattern as xref:args/input_dropdown.adoc#args-dropdown-entities[entity-backed dropdowns]. Define one choice template and set `entity` to the entity type name:
+
+[source,yaml]
+----
+actions:
+  - title: Restart selected containers
+    shell: 'docker restart {{ containers }}'
+    arguments:
+      - name: containers
+        title: Containers to restart
+        type: checklist
+        entity: container
+        choices:
+          - value: '{{ container.Names }}'
+            title: '{{ container.Names }}'
+
+entities:
+  - file: entities/containers.json
+    name: container
+----
+
+OliveTin expands the template once per entity instance and renders each result as a checkbox. Selected values are still passed as a comma-separated string.

+ 3 - 3
docs/modules/ROOT/pages/args/types.adoc

@@ -9,9 +9,9 @@ A full list of argument types are below;
 | Type                        | Rendered as                       | Allowed values
 | (default)                   | xref:args/input.adoc[Textbox]           | If a `type:` is not set, and `choices:` is empty, then ascii will be used, and a warning will be logged. It is recommended that you set the type explicitly, rather than relying on defaults.
 | ascii                       | xref:args/input.adoc[Textbox]           | a-z (case insensitive), 0-9, but no spaces or punctuation
-| ascii_identifier            | xref:args/input.adoc[Textbox]           | Like a DNS name, a-Z (case insensitive), 0-9, `-`, `.`, and `_`. 
+| ascii_identifier            | xref:args/input.adoc[Textbox]           | Like a DNS name, a-Z (case insensitive), 0-9, `-`, `.`, and `_`.
 | shell_safe_identifier       | xref:args/input.adoc[Textbox]           | Like an ascii identifier, but also allows `@` and `+`. Useful for shell-safe usernames and email-style identifiers.
-| ascii_sentence              | xref:args/input.adoc[Textbox]           | a-z (case insensitive), 0-9, with spaces, `.` and `,`. 
+| ascii_sentence              | xref:args/input.adoc[Textbox]           | a-z (case insensitive), 0-9, with spaces, `.` and `,`.
 | unicode_identifier          | xref:args/input.adoc[Textbox]           | Like an ascii identifier, but allows unicode characters. This is useful for languages that use non-ascii characters, such as Chinese, Japanese, etc.
 | email                       | xref:args/input.adoc[Textbox]           | An email address.
 | password                    | xref:args/password.adoc[Password]       | A password, which is hidden when typed.
@@ -20,6 +20,7 @@ A full list of argument types are below;
 | int                         | xref:args/input.adoc[Textbox]           | Any number, made up of the characters 0 to 9. Negative numbers are not supported.
 | url                         | xref:args/input.adoc[Textbox]           | A URL (e.g. https://example.com). Accepts any scheme, including `file://` and `ftp://`. See warning below.
 | confirmation                | xref:args/input_confirmation.adoc[Confirmation] | A "hidden" argument that makes the action require a confirmation before launching.
+| checklist                   | xref:args/input_checklist.adoc[Checklist]     | Multiple checkboxes from predefined choices. Selected values are passed as a comma-separated string.
 | n/a, but `choices` used     | xref:args/input_dropdown.adoc[Dropdown]         | A "hidden" argument that makes the action require a confirmation before launching.
 | raw_string_multiline        | xref:args/input_textarea.adoc[Textarea]         | Anything. This is **dangerous**, as effectively people can type anything they like
 |===
@@ -31,4 +32,3 @@ The `url` argument type does not restrict the URL scheme. Users can enter `file:
 
 If your action might be used by untrusted users, validate or filter the URL in your script (e.g. allow only `https://`) before using the value.
 ====
-

+ 89 - 2
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts

@@ -92,9 +92,9 @@ export declare type Action = Message<"olivetin.api.v1.Action"> & {
   execOnWebhooks: ActionWebhookExecHint[];
 
   /**
-   * @generated from field: bool justification = 16;
+   * @generated from field: string justification = 20;
    */
-  justification: boolean;
+  justification: string;
 
   /**
    * @generated from field: bool has_running_instance = 17;
@@ -247,6 +247,27 @@ export declare type ActionArgumentChoice = Message<"olivetin.api.v1.ActionArgume
  */
 export declare const ActionArgumentChoiceSchema: GenMessage<ActionArgumentChoice>;
 
+/**
+ * @generated from message olivetin.api.v1.EntityRelatedAction
+ */
+export declare type EntityRelatedAction = Message<"olivetin.api.v1.EntityRelatedAction"> & {
+  /**
+   * @generated from field: olivetin.api.v1.Action action = 1;
+   */
+  action?: Action | undefined;
+
+  /**
+   * @generated from field: map<string, string> prefilled_arguments = 2;
+   */
+  prefilledArguments: { [key: string]: string };
+};
+
+/**
+ * Describes the message olivetin.api.v1.EntityRelatedAction.
+ * Use `create(EntityRelatedActionSchema)` to create a new message.
+ */
+export declare const EntityRelatedActionSchema: GenMessage<EntityRelatedAction>;
+
 /**
  * @generated from message olivetin.api.v1.Entity
  */
@@ -275,6 +296,16 @@ export declare type Entity = Message<"olivetin.api.v1.Entity"> & {
    * @generated from field: map<string, string> fields = 5;
    */
   fields: { [key: string]: string };
+
+  /**
+   * @generated from field: repeated olivetin.api.v1.EntityRelatedAction related_actions = 6;
+   */
+  relatedActions: EntityRelatedAction[];
+
+  /**
+   * @generated from field: string icon = 7;
+   */
+  icon: string;
 };
 
 /**
@@ -1894,6 +1925,25 @@ export declare const GetActionBindingResponseSchema: GenMessage<GetActionBinding
  * @generated from message olivetin.api.v1.GetEntitiesRequest
  */
 export declare type GetEntitiesRequest = Message<"olivetin.api.v1.GetEntitiesRequest"> & {
+  /**
+   * @generated from field: string entity_type = 1;
+   */
+  entityType: string;
+
+  /**
+   * @generated from field: string filter = 2;
+   */
+  filter: string;
+
+  /**
+   * @generated from field: int32 page = 3;
+   */
+  page: number;
+
+  /**
+   * @generated from field: int32 page_size = 4;
+   */
+  pageSize: number;
 };
 
 /**
@@ -1936,6 +1986,21 @@ export declare type EntityDefinition = Message<"olivetin.api.v1.EntityDefinition
    * @generated from field: repeated string used_on_dashboards = 3;
    */
   usedOnDashboards: string[];
+
+  /**
+   * @generated from field: string icon = 4;
+   */
+  icon: string;
+
+  /**
+   * @generated from field: repeated olivetin.api.v1.EntityProperty properties = 5;
+   */
+  properties: EntityProperty[];
+
+  /**
+   * @generated from field: int32 total_instances = 6;
+   */
+  totalInstances: number;
 };
 
 /**
@@ -1944,6 +2009,27 @@ export declare type EntityDefinition = Message<"olivetin.api.v1.EntityDefinition
  */
 export declare const EntityDefinitionSchema: GenMessage<EntityDefinition>;
 
+/**
+ * @generated from message olivetin.api.v1.EntityProperty
+ */
+export declare type EntityProperty = Message<"olivetin.api.v1.EntityProperty"> & {
+  /**
+   * @generated from field: string name = 1;
+   */
+  name: string;
+
+  /**
+   * @generated from field: string title = 2;
+   */
+  title: string;
+};
+
+/**
+ * Describes the message olivetin.api.v1.EntityProperty.
+ * Use `create(EntityPropertySchema)` to create a new message.
+ */
+export declare const EntityPropertySchema: GenMessage<EntityProperty>;
+
 /**
  * @generated from message olivetin.api.v1.GetEntityRequest
  */
@@ -2194,3 +2280,4 @@ export declare const OliveTinApiService: GenService<{
     output: typeof EntitySchema;
   },
 }>;
+

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js


+ 15 - 1
frontend/resources/vue/ActionButton.vue

@@ -61,6 +61,11 @@ const props = defineProps({
 	type: String,
 	required: false,
 	default: ''
+  },
+  prefilledArguments: {
+	type: Object,
+	required: false,
+	default: () => ({})
   }
 })
 
@@ -248,7 +253,16 @@ async function handleClick() {
 	return
   }
   if (needsArgumentForm(props.actionData)) {
-	router.push(`/actionBinding/${props.actionData.bindingId}/argumentForm`)
+	const bindingId = props.actionData.bindingId
+	const prefilled = props.prefilledArguments || {}
+	if (Object.keys(prefilled).length > 0) {
+	  router.push({
+		path: `/actionBinding/${bindingId}/argumentForm`,
+		state: { prefilledArguments: prefilled }
+	  })
+	} else {
+	  router.push(`/actionBinding/${bindingId}/argumentForm`)
+	}
   } else {
 	await startAction()
   }

+ 156 - 0
frontend/resources/vue/components/ChoiceChecklist.vue

@@ -0,0 +1,156 @@
+<template>
+  <div class="choice-checklist" :id="`${id}-wrapper`">
+    <div class="choice-checklist-controls">
+      <button type="button" class="choice-checklist-control" @click="selectAll">
+        Select all
+      </button>
+      <button type="button" class="choice-checklist-control" @click="selectNone">
+        Select none
+      </button>
+    </div>
+    <fieldset class="choice-checklist-fieldset">
+      <legend class="visually-hidden">{{ label || name }}</legend>
+      <label
+        v-for="(choice, index) in choices"
+        :key="choice.value"
+        class="choice-checklist-item"
+        :for="`${id}-${index}`"
+      >
+        <input
+          :id="`${id}-${index}`"
+          type="checkbox"
+          :checked="isSelected(choice.value)"
+          @change="handleToggle(choice.value)"
+        />
+        <span>{{ choiceLabel(choice) }}</span>
+      </label>
+    </fieldset>
+    <input
+      :id="`${id}-value`"
+      :name="name"
+      type="text"
+      class="visually-hidden choice-checklist-value"
+      :value="modelValue"
+      :required="required"
+      tabindex="-1"
+      aria-hidden="true"
+    />
+  </div>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import {
+  allChoiceValues,
+  choiceLabel,
+  formatChecklistValue,
+  parseChecklistValue,
+  toggleChoice
+} from '../utils/choiceChecklistHelpers.js'
+
+const props = defineProps({
+  id: {
+    type: String,
+    required: true
+  },
+  name: {
+    type: String,
+    required: true
+  },
+  label: {
+    type: String,
+    default: ''
+  },
+  choices: {
+    type: Array,
+    required: true
+  },
+  modelValue: {
+    type: String,
+    default: ''
+  },
+  required: {
+    type: Boolean,
+    default: false
+  }
+})
+
+const emit = defineEmits(['update:modelValue'])
+
+const selectedValues = computed(() => parseChecklistValue(props.modelValue))
+
+function isSelected(value) {
+  return selectedValues.value.includes(value)
+}
+
+function emitSelection(selected) {
+  emit('update:modelValue', formatChecklistValue(selected))
+}
+
+function handleToggle(value) {
+  emitSelection(toggleChoice(selectedValues.value, value))
+}
+
+function selectAll() {
+  emitSelection(allChoiceValues(props.choices))
+}
+
+function selectNone() {
+  emitSelection([])
+}
+</script>
+
+<style scoped>
+.choice-checklist {
+  display: flex;
+  flex-direction: column;
+  gap: 0.5em;
+}
+
+.choice-checklist-controls {
+  display: flex;
+  gap: 0.75em;
+}
+
+.choice-checklist-control {
+  background: none;
+  border: none;
+  color: inherit;
+  cursor: pointer;
+  font: inherit;
+  padding: 0;
+  text-decoration: underline;
+}
+
+.choice-checklist-fieldset {
+  border: none;
+  display: grid;
+  gap: 0.5em 1em;
+  grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
+  margin: 0;
+  padding: 0;
+}
+
+.choice-checklist-item {
+  align-items: center;
+  display: flex;
+  gap: 0.4em;
+  margin: 0;
+}
+
+.choice-checklist-item input[type="checkbox"] {
+  margin: 0;
+}
+
+.visually-hidden {
+  border: 0;
+  clip: rect(0 0 0 0);
+  height: 1px;
+  margin: -1px;
+  overflow: hidden;
+  padding: 0;
+  position: absolute;
+  white-space: nowrap;
+  width: 1px;
+}
+</style>

+ 17 - 1
frontend/resources/vue/components/ChoiceCombobox.vue

@@ -88,6 +88,7 @@ const rootRef = ref(null)
 const searchInputRef = ref(null)
 const isOpen = ref(false)
 const query = ref('')
+const isUserFiltering = ref(false)
 const highlightedIndex = ref(0)
 
 const listboxId = computed(() => `${props.id}-listbox`)
@@ -109,6 +110,10 @@ const placeholderText = computed(() => {
 })
 
 const filteredChoices = computed(() => {
+  if (!isUserFiltering.value) {
+    return props.choices
+  }
+
   const search = query.value.trim().toLowerCase()
   if (!search) {
     return props.choices
@@ -140,14 +145,23 @@ function syncFromModelValue() {
   }
 }
 
+function selectedChoiceIndex(choices) {
+  const index = choices.findIndex(choice => choice.value === props.modelValue)
+  return index >= 0 ? index : 0
+}
+
 function openList() {
   document.dispatchEvent(new CustomEvent(closeOthersEvent, { detail: { id: props.id } }))
+  const wasClosed = !isOpen.value
   isOpen.value = true
-  highlightedIndex.value = 0
+  if (wasClosed) {
+    highlightedIndex.value = selectedChoiceIndex(filteredChoices.value)
+  }
 }
 
 function closeList() {
   isOpen.value = false
+  isUserFiltering.value = false
   syncFromModelValue()
 }
 
@@ -164,12 +178,14 @@ function selectChoice(choice) {
 function handleFocus() {
   if (!isOpen.value) {
     syncFromModelValue()
+    isUserFiltering.value = false
   }
 
   openList()
 }
 
 function handleSearchInput(event) {
+  isUserFiltering.value = true
   query.value = event.target.value
   openList()
   highlightedIndex.value = 0

+ 181 - 0
frontend/resources/vue/components/EntityDefinitionSection.vue

@@ -0,0 +1,181 @@
+<template>
+	<Section :padding="!hasTable">
+		<template #title>
+			<span class="section-title-with-icon">
+				Entity:
+				<ActionIconGlyph v-if="definition.icon" class="entity-title-icon" :glyph="definition.icon" />
+				{{ definition.title }}
+			</span>
+		</template>
+		<template v-if="hasTable" #toolbar>
+			<EntityListFilter v-model="searchText" />
+		</template>
+
+		<p v-if="!hasTable">{{ definition.instances.length }} instances.</p>
+
+		<template v-if="hasTable">
+			<p v-if="tableError" class="table-error padding" role="alert">{{ tableError }}</p>
+			<EntityInstancesTable
+				v-else
+				:instances="tableInstances"
+				:properties="definition.properties"
+				:total-instances="totalInstances"
+				v-model:page="currentPage"
+				v-model:page-size="pageSize"
+			/>
+		</template>
+
+		<ul v-else>
+			<li v-for="inst in definition.instances" :key="inst.uniqueKey">
+				<router-link :to="entityDetailsRoute(inst)">
+					{{ inst.title }}
+				</router-link>
+			</li>
+		</ul>
+
+		<div v-if="usedDashboards.length > 0" :class="{ padding: hasTable }">
+			<h3>Used on Dashboards:</h3>
+			<ul>
+				<li v-for="dash in usedDashboards" :key="dash">
+					<template v-if="isEntityDirectory(dash)">
+						{{ getDashboardTitle(dash) }} <span class="entity-directory-label">[Entity Directory]</span>
+					</template>
+					<router-link v-else-if="!dash.includes('entity:')" :to="{ name: 'Dashboard', params: { title: getDashboardTitle(dash) } }">
+						{{ getDashboardTitle(dash) }}
+					</router-link>
+					<span v-else>{{ dash }}</span>
+				</li>
+			</ul>
+		</div>
+	</Section>
+</template>
+
+<script setup>
+	import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
+	import Section from 'picocrank/vue/components/Section.vue'
+	import ActionIconGlyph from './ActionIconGlyph.vue'
+	import EntityInstancesTable from './EntityInstancesTable.vue'
+	import EntityListFilter from './EntityListFilter.vue'
+	import { entityDetailsRoute } from '../utils/entityRoutes.js'
+
+	const props = defineProps({
+		definition: {
+			type: Object,
+			required: true
+		}
+	})
+
+	const searchText = ref('')
+	const tableInstances = ref([])
+	const totalInstances = ref(0)
+	const currentPage = ref(1)
+	const pageSize = ref(10)
+	const tableError = ref('')
+	let fetchTimer = null
+	let fetchSequence = 0
+
+	const hasTable = computed(() => (props.definition.properties?.length ?? 0) > 0)
+
+	const usedDashboards = computed(() => filteredDashboards(props.definition.usedOnDashboards ?? []))
+
+	watch(searchText, () => {
+		currentPage.value = 1
+		scheduleFetchTableInstances()
+	})
+
+	watch([currentPage, pageSize], () => {
+		scheduleFetchTableInstances()
+	})
+
+	function filteredDashboards(dashboards) {
+		return dashboards.filter(d => d && !d.includes('{{'))
+	}
+
+	function isEntityDirectory(dashboardTitle) {
+		return dashboardTitle.endsWith(' [Entity Directory]')
+	}
+
+	function getDashboardTitle(dashboardTitle) {
+		if (isEntityDirectory(dashboardTitle)) {
+			return dashboardTitle.slice(0, -' [Entity Directory]'.length)
+		}
+		return dashboardTitle
+	}
+
+	function scheduleFetchTableInstances() {
+		if (!hasTable.value) {
+			return
+		}
+
+		if (fetchTimer) {
+			clearTimeout(fetchTimer)
+		}
+
+		fetchTimer = setTimeout(() => {
+			fetchTableInstances()
+		}, 250)
+	}
+
+	async function fetchTableInstances() {
+		if (!hasTable.value) {
+			return
+		}
+
+		const requestId = ++fetchSequence
+		tableError.value = ''
+		try {
+			const response = await window.client.getEntities({
+				entityType: props.definition.title,
+				filter: searchText.value.trim(),
+				page: currentPage.value,
+				pageSize: pageSize.value
+			})
+
+			if (requestId !== fetchSequence) {
+				return
+			}
+
+			const definition = response.entityDefinitions?.find(def => def.title === props.definition.title)
+			tableInstances.value = definition?.instances ?? []
+			totalInstances.value = definition?.totalInstances ?? 0
+		} catch (err) {
+			if (requestId !== fetchSequence) {
+				return
+			}
+
+			console.error('Failed to fetch entity instances:', err)
+			tableError.value = 'Failed to load entity instances.'
+			tableInstances.value = []
+			totalInstances.value = 0
+		}
+	}
+
+	onMounted(() => {
+		if (hasTable.value) {
+			fetchTableInstances()
+		}
+	})
+
+	onBeforeUnmount(() => {
+		if (fetchTimer) {
+			clearTimeout(fetchTimer)
+			fetchTimer = null
+		}
+	})
+</script>
+
+<style scoped>
+.section-title-with-icon {
+	display: inline-flex;
+	align-items: center;
+	gap: 0.5em;
+}
+
+.entity-title-icon {
+	font-size: 1.2em;
+}
+
+.table-error {
+	color: var(--error, #c00);
+}
+</style>

+ 96 - 0
frontend/resources/vue/components/EntityInstancesTable.vue

@@ -0,0 +1,96 @@
+<template>
+	<Table
+		:data="tableRows"
+		:headers="headers"
+		:show-pagination="false"
+	>
+		<template #cell-title="{ row, value }">
+			<router-link :to="entityDetailsRoute(row)">
+				{{ value }}
+			</router-link>
+		</template>
+	</Table>
+
+	<div v-if="totalInstances > 0" class="padding">
+		<Pagination
+			:total="totalInstances"
+			v-model:page="currentPageModel"
+			v-model:page-size="pageSizeModel"
+			item-title="entities"
+		/>
+	</div>
+</template>
+
+<script setup>
+	import { computed } from 'vue'
+	import Table from 'picocrank/vue/components/Table.vue'
+	import Pagination from 'picocrank/vue/components/Pagination.vue'
+	import { entityDetailsRoute } from '../utils/entityRoutes.js'
+
+	const props = defineProps({
+		instances: {
+			type: Array,
+			required: true
+		},
+		properties: {
+			type: Array,
+			required: true
+		},
+		totalInstances: {
+			type: Number,
+			default: 0
+		},
+		page: {
+			type: Number,
+			default: 1
+		},
+		pageSize: {
+			type: Number,
+			default: 10
+		}
+	})
+
+	const emit = defineEmits(['update:page', 'update:pageSize'])
+
+	const headers = computed(() => {
+		const propertyHeaders = props.properties.map(property => ({
+			key: property.name,
+			label: property.title
+		}))
+
+		return [
+			{ key: 'title', label: 'Name' },
+			...propertyHeaders
+		]
+	})
+
+	const tableRows = computed(() =>
+		props.instances.map(instance => ({
+			...instance.fields,
+			title: instance.title,
+			type: instance.type,
+			uniqueKey: instance.uniqueKey
+		}))
+	)
+
+	const currentPageModel = computed({
+		get: () => props.page,
+		set: value => emit('update:page', value)
+	})
+
+	const pageSizeModel = computed({
+		get: () => props.pageSize,
+		set: value => emit('update:pageSize', value)
+	})
+
+</script>
+
+<style scoped>
+a {
+	text-decoration: none;
+}
+
+a:hover {
+	text-decoration: underline;
+}
+</style>

+ 65 - 0
frontend/resources/vue/components/EntityListFilter.vue

@@ -0,0 +1,65 @@
+<template>
+	<label class="input-with-icons">
+		<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true">
+			<path fill="currentColor"
+				d="m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14" />
+		</svg>
+		<input
+			:value="modelValue"
+			aria-label="Filter entities"
+			placeholder="Filter entities..."
+			@input="$emit('update:modelValue', $event.target.value)"
+		/>
+		<button title="Clear search filter" :disabled="!modelValue" @click="$emit('update:modelValue', '')">
+			<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true">
+				<path fill="currentColor"
+					d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z" />
+			</svg>
+		</button>
+	</label>
+</template>
+
+<script setup>
+	defineProps({
+		modelValue: {
+			type: String,
+			default: ''
+		}
+	})
+
+	defineEmits(['update:modelValue'])
+</script>
+
+<style scoped>
+.input-with-icons {
+	display: flex;
+	align-items: center;
+	gap: 0.5rem;
+	padding: 0.5rem;
+	border: 1px solid var(--border-color, #ccc);
+	border-radius: 0.25rem;
+	background: var(--section-background);
+	width: 100%;
+	max-width: 300px;
+}
+
+.input-with-icons input {
+	border: none;
+	outline: none;
+	background: transparent;
+	flex: 1;
+	color: var(--text-primary);
+}
+
+.input-with-icons button {
+	background: none;
+	border: none;
+	cursor: pointer;
+	color: var(--text-secondary);
+}
+
+.input-with-icons button:disabled {
+	opacity: 0.3;
+	cursor: not-allowed;
+}
+</style>

+ 54 - 0
frontend/resources/vue/utils/choiceChecklistHelpers.js

@@ -0,0 +1,54 @@
+function parseLegacyChecklistValue(value) {
+  return value.split(',').map((segment) => segment.trim()).filter((segment) => segment !== '')
+}
+
+export function parseChecklistValue(value) {
+  if (!value || value === '') {
+    return []
+  }
+
+  const trimmed = value.trim()
+  if (trimmed.startsWith('[')) {
+    try {
+      const parsed = JSON.parse(trimmed)
+      if (!Array.isArray(parsed)) {
+        return []
+      }
+
+      return parsed.map((segment) => String(segment).trim()).filter((segment) => segment !== '')
+    } catch {
+      return []
+    }
+  }
+
+  return parseLegacyChecklistValue(value)
+}
+
+export function formatChecklistValue(selected) {
+  if (!Array.isArray(selected) || selected.length === 0) {
+    return ''
+  }
+
+  return JSON.stringify(selected)
+}
+
+export function toggleChoice(selected, value) {
+  const current = Array.isArray(selected) ? [...selected] : []
+  const index = current.indexOf(value)
+
+  if (index === -1) {
+    current.push(value)
+    return current
+  }
+
+  current.splice(index, 1)
+  return current
+}
+
+export function choiceLabel(choice) {
+  return choice.title || choice.value || ''
+}
+
+export function allChoiceValues(choices) {
+  return choices.map((choice) => choice.value)
+}

+ 46 - 0
frontend/resources/vue/utils/choiceChecklistHelpers.test.mjs

@@ -0,0 +1,46 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import {
+  allChoiceValues,
+  choiceLabel,
+  formatChecklistValue,
+  parseChecklistValue,
+  toggleChoice
+} from './choiceChecklistHelpers.js'
+
+const choices = [
+  { title: 'Documents', value: 'documents' },
+  { title: 'Photos', value: 'photos' }
+]
+
+	test('parseChecklistValue parses JSON-encoded values', () => {
+  assert.deepEqual(parseChecklistValue('["documents","photos"]'), ['documents', 'photos'])
+  assert.deepEqual(parseChecklistValue('["kitchen,bedroom","hallway"]'), ['kitchen,bedroom', 'hallway'])
+  assert.deepEqual(parseChecklistValue(''), [])
+})
+
+test('parseChecklistValue accepts legacy comma-delimited values', () => {
+  assert.deepEqual(parseChecklistValue('documents,photos'), ['documents', 'photos'])
+  assert.deepEqual(parseChecklistValue('documents, photos'), ['documents', 'photos'])
+})
+
+test('formatChecklistValue joins selected values as JSON', () => {
+  assert.equal(formatChecklistValue(['documents', 'photos']), '["documents","photos"]')
+  assert.equal(formatChecklistValue(['kitchen,bedroom']), '["kitchen,bedroom"]')
+  assert.equal(formatChecklistValue([]), '')
+})
+
+test('toggleChoice adds and removes values', () => {
+  assert.deepEqual(toggleChoice([], 'documents'), ['documents'])
+  assert.deepEqual(toggleChoice(['documents'], 'photos'), ['documents', 'photos'])
+  assert.deepEqual(toggleChoice(['documents', 'photos'], 'documents'), ['photos'])
+})
+
+test('choiceLabel prefers title over value', () => {
+  assert.equal(choiceLabel(choices[0]), 'Documents')
+  assert.equal(choiceLabel({ value: 'music' }), 'music')
+})
+
+test('allChoiceValues returns every choice value', () => {
+  assert.deepEqual(allChoiceValues(choices), ['documents', 'photos'])
+})

+ 9 - 0
frontend/resources/vue/utils/entityRoutes.js

@@ -0,0 +1,9 @@
+export function entityDetailsRoute (entity) {
+  return {
+    name: 'EntityDetails',
+    params: {
+      entityType: entity.type,
+      entityKey: entity.uniqueKey
+    }
+  }
+}

+ 15 - 0
frontend/resources/vue/utils/justificationTemplate.js

@@ -0,0 +1,15 @@
+export function applyArgumentTemplate(template, args) {
+  if (!template) {
+    return ''
+  }
+
+  return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, name) => args[name] ?? '')
+}
+
+export function actionRequiresJustification(justification) {
+  return (justification ?? '').length > 0
+}
+
+export function actionJustificationTemplate(justification) {
+  return justification ?? ''
+}

+ 3 - 1
frontend/resources/vue/utils/needsArgumentForm.js

@@ -1,3 +1,5 @@
+import { actionRequiresJustification } from './justificationTemplate.js'
+
 export function needsArgumentForm (action) {
-  return (action?.arguments?.length > 0) || action?.justification
+  return (action?.arguments?.length > 0) || actionRequiresJustification(action?.justification)
 }

+ 21 - 0
frontend/resources/vue/utils/prefilledArguments.js

@@ -0,0 +1,21 @@
+export function readPrefilledArgumentsFromNavigation() {
+	const state = window.history.state
+	if (state?.prefilledArguments && typeof state.prefilledArguments === 'object') {
+		return { ...state.prefilledArguments }
+	}
+
+	return {}
+}
+
+export function getInitialArgumentValue(paramName, prefilledArguments = {}) {
+	const safePrefilledArguments = prefilledArguments && typeof prefilledArguments === 'object'
+		? prefilledArguments
+		: {}
+
+	if (Object.prototype.hasOwnProperty.call(safePrefilledArguments, paramName)) {
+		return safePrefilledArguments[paramName]
+	}
+
+	const params = new URLSearchParams(window.location.search)
+	return params.get(paramName)
+}

+ 39 - 0
frontend/resources/vue/utils/prefilledArguments.test.mjs

@@ -0,0 +1,39 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+
+import { getInitialArgumentValue, readPrefilledArgumentsFromNavigation } from './prefilledArguments.js'
+
+test('readPrefilledArgumentsFromNavigation returns navigation state values', () => {
+	const originalState = window.history.state
+	window.history.replaceState({ prefilledArguments: { ansible_host: '10.0.0.1' } }, '')
+
+	assert.deepEqual(readPrefilledArgumentsFromNavigation(), { ansible_host: '10.0.0.1' })
+
+	window.history.replaceState(originalState, '')
+})
+
+test('getInitialArgumentValue prefers navigation state over query params', () => {
+	const originalState = window.history.state
+	const originalSearch = window.location.search
+
+	window.history.replaceState({ prefilledArguments: { ansible_host: '10.0.0.1' } }, '')
+	window.history.replaceState(window.history.state, '', '?ansible_host=10.0.0.2')
+
+	assert.equal(getInitialArgumentValue('ansible_host', readPrefilledArgumentsFromNavigation()), '10.0.0.1')
+
+	window.history.replaceState(originalState, '')
+	window.history.replaceState(window.history.state, '', originalSearch || '/')
+})
+
+test('getInitialArgumentValue falls back to query params when state is absent', () => {
+	const originalState = window.history.state
+	const originalSearch = window.location.search
+
+	window.history.replaceState({}, '')
+	window.history.replaceState(window.history.state, '', '?ansible_host=10.0.0.2')
+
+	assert.equal(getInitialArgumentValue('ansible_host', readPrefilledArgumentsFromNavigation()), '10.0.0.2')
+
+	window.history.replaceState(originalState, '')
+	window.history.replaceState(window.history.state, '', originalSearch || '/')
+})

+ 3 - 2
frontend/resources/vue/utils/rerunArguments.js

@@ -1,4 +1,5 @@
 import { needsArgumentForm } from './needsArgumentForm.js'
+import { actionRequiresJustification } from './justificationTemplate.js'
 
 const nonStorableArgumentTypes = new Set([
   'password',
@@ -17,7 +18,7 @@ export function logEntryArgumentsToStartActionArgs (logEntry) {
 }
 
 export function rerunNeedsArgumentForm (action, logEntry) {
-  if (action?.justification && !logEntry?.justification) {
+  if (actionRequiresJustification(action?.justification) && !logEntry?.justification) {
     return true
   }
 
@@ -60,7 +61,7 @@ export function buildRerunStartActionArgs (bindingId, logEntry, action) {
     arguments: logEntryArgumentsToStartActionArgs(logEntry)
   }
 
-  if (action?.justification && logEntry?.justification) {
+  if (actionRequiresJustification(action?.justification) && logEntry?.justification) {
     startActionArgs.justification = logEntry.justification
   }
 

+ 2 - 2
frontend/resources/vue/utils/rerunArguments.test.mjs

@@ -75,7 +75,7 @@ test('rerunNeedsArgumentForm can start directly when stored args are complete',
 })
 
 test('rerunNeedsArgumentForm opens the form when justification is missing', () => {
-  const action = { justification: true, arguments: [] }
+  const action = { justification: ' ', arguments: [] }
 
   assert.equal(rerunNeedsArgumentForm(action, {}), true)
   assert.equal(
@@ -90,7 +90,7 @@ test('buildRerunStartActionArgs includes stored justification', () => {
       arguments: [{ name: 'host', value: 'db-1' }],
       justification: 'maintenance window'
     }, {
-      justification: true,
+      justification: ' ',
       arguments: [{ name: 'host', type: 'ascii_identifier' }]
     }),
     {

+ 6 - 2
frontend/resources/vue/views/ActionDetailsView.vue

@@ -96,7 +96,9 @@
               <td class="duration">{{ formatExecutionDuration(log) }}</td>
               <td>
                 <router-link :to="`/logs/${log.executionTrackingId}`">
-                  {{ log.executionTrackingId }}
+                  <LogActionTitle :justification="log.justification">
+                    {{ log.executionTrackingId }}
+                  </LogActionTitle>
                 </router-link>
               </td>
               <td class="tags">
@@ -134,6 +136,7 @@ import Section from 'picocrank/vue/components/Section.vue'
 import ActionIconGlyph from '../components/ActionIconGlyph.vue'
 import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
 import ActionGroupLimitsLabel from '../components/ActionGroupLimitsLabel.vue'
+import LogActionTitle from '../components/LogActionTitle.vue'
 import { HugeiconsIcon } from '@hugeicons/vue'
 import { DashboardSquare01Icon, WorkoutRunIcon } from '@hugeicons/core-free-icons'
 import { requestReconnectNow } from '../../../js/websocket.js'
@@ -162,7 +165,8 @@ const filteredLogs = computed(() => {
   const searchLower = searchText.value.toLowerCase()
   return logs.value.filter(log =>
     log.executionTrackingId.toLowerCase().includes(searchLower) ||
-    log.actionTitle.toLowerCase().includes(searchLower)
+    log.actionTitle.toLowerCase().includes(searchLower) ||
+    (log.justification || '').toLowerCase().includes(searchLower)
   )
 })
 

+ 112 - 26
frontend/resources/vue/views/ArgumentForm.vue

@@ -1,16 +1,30 @@
 <template>
   <section id = "argument-popup">
     <div class="section-header">
-      <h2>Start action: {{ title }}</h2>
+      <h2>
+        <span class="section-title-with-icon">
+          Start action:
+          <router-link
+            :to="`/action/${bindingId}`"
+            class="action-details-title-link"
+          >
+            <ActionIconGlyph v-if="icon" class="action-title-icon" :glyph="icon" />
+            {{ title }}
+          </router-link>
+        </span>
+      </h2>
     </div>
     <div class="section-content padding">
       <form @submit="handleSubmit">
         <template v-if="actionArguments.length > 0">
 
           <template v-for="arg in actionArguments" :key="arg.name">
-              <label :for="arg.name">
+              <label v-if="arg.type !== 'checklist'" :for="arg.name">
                 {{ formatLabel(arg.title) }}
               </label>
+              <div v-else class="argument-label">
+                {{ formatLabel(arg.title) }}
+              </div>
 
               <datalist v-if="(arg.suggestions && Object.keys(arg.suggestions).length > 0) || getBrowserSuggestions(arg).length > 0" :id="`${arg.name}-choices`">
                 <option v-for="(suggestion, key) in arg.suggestions" :key="key" :value="key">
@@ -25,6 +39,10 @@
                 :choices="arg.choices" :model-value="getArgumentValue(arg)" :required="arg.required"
                 @update:model-value="handleChoiceUpdate(arg, $event)" />
 
+              <ChoiceChecklist v-else-if="arg.type === 'checklist'" :id="arg.name" :name="arg.name"
+                :label="arg.title" :choices="arg.choices" :model-value="getArgumentValue(arg)" :required="arg.required"
+                @update:model-value="handleChoiceUpdate(arg, $event)" />
+
               <component v-else :is="getInputComponent(arg)" :id="arg.name" :name="arg.name"
                 :value="(arg.type === 'checkbox' || arg.type === 'confirmation') ? undefined : getArgumentValue(arg)"
                 :checked="(arg.type === 'checkbox' || arg.type === 'confirmation') ? getArgumentValue(arg) : undefined"
@@ -40,7 +58,7 @@
 
         <template v-if="justificationRequired">
           <label for="justification">Justification:</label>
-          <input id="justification" name="justification" type="text" v-model="justificationValue" required />
+          <input id="justification" name="justification" type="text" :value="justificationValue" required @input="handleJustificationInput" />
         </template>
 
         <div v-if="actionArguments.length === 0 && !justificationRequired">
@@ -61,10 +79,18 @@
 </template>
 
 <script setup>
-import { ref, onMounted, onBeforeUnmount, onUnmounted, nextTick } from 'vue'
+import { ref, computed, onMounted, onBeforeUnmount, onUnmounted, nextTick } from 'vue'
 import { useRouter } from 'vue-router'
 import { requestReconnectNow } from '../../../js/websocket.js'
 import ChoiceCombobox from '../components/ChoiceCombobox.vue'
+import ChoiceChecklist from '../components/ChoiceChecklist.vue'
+import ActionIconGlyph from '../components/ActionIconGlyph.vue'
+import {
+  actionJustificationTemplate,
+  actionRequiresJustification,
+  applyArgumentTemplate
+} from '../utils/justificationTemplate.js'
+import { getInitialArgumentValue, readPrefilledArgumentsFromNavigation } from '../utils/prefilledArguments.js'
 
 const router = useRouter()
 
@@ -80,8 +106,11 @@ const formErrors = ref({})
 const actionArguments = ref([])
 const popupOnStart = ref('')
 const formReady = ref(false)
-const justificationRequired = ref(false)
+const justificationConfig = ref('')
 const justificationValue = ref('')
+const justificationEditedManually = ref(false)
+const justificationRequired = computed(() => actionRequiresJustification(justificationConfig.value))
+const justificationTemplate = computed(() => actionJustificationTemplate(justificationConfig.value))
 let isComponentMounted = true
 
 // Computed properties
@@ -109,18 +138,21 @@ async function setup() {
     icon.value = action.icon
     popupOnStart.value = action.popupOnStart || ''
     actionArguments.value = action.arguments || []
-    justificationRequired.value = action.justification || false
+    justificationConfig.value = action.justification || ''
     justificationValue.value = ''
+    justificationEditedManually.value = false
     argValues.value = {}
     formErrors.value = {}
     confirmationChecked.value = false
     hasConfirmation.value = false
 
-    // Initialize values from query params or defaults
+    const prefilledArguments = readPrefilledArgumentsFromNavigation()
+
+    // Initialize values from navigation state, query params, or defaults
     actionArguments.value.forEach(arg => {
     if (arg.type === 'confirmation') {
       hasConfirmation.value = true
-      const paramValue = getQueryParamValue(arg.name)
+      const paramValue = getInitialArgumentValue(arg.name, prefilledArguments)
       let checkedValue = false
       if (paramValue !== null) {
         checkedValue = paramValue === '1' || paramValue === 'true' || paramValue === true
@@ -130,7 +162,7 @@ async function setup() {
       argValues.value[arg.name] = checkedValue
       confirmationChecked.value = checkedValue
     } else {
-      const paramValue = getQueryParamValue(arg.name)
+      const paramValue = getInitialArgumentValue(arg.name, prefilledArguments)
       if (arg.type === 'checkbox') {
         // For checkboxes, handle boolean default values properly
         if (paramValue !== null) {
@@ -158,16 +190,13 @@ async function setup() {
       formReady.value = true
       document.body.setAttribute('loaded-argument-form', props.bindingId)
     }
+
+    updateJustificationFromTemplate()
   } catch (err) {
     console.error('Failed to load argument form:', err)
   }
 }
 
-function getQueryParamValue(paramName) {
-  const params = new URLSearchParams(window.location.search.substring(1))
-  return params.get(paramName)
-}
-
 function formatLabel(title) {
   const lastChar = title.charAt(title.length - 1)
   if (lastChar === '?' || lastChar === '.' || lastChar === ':') {
@@ -222,10 +251,16 @@ function getArgumentValue(arg) {
   return argValues.value[arg.name] || ''
 }
 
+function handleJustificationInput(event) {
+  justificationValue.value = event.target.value
+  justificationEditedManually.value = true
+}
+
 function handleInput(arg, event) {
   const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value
   argValues.value[arg.name] = value
   updateUrlWithArg(arg.name, value)
+  updateJustificationFromTemplate()
 }
 
 function handleChange(arg, event) {
@@ -238,10 +273,19 @@ function handleChange(arg, event) {
   validateArgument(arg, event.target.value)
 }
 
+function getValidationElement(arg) {
+  if (arg.type === 'checklist') {
+    return document.getElementById(`${arg.name}-value`)
+  }
+
+  return document.getElementById(arg.name)
+}
+
 function handleChoiceUpdate(arg, value) {
   argValues.value[arg.name] = value
   updateUrlWithArg(arg.name, value)
   validateArgument(arg, value)
+  updateJustificationFromTemplate()
 }
 
 async function validateArgument(arg, value) {
@@ -251,7 +295,7 @@ async function validateArgument(arg, value) {
 
   // Skip validation for datetime - backend will handle mangling values without seconds
   if (arg.type === 'datetime') {
-    const inputElement = document.getElementById(arg.name)
+    const inputElement = getValidationElement(arg)
     if (inputElement) {
       inputElement.setCustomValidity('')
     }
@@ -261,7 +305,7 @@ async function validateArgument(arg, value) {
 
   // Skip validation for checkbox and confirmation - they're always valid
   if (arg.type === 'checkbox' || arg.type === 'confirmation') {
-    const inputElement = document.getElementById(arg.name)
+    const inputElement = getValidationElement(arg)
     if (inputElement) {
       inputElement.setCustomValidity('')
     }
@@ -279,8 +323,7 @@ async function validateArgument(arg, value) {
 
     const validation = await window.client.validateArgumentType(validateArgumentTypeArgs)
 
-    // Get the input element to set custom validity
-    const inputElement = document.getElementById(arg.name)
+    const inputElement = getValidationElement(arg)
 
     if (validation.valid) {
       delete formErrors.value[arg.name]
@@ -297,8 +340,7 @@ async function validateArgument(arg, value) {
     }
   } catch (err) {
     console.warn('Validation failed:', err)
-    // On error, clear any custom validity
-    const inputElement = document.getElementById(arg.name)
+    const inputElement = getValidationElement(arg)
     if (inputElement) {
       inputElement.setCustomValidity('')
     }
@@ -344,21 +386,39 @@ function formatArgumentValueForApi(arg, rawValue) {
   return rawValue ?? ''
 }
 
-function getArgumentValues() {
-  const ret = []
+function getSelectedArgumentEntries() {
+  const entries = []
 
   for (const arg of actionArguments.value) {
     if (!shouldSendArgument(arg)) {
       continue
     }
 
-    ret.push({
+    entries.push({
       name: arg.name,
       value: formatArgumentValueForApi(arg, argValues.value[arg.name])
     })
   }
 
-  return ret
+  return entries
+}
+
+function getArgumentValues() {
+  return getSelectedArgumentEntries().map(({ name, value }) => ({ name, value }))
+}
+
+function getArgumentMapForTemplate() {
+  return Object.fromEntries(
+    getSelectedArgumentEntries().map(({ name, value }) => [name, value])
+  )
+}
+
+function updateJustificationFromTemplate() {
+  if (!justificationTemplate.value || justificationEditedManually.value) {
+    return
+  }
+
+  justificationValue.value = applyArgumentTemplate(justificationTemplate.value, getArgumentMapForTemplate())
 }
 
 function getUniqueId() {
@@ -393,7 +453,7 @@ function saveBrowserSuggestions() {
       const value = argValues.value[arg.name]
 
       // Only save non-empty values for non-checkbox/confirmation/password types
-      if (value && value !== '' && arg.type !== 'checkbox' && arg.type !== 'confirmation' && arg.type !== 'password') {
+      if (value && value !== '' && arg.type !== 'checkbox' && arg.type !== 'confirmation' && arg.type !== 'checklist' && arg.type !== 'password') {
         try {
           const key = `olivetin-suggestions-${arg.suggestionsBrowserKey}`
           const stored = localStorage.getItem(key)
@@ -467,7 +527,7 @@ async function handleSubmit(event) {
 
   for (const arg of actionArguments.value) {
     const value = argValues.value[arg.name]
-    const inputElement = document.getElementById(arg.name)
+    const inputElement = getValidationElement(arg)
 
     if (arg.required && (!value || value === '')) {
       formErrors.value[arg.name] = 'This field is required'
@@ -484,6 +544,11 @@ async function handleSubmit(event) {
     return
   }
 
+  if (Object.keys(formErrors.value).length > 0) {
+    console.log('argument form has validation errors')
+    return
+  }
+
   const argvs = getArgumentValues()
   console.log('argument form has elements that passed validation')
 
@@ -546,6 +611,27 @@ onUnmounted(() => {
 </script>
 
 <style scoped>
+.section-title-with-icon {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.5rem;
+}
+
+.action-title-icon {
+  font-size: 1.5rem;
+}
+
+.action-details-title-link {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.5rem;
+  color: var(--link-color, #0066cc);
+  text-decoration: underline;
+}
+
+.action-details-title-link:hover {
+  color: var(--link-hover-color, #004499);
+}
 
 form {
   grid-template-columns: max-content auto auto;

+ 8 - 41
frontend/resources/vue/views/EntitiesView.vue

@@ -9,36 +9,18 @@
 		</p>
 	</Section>
 	<template v-else>
-		<Section v-for="def in entityDefinitions" :key="def.title" :title="'Entity: ' + def.title ">
-			<p>{{ def.instances.length }} instances.</p>
-
-			<ul>
-				<li v-for="inst in def.instances" :key="inst.uniqueKey">
-					<router-link :to="{ name: 'EntityDetails', params: { entityType: inst.type, entityKey: inst.uniqueKey } }">
-						{{ inst.title }}
-					</router-link>
-				</li>
-			</ul>
-
-			<h3>Used on Dashboards:</h3>
-			<ul>
-				<li v-for="dash in filteredDashboards(def.usedOnDashboards)" :key="dash">
-					<template v-if="isEntityDirectory(dash)">
-						{{ getDashboardTitle(dash) }} <span class="entity-directory-label">[Entity Directory]</span>
-					</template>
-					<router-link v-else-if="!dash.includes('entity:')" :to="{ name: 'Dashboard', params: { title: getDashboardTitle(dash) } }">
-						{{ getDashboardTitle(dash) }}
-					</router-link>
-					<span v-else>{{ dash }}</span>
-				</li>
-			</ul>
-		</Section>
+		<EntityDefinitionSection
+			v-for="def in entityDefinitions"
+			:key="def.title"
+			:definition="def"
+		/>
 	</template>
 </template>
 
 <script setup>
 	import { ref, computed, onMounted } from 'vue'
 	import Section from 'picocrank/vue/components/Section.vue'
+	import EntityDefinitionSection from '../components/EntityDefinitionSection.vue'
 
 	const definitionsLoaded = ref(false)
 	const entityDefinitions = ref([])
@@ -63,22 +45,7 @@
 		}
 	}
 
-	function filteredDashboards(dashboards) {
-		return dashboards.filter(d => d && !d.includes('{{'))
-	}
-
-	function isEntityDirectory(dashboardTitle) {
-		return dashboardTitle.endsWith(' [Entity Directory]')
-	}
-
-	function getDashboardTitle(dashboardTitle) {
-		if (isEntityDirectory(dashboardTitle)) {
-			return dashboardTitle.slice(0, -' [Entity Directory]'.length)
-		}
-		return dashboardTitle
-	}
-
-    onMounted(() => {
-        fetchEntities()
+	onMounted(() => {
+	    fetchEntities()
 	})
 </script>

+ 69 - 39
frontend/resources/vue/views/EntityDetailsView.vue

@@ -1,5 +1,12 @@
 <template>
-	<Section title="Entity Details">
+	<Section>
+		<template #title>
+			<span class="section-title-with-icon">
+				Entity Details:
+				<ActionIconGlyph v-if="entityIcon" class="entity-title-icon" :glyph="entityIcon" />
+				<span v-if="entityDetails?.title">{{ entityDetails.title }}</span>
+			</span>
+		</template>
 		<template #toolbar>
 			<button @click="goBack" class="back-button">
 				<HugeiconsIcon :icon="ArrowLeftIcon" width="1.2em" height="1.2em" />
@@ -22,37 +29,51 @@
 				<template v-if="entityDetails.fields">
 					<template v-for="(value, key) in entityDetails.fields" :key="key">
 						<dt>{{ key }}</dt>
-						<dd v-html="value"></dd>
+						<dd>{{ value }}</dd>
 					</template>
 				</template>
 			</dl>
 			<p v-if="!entityDetails.title && (!entityDetails.fields || Object.keys(entityDetails.fields).length === 0)">No details available for this entity.</p>
-
-			<hr />
-
-			<h3>Dashboard Entity Directories</h3>
-			<div v-if="filteredDirectories.length > 0" class="directories-section">
-				<ul class="directory-list">
-					<li v-for="(directory, idx) in filteredDirectories" :key="idx">
-						<router-link
-							:to="{
-								name: 'Dashboard',
-								params: {
-									title: directory,
-									entityType: entityType,
-									entityKey: entityKey
-								}
-							}">
-							{{ directory }}
-						</router-link>
-					</li>
-				</ul>
-			</div>
-			<p v-else>No directories found for this entity.
-				<a href = "https://docs.olivetin.app/dashboards/entity-directories.html" target = "_blank">Learn more</a>
-			</p>
 		</template>
 	</Section>
+
+	<Section v-if="entityDetails" title="Dashboard Entity Directories">
+		<div v-if="filteredDirectories.length > 0" class="directories-section">
+			<ul class="directory-list">
+				<li v-for="(directory, idx) in filteredDirectories" :key="idx">
+					<router-link
+						:to="{
+							name: 'Dashboard',
+							params: {
+								title: directory,
+								entityType: entityType,
+								entityKey: entityKey
+							}
+						}">
+						{{ directory }}
+					</router-link>
+				</li>
+			</ul>
+		</div>
+		<p v-else>No directories found for this entity.
+			<a href="https://docs.olivetin.app/dashboards/entity-directories.html" target="_blank" rel="noopener noreferrer">Learn more</a>
+		</p>
+	</Section>
+
+	<section v-if="entityDetails && relatedActions.length > 0" class="transparent">
+		<div class="dashboard-row">
+			<fieldset>
+				<legend class="visually-hidden">Related actions</legend>
+				<template v-for="(related, idx) in relatedActions" :key="related.action?.bindingId || idx">
+					<ActionButton
+						v-if="related.action"
+						:action-data="related.action"
+						:prefilled-arguments="related.prefilledArguments"
+					/>
+				</template>
+			</fieldset>
+		</div>
+	</section>
 </template>
 
 <script setup>
@@ -61,6 +82,8 @@
 	import { HugeiconsIcon } from '@hugeicons/vue'
 	import { ArrowLeftIcon } from '@hugeicons/core-free-icons'
 	import Section from 'picocrank/vue/components/Section.vue'
+	import ActionButton from '../ActionButton.vue'
+	import ActionIconGlyph from '../components/ActionIconGlyph.vue'
 
 	const router = useRouter()
 	const entityDetails = ref(null)
@@ -77,6 +100,10 @@
 		return entityDetails.value.directories.filter(d => d)
 	})
 
+	const relatedActions = computed(() => entityDetails.value?.relatedActions ?? [])
+
+	const entityIcon = computed(() => entityDetails.value?.icon ?? '')
+
 	function goBack() {
 		router.push({ name: 'Entities' })
 	}
@@ -121,11 +148,6 @@
     box-shadow: 0 0 .5em rgba(0, 0, 0, 0.15);
 }
 
-.directories-section h3 {
-    margin-bottom: 0.5em;
-    font-size: 1.1em;
-}
-
 .directory-list a {
     text-decoration: none;
     padding: 0.5em;
@@ -149,9 +171,22 @@
     opacity: 0.8;
 }
 
-hr {
-	border: 0;
-	border-top: 1px solid var(--border-color, #ccc);
+.section-title-with-icon {
+	display: inline-flex;
+	align-items: center;
+	gap: 0.5em;
+}
+
+.entity-title-icon {
+	font-size: 1.2em;
+}
+
+fieldset {
+	display: grid;
+	grid-template-columns: repeat(auto-fit, 180px);
+	grid-auto-rows: 1fr;
+	justify-content: center;
+	place-items: stretch;
 }
 
 @media (prefers-color-scheme: dark) {
@@ -164,11 +199,6 @@ hr {
         background-color: var(--bg-hover, #222);
     }
 
-    .directories-section {
-        border-top-color: var(--border-color, #333);
-    }
-
-
     .directory-list a:hover {
         background-color: var(--bg-hover, #222);
     }

+ 6 - 2
integration-tests/Makefile

@@ -1,8 +1,12 @@
-default: test-install test-run
+default: test-install prep test-run
 
 test-install:
 	npm install --no-fund
 
+prep:
+	$(MAKE) -wC .. webui-dist
+	$(MAKE) -wC ../service compile-currentenv
+
 test-run:
     # GitHub Actions fails badly on the default timeout of 2000ms
 	npx mocha tests --recursive -t 10000
@@ -24,4 +28,4 @@ getsnapshot:
 	rm -rf /opt/OliveTin-snapshot/*
 	gh run download -D /opt/OliveTin-snapshot/
 
-.PHONY: default find-flakey-tests find-flakey-tests-inf
+.PHONY: default find-flakey-tests find-flakey-tests-inf prep

+ 3 - 3
integration-tests/tests/checkbox/config.yaml

@@ -7,7 +7,9 @@ defaultPopupOnStart: execution-dialog
 
 actions:
   - title: Test checkbox argument
-    shell: "echo 'Checkbox value: {{ confirm }}'"
+    exec:
+      - echo
+      - "Checkbox value: {{ confirm }}"
     icon: ping
     arguments:
       - name: confirm
@@ -15,5 +17,3 @@ actions:
         type: checkbox
         description: "When checked: 1, when unchecked: 0"
         default: false
-
-

+ 178 - 0
integration-tests/tests/checklist/checklist.mjs

@@ -0,0 +1,178 @@
+import { describe, it, before, after } from 'mocha'
+import { expect } from 'chai'
+import { By, Condition } from 'selenium-webdriver'
+import {
+  DEFAULT_UI_WAIT_MS,
+  getRootAndWait,
+  getActionButton,
+  takeScreenshotOnFailure,
+  getTerminalBuffer,
+  waitForArgumentFormPage,
+  waitForArgumentFormReady,
+  waitForLogsPage,
+  waitForExecutionComplete,
+} from '../../lib/elements.js'
+
+async function openChecklistArgumentForm(actionTitle = 'Test checklist argument') {
+  await getRootAndWait()
+  const btn = await getActionButton(webdriver, actionTitle)
+  await btn.click()
+
+  await waitForArgumentFormPage()
+  await waitForArgumentFormReady()
+}
+
+async function submitChecklistForm() {
+  const submitButton = await webdriver.findElement(By.css('button[name="start"]'))
+  await submitButton.click()
+}
+
+async function pollTerminal(matcher, timeoutMs = DEFAULT_UI_WAIT_MS) {
+  await webdriver.wait(
+    new Condition('wait for terminal output', async () => {
+      try {
+        const terminalReady = await webdriver.executeScript(`
+          return !!(window.terminal && window.terminal.getBufferAsString);
+        `)
+        if (!terminalReady) {
+          return false
+        }
+
+        const output = await getTerminalBuffer()
+        if (!output) {
+          return false
+        }
+
+        return matcher(output.trim())
+      } catch (e) {
+        return false
+      }
+    }),
+    timeoutMs
+  )
+}
+
+async function waitForTerminalOutput(expectedValue, label = 'Selected segments') {
+  await pollTerminal(
+    (output) => output.includes(`${label}: ${expectedValue}`),
+    DEFAULT_UI_WAIT_MS
+  )
+}
+
+async function waitForTerminalOutputPattern(pattern) {
+  await pollTerminal(
+    (output) => pattern.test(output),
+    DEFAULT_UI_WAIT_MS
+  )
+}
+
+async function waitForChecklistValue(expectedValue) {
+  await webdriver.wait(
+    new Condition('wait for checklist hidden value', async () => {
+      const valueInput = await webdriver.findElement(By.css('.choice-checklist > input'))
+      return (await valueInput.getAttribute('value')) === expectedValue
+    }),
+    DEFAULT_UI_WAIT_MS
+  )
+}
+
+async function getCheckboxByValueIndex(index) {
+  const checkboxes = await webdriver.findElements(
+    By.css('.choice-checklist-item input[type="checkbox"]')
+  )
+  return checkboxes[index]
+}
+
+describe('config: checklist', function () {
+  this.timeout(10000)
+
+  before(async function () {
+    await runner.start('checklist')
+  })
+
+  after(async () => {
+    await runner.stop()
+  })
+
+  afterEach(function () {
+    takeScreenshotOnFailure(this.currentTest, webdriver)
+  })
+
+  it('Checklist argument renders multiple checkbox inputs', async function () {
+    await openChecklistArgumentForm()
+
+    const kitchen = await getCheckboxByValueIndex(0)
+    const bedroom = await getCheckboxByValueIndex(1)
+    const hallway = await getCheckboxByValueIndex(2)
+
+    expect(await kitchen.getAttribute('type')).to.equal('checkbox')
+    expect(await bedroom.getAttribute('type')).to.equal('checkbox')
+    expect(await hallway.getAttribute('type')).to.equal('checkbox')
+    expect(await kitchen.isSelected()).to.be.true
+    expect(await bedroom.isSelected()).to.be.true
+    expect(await hallway.isSelected()).to.be.false
+  })
+
+  it('Checklist select none submits an empty value', async function () {
+    await openChecklistArgumentForm()
+
+    const selectNone = await webdriver.findElement(By.xpath("//button[normalize-space()='Select none']"))
+    await selectNone.click()
+    await waitForChecklistValue('')
+
+    const valueInput = await webdriver.findElement(By.css('.choice-checklist > input'))
+    expect(await valueInput.getAttribute('value')).to.equal('')
+
+    await submitChecklistForm()
+    await waitForLogsPage()
+    await waitForExecutionComplete()
+    await waitForTerminalOutputPattern(/Selected segments:\s*(\r?\n|$)/)
+  })
+
+  it('Checklist select all submits every choice value', async function () {
+    await openChecklistArgumentForm()
+
+    const selectNone = await webdriver.findElement(By.xpath("//button[normalize-space()='Select none']"))
+    await selectNone.click()
+
+    const selectAll = await webdriver.findElement(By.xpath("//button[normalize-space()='Select all']"))
+    await selectAll.click()
+
+    await submitChecklistForm()
+    await waitForLogsPage()
+    await waitForExecutionComplete()
+    await waitForTerminalOutput('["kitchen","bedroom","hallway"]')
+  })
+
+  it('Checklist toggles individual choices before submit', async function () {
+    await openChecklistArgumentForm()
+
+    const hallway = await getCheckboxByValueIndex(2)
+    await hallway.click()
+
+    await submitChecklistForm()
+    await waitForLogsPage()
+    await waitForExecutionComplete()
+    await waitForTerminalOutput('["kitchen","bedroom","hallway"]')
+  })
+
+  it('Checklist entity argument renders choices from entities', async function () {
+    await openChecklistArgumentForm('Test checklist entity argument')
+
+    const checkboxes = await webdriver.findElements(
+      By.css('.choice-checklist-item input[type="checkbox"]')
+    )
+    expect(checkboxes).to.have.length(2)
+
+    const labels = await webdriver.findElements(By.css('.choice-checklist-item span'))
+    expect(await labels[0].getText()).to.equal('attic')
+    expect(await labels[1].getText()).to.equal('basement')
+
+    await checkboxes[0].click()
+
+    await submitChecklistForm()
+    await waitForLogsPage()
+    await waitForExecutionComplete()
+    await waitForTerminalOutput('["attic"]', 'Selected rooms')
+  })
+})

+ 40 - 0
integration-tests/tests/checklist/config.yaml

@@ -0,0 +1,40 @@
+---
+listenAddressSingleHTTPFrontend: 0.0.0.0:1337
+
+logLevel: "DEBUG"
+checkForUpdates: false
+defaultPopupOnStart: execution-dialog
+
+entities:
+  - file: entities/rooms.yaml
+    name: room
+
+actions:
+  - title: Test checklist argument
+    shell: "echo 'Selected segments: {{ segments }}'"
+    icon: ping
+    arguments:
+      - name: segments
+        title: Rooms to clean
+        type: checklist
+        description: Select the rooms to include in the vacuum run.
+        choices:
+          - title: Kitchen
+            value: kitchen
+          - title: Bedroom
+            value: bedroom
+          - title: Hallway
+            value: hallway
+        default: kitchen,bedroom
+
+  - title: Test checklist entity argument
+    shell: "echo 'Selected rooms: {{ rooms }}'"
+    icon: ping
+    arguments:
+      - name: rooms
+        title: Rooms to include
+        type: checklist
+        entity: room
+        choices:
+          - title: '{{ room.hostname }}'
+            value: '{{ room.hostname }}'

+ 2 - 0
integration-tests/tests/checklist/entities/rooms.yaml

@@ -0,0 +1,2 @@
+- hostname: attic
+- hostname: basement

+ 21 - 1
proto/olivetin/api/v1/olivetin.proto

@@ -20,7 +20,8 @@ message Action {
 	repeated string exec_on_file_changed_in_dir = 13;
 	string exec_on_calendar_file = 14;
 	repeated ActionWebhookExecHint exec_on_webhooks = 15;
-	bool justification = 16;
+	reserved 16;
+	string justification = 20;
 	bool has_running_instance = 17;
 	bool has_queued_instance = 18;
 	repeated ActionGroupMembership groups = 19;
@@ -57,12 +58,19 @@ message ActionArgumentChoice {
 	string title = 2;
 }
 
+message EntityRelatedAction {
+	Action action = 1;
+	map<string, string> prefilled_arguments = 2;
+}
+
 message Entity {
 	string title = 1;
     string unique_key = 2;
     string type = 3;
     repeated string directories = 4;
     map<string, string> fields = 5;
+    repeated EntityRelatedAction related_actions = 6;
+    string icon = 7;
 }
 
 message GetDashboardResponse {
@@ -427,6 +435,10 @@ message GetActionBindingResponse {
 }
 
 message GetEntitiesRequest {
+  string entity_type = 1;
+  string filter = 2;
+  int32 page = 3;
+  int32 page_size = 4;
 }
 
 message GetEntitiesResponse {
@@ -437,6 +449,14 @@ message EntityDefinition {
   string title = 1;
   repeated Entity instances = 2;
   repeated string used_on_dashboards = 3;
+  string icon = 4;
+  repeated EntityProperty properties = 5;
+  int32 total_instances = 6;
+}
+
+message EntityProperty {
+  string name = 1;
+  string title = 2;
 }
 
 message GetEntityRequest {

+ 289 - 0
scripts/devcheck.py

@@ -0,0 +1,289 @@
+#!/usr/bin/env python3
+"""Scan project Makefiles for development tools and report PATH availability."""
+
+from __future__ import annotations
+
+import os
+import re
+import shutil
+import sys
+from collections import defaultdict
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+
+FAIL_GROUPS = frozenset({
+    "Core build and test",
+    "Go codestyle (install via: make go-tools)",
+    "Protocol buffers (install via: make -C service go-tools-all)",
+})
+
+SKIP_DIRS = frozenset({
+    ".git",
+    "node_modules",
+    "vendor",
+    "dist",
+    "reports",
+    "webui",
+})
+
+SKIP_COMMANDS = frozenset({
+    ".", ":", "[", "bash", "case", "cat", "cd", "chmod", "chown", "cp", "curl",
+    "do", "done", "echo", "elif", "else", "esac", "exit", "false", "fi", "for",
+    "fuser", "grep", "head", "if", "kill", "killall", "lsof", "make", "mkdir",
+    "mv", "objdump", "pwd", "rm", "sed", "set", "sh", "sleep", "tail", "test",
+    "then", "touch", "trap", "true", "unzip", "while",
+})
+
+SKIP_PREFIXES = ("./", "../", "-", "$(")
+
+GO_INSTALL_RE = re.compile(r"""go\s+install\s+(?:["'])([^"']+)(?:["'])""")
+
+TOOL_GROUPS: list[tuple[str, frozenset[str]]] = [
+    (
+        "Core build and test",
+        frozenset({"go", "npm", "npx", "node", "python3"}),
+    ),
+    (
+        "Go codestyle (install via: make go-tools)",
+        frozenset({"gocyclo", "gocritic"}),
+    ),
+    (
+        "Protocol buffers (install via: make -C service go-tools-all)",
+        frozenset({"buf", "protoc-gen-go"}),
+    ),
+    (
+        "Containers and packaging (optional)",
+        frozenset({"buildah", "docker", "podman", "podman-compose"}),
+    ),
+]
+
+
+class Color:
+    RESET = "\033[0m"
+    BOLD = "\033[1m"
+    GREEN = "\033[32m"
+    RED = "\033[31m"
+    ORANGE = "\033[38;5;208m"
+    DIM = "\033[2m"
+
+    @classmethod
+    def ok(cls, text: str) -> str:
+        return f"{cls.BOLD}{cls.GREEN}{text}{cls.RESET}"
+
+    @classmethod
+    def fail(cls, text: str) -> str:
+        return f"{cls.BOLD}{cls.RED}{text}{cls.RESET}"
+
+    @classmethod
+    def warn(cls, text: str) -> str:
+        return f"{cls.BOLD}{cls.ORANGE}{text}{cls.RESET}"
+
+    @classmethod
+    def dim(cls, text: str) -> str:
+        return f"{cls.DIM}{text}{cls.RESET}"
+
+
+def colors_enabled() -> bool:
+    if os.environ.get("NO_COLOR"):
+        return False
+    if "--no-color" in sys.argv:
+        return False
+    return sys.stdout.isatty()
+
+
+def paint_ok(text: str) -> str:
+    return Color.ok(text) if colors_enabled() else text
+
+
+def paint_fail(text: str) -> str:
+    return Color.fail(text) if colors_enabled() else text
+
+
+def paint_warn(text: str) -> str:
+    return Color.warn(text) if colors_enabled() else text
+
+
+def paint_dim(text: str) -> str:
+    return Color.dim(text) if colors_enabled() else text
+
+
+def find_makefiles(root: Path) -> list[Path]:
+    makefiles: list[Path] = []
+    for path in root.rglob("Makefile"):
+        rel_parts = path.relative_to(root).parts
+        if any(part.startswith(".") or part in SKIP_DIRS for part in rel_parts):
+            continue
+        makefiles.append(path)
+    return sorted(makefiles)
+
+
+def tool_from_go_install(package: str) -> str:
+    return package.rstrip("/").rsplit("/", 1)[-1]
+
+
+def first_command_token(line: str) -> str | None:
+    line = line.strip()
+    if not line or line.startswith("#"):
+        return None
+
+    if line.startswith("$(call") or line.startswith("$(MAKE)"):
+        return None
+
+    for token in line.split():
+        if "=" in token and not token.startswith("./"):
+            continue
+        if token.startswith(SKIP_PREFIXES):
+            return None
+        return token.strip("\"'")
+    return None
+
+
+def collect_tools(makefiles: list[Path]) -> dict[str, set[str]]:
+    tools: dict[str, set[str]] = defaultdict(set)
+
+    for makefile in makefiles:
+        rel = makefile.relative_to(ROOT).as_posix()
+        try:
+            lines = makefile.read_text(encoding="utf-8", errors="replace").splitlines()
+        except OSError:
+            continue
+
+        for line in lines:
+            if not line.startswith("\t"):
+                continue
+
+            recipe = line.lstrip("\t@")
+            for package in GO_INSTALL_RE.findall(recipe):
+                tools[tool_from_go_install(package)].add(rel)
+
+            if "python -c" in recipe or recipe.startswith("python "):
+                tools["python3"].add(rel)
+
+            command = first_command_token(recipe)
+            if command is None:
+                continue
+
+            command = command.lower()
+            if command in SKIP_COMMANDS or command == "python":
+                continue
+            if "/" in command and command not in {"podman-compose"}:
+                continue
+
+            tools[command].add(rel)
+
+    return dict(tools)
+
+
+def resolve_python() -> tuple[bool, str | None]:
+    for name in ("python3", "python"):
+        path = shutil.which(name)
+        if path is not None:
+            return True, path
+    return False, None
+
+
+def resolve_tool(name: str) -> tuple[bool, str | None]:
+    if name == "python3":
+        return resolve_python()
+    path = shutil.which(name)
+    return path is not None, path
+
+
+def group_tools(tools: dict[str, set[str]]) -> list[tuple[str, list[str]]]:
+    grouped: list[tuple[str, list[str]]] = []
+    assigned: set[str] = set()
+
+    for title, members in TOOL_GROUPS:
+        present = sorted(tool for tool in tools if tool in members)
+        if present:
+            grouped.append((title, present))
+            assigned.update(present)
+
+    remaining = sorted(tool for tool in tools if tool not in assigned)
+    if remaining:
+        grouped.append(("Other tools found in Makefiles", remaining))
+
+    return grouped
+
+
+def format_status(found: bool, path: str | None, *, required: bool) -> str:
+    if found:
+        return paint_ok(f"OK      {path}")
+
+    if required:
+        return paint_fail("MISSING")
+    return paint_warn("MISSING")
+
+
+def print_group(
+    title: str,
+    tool_names: list[str],
+    tools: dict[str, set[str]],
+    verbose: bool,
+) -> tuple[int, int, list[str]]:
+    required = title in FAIL_GROUPS
+    print(title)
+    available = 0
+    missing: list[str] = []
+
+    for name in tool_names:
+        found, path = resolve_tool(name)
+        available += int(found)
+        if not found:
+            missing.append(name)
+        print(f"  {name:<16} {format_status(found, path, required=required)}")
+        if verbose:
+            for source in sorted(tools[name]):
+                print(paint_dim(f"    referenced in {source}"))
+    print()
+    return available, len(tool_names), missing
+
+
+def main() -> int:
+    verbose = "--verbose" in sys.argv or "-v" in sys.argv
+    makefiles = find_makefiles(ROOT)
+    tools = collect_tools(makefiles)
+
+    print("Development environment check")
+    print(f"Scanned {len(makefiles)} Makefile(s) under {ROOT}")
+    print()
+
+    total_available = 0
+    total_checked = 0
+    required_missing: list[str] = []
+    optional_missing: list[str] = []
+
+    for title, tool_names in group_tools(tools):
+        available, checked, missing = print_group(title, tool_names, tools, verbose)
+        total_available += available
+        total_checked += checked
+
+        if title in FAIL_GROUPS:
+            required_missing.extend(missing)
+        else:
+            optional_missing.extend(missing)
+
+    summary = f"Summary: {total_available}/{total_checked} tools available on PATH"
+    if required_missing:
+        print(paint_fail(summary))
+    elif optional_missing:
+        print(paint_warn(summary))
+    else:
+        print(paint_ok(summary))
+
+    if required_missing:
+        print()
+        print(paint_fail("Missing required tools: " + ", ".join(required_missing)))
+        print(paint_dim("Install the missing tools for your platform, then re-run: make devcheck"))
+        return 1
+
+    if optional_missing:
+        print()
+        print(paint_warn("Missing optional tools: " + ", ".join(optional_missing)))
+
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

Diferenças do arquivo suprimidas por serem muito extensas
+ 177 - 109
service/gen/olivetin/api/v1/olivetin.pb.go


+ 153 - 49
service/internal/api/api.go

@@ -4,9 +4,14 @@ import (
 	ctx "context"
 	"encoding/json"
 	"errors"
+	"fmt"
+	"net/http"
 	"os"
 	"path"
 	"sort"
+	"strings"
+	"sync"
+	"time"
 
 	"connectrpc.com/connect"
 	"google.golang.org/protobuf/encoding/protojson"
@@ -16,11 +21,6 @@ import (
 	"github.com/google/uuid"
 	log "github.com/sirupsen/logrus"
 
-	"fmt"
-	"net/http"
-	"sync"
-	"time"
-
 	acl "github.com/OliveTin/OliveTin/internal/acl"
 	auth "github.com/OliveTin/OliveTin/internal/auth"
 	authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
@@ -156,15 +156,17 @@ func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *connect.Request[apiv1.
 	}
 
 	authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg)
-	if err := validateJustificationRequired(pair.Action, req.Msg.Justification, authenticatedUser); err != nil {
+	args := startActionArgumentsFromProto(req.Msg.Arguments)
+	justification := resolveStartJustification(pair.Action, pair, req.Msg.Justification, args)
+	if err := validateJustificationRequired(pair.Action, justification, authenticatedUser); err != nil {
 		return nil, connectInvalidJustification(err)
 	}
 
 	execReq := executor.ExecutionRequest{
 		Binding:           pair,
 		TrackingID:        req.Msg.UniqueTrackingId,
-		Arguments:         startActionArgumentsFromProto(req.Msg.Arguments),
-		Justification:     req.Msg.Justification,
+		Arguments:         args,
+		Justification:     justification,
 		AuthenticatedUser: authenticatedUser,
 		Cfg:               api.cfg,
 	}
@@ -280,6 +282,21 @@ func (api *oliveTinAPI) findBindingByIDOrNotFound(bindingId string) (*executor.A
 	return api.findBindingOrNotFound(bindingId)
 }
 
+func (api *oliveTinAPI) startActionAndWaitLogEntry(binding *executor.ActionBinding, args map[string]string, justification string, user *authpublic.AuthenticatedUser) (*apiv1.LogEntry, error) {
+	internalLogEntry, ok := api.startActionAndWaitRun(binding, args, justification, user)
+	if !ok {
+		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found"))
+	}
+	return api.logEntryForAllowedViewer(internalLogEntry, user)
+}
+
+func (api *oliveTinAPI) logEntryForAllowedViewer(internalLogEntry *executor.InternalLogEntry, user *authpublic.AuthenticatedUser) (*apiv1.LogEntry, error) {
+	if err := api.requireLogEntryAllowed(internalLogEntry, user); err != nil {
+		return nil, err
+	}
+	return api.internalLogEntryToPb(internalLogEntry, user), nil
+}
+
 func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionAndWaitRequest]) (*connect.Response[apiv1.StartActionAndWaitResponse], error) {
 	binding, err := api.findBindingOrNotFound(req.Msg.ActionId)
 	if err != nil {
@@ -287,16 +304,18 @@ func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request
 	}
 
 	user := auth.UserFromApiCall(ctx, req, api.cfg)
-	if err := validateJustificationRequired(binding.Action, req.Msg.Justification, user); err != nil {
+	args := startActionArgumentsFromProto(req.Msg.Arguments)
+	justification := resolveStartJustification(binding.Action, binding, req.Msg.Justification, args)
+	if err := validateJustificationRequired(binding.Action, justification, user); err != nil {
 		return nil, connectInvalidJustification(err)
 	}
 
-	internalLogEntry, ok := api.startActionAndWaitRun(binding, startActionArgumentsFromProto(req.Msg.Arguments), req.Msg.Justification, user)
-	if !ok {
-		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found"))
+	logEntry, err := api.startActionAndWaitLogEntry(binding, args, justification, user)
+	if err != nil {
+		return nil, err
 	}
 	return connect.NewResponse(&apiv1.StartActionAndWaitResponse{
-		LogEntry: api.internalLogEntryToPb(internalLogEntry, user),
+		LogEntry: logEntry,
 	}), nil
 }
 
@@ -323,16 +342,7 @@ func (api *oliveTinAPI) StartActionByGet(ctx ctx.Context, req *connect.Request[a
 	}), nil
 }
 
-func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionByGetAndWaitRequest]) (*connect.Response[apiv1.StartActionByGetAndWaitResponse], error) {
-	binding := api.executor.FindBindingByID(req.Msg.ActionId)
-	if binding == nil || binding.Action == nil {
-		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.ActionId))
-	}
-
-	args := make(map[string]string)
-
-	user := auth.UserFromApiCall(ctx, req, api.cfg)
-
+func (api *oliveTinAPI) runBindingAndWait(binding *executor.ActionBinding, args map[string]string, user *authpublic.AuthenticatedUser) (*executor.InternalLogEntry, bool) {
 	execReq := executor.ExecutionRequest{
 		Binding:           binding,
 		TrackingID:        uuid.NewString(),
@@ -344,14 +354,31 @@ func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Re
 	wg, _ := api.executor.ExecRequest(&execReq)
 	wg.Wait()
 
-	internalLogEntry, ok := api.executor.GetLog(execReq.TrackingID)
+	return api.executor.GetLog(execReq.TrackingID)
+}
+
+func (api *oliveTinAPI) startActionByGetAndWaitLogEntry(binding *executor.ActionBinding, user *authpublic.AuthenticatedUser) (*apiv1.LogEntry, error) {
+	internalLogEntry, ok := api.runBindingAndWait(binding, map[string]string{}, user)
+	if !ok {
+		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found"))
+	}
+	return api.logEntryForAllowedViewer(internalLogEntry, user)
+}
+
+func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionByGetAndWaitRequest]) (*connect.Response[apiv1.StartActionByGetAndWaitResponse], error) {
+	binding := api.executor.FindBindingByID(req.Msg.ActionId)
+	if binding == nil || binding.Action == nil {
+		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.ActionId))
+	}
 
-	if ok {
-		return connect.NewResponse(&apiv1.StartActionByGetAndWaitResponse{
-			LogEntry: api.internalLogEntryToPb(internalLogEntry, user),
-		}), nil
+	user := auth.UserFromApiCall(ctx, req, api.cfg)
+	logEntry, err := api.startActionByGetAndWaitLogEntry(binding, user)
+	if err != nil {
+		return nil, err
 	}
-	return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found"))
+	return connect.NewResponse(&apiv1.StartActionByGetAndWaitResponse{
+		LogEntry: logEntry,
+	}), nil
 }
 
 func calculateRateLimitExpires(api *oliveTinAPI, logEntry *executor.InternalLogEntry) string {
@@ -680,6 +707,13 @@ func (api *oliveTinAPI) isLogEntryAllowed(e *executor.InternalLogEntry, user *au
 	return acl.IsAllowedLogs(api.cfg, user, e.Binding.Action)
 }
 
+func (api *oliveTinAPI) requireLogEntryAllowed(entry *executor.InternalLogEntry, user *authpublic.AuthenticatedUser) error {
+	if api.isLogEntryAllowed(entry, user) {
+		return nil
+	}
+	return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied to view this execution"))
+}
+
 // mayViewExecutionEvent returns whether the user is allowed to receive this execution event (for EventStream ACL).
 func (api *oliveTinAPI) mayViewExecutionEvent(entry *executor.InternalLogEntry, user *authpublic.AuthenticatedUser) bool {
 	if user == nil {
@@ -1373,21 +1407,7 @@ func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.
 	}
 
 	entityMap := entities.GetEntities()
-	entityNames := make([]string, 0, len(entityMap))
-	for name := range entityMap {
-		entityNames = append(entityNames, name)
-	}
-	sort.Strings(entityNames)
-
-	entityDefinitions := make([]*apiv1.EntityDefinition, 0, len(entityNames))
-	for _, name := range entityNames {
-		def := &apiv1.EntityDefinition{
-			Title:            name,
-			UsedOnDashboards: findDashboardsForEntity(name, api.cfg.Dashboards),
-			Instances:        buildSortedEntityInstances(name, entityMap[name]),
-		}
-		entityDefinitions = append(entityDefinitions, def)
-	}
+	entityDefinitions := api.buildEntityDefinitionsResponse(req.Msg, entityMap)
 
 	res := &apiv1.GetEntitiesResponse{
 		EntityDefinitions: entityDefinitions,
@@ -1396,7 +1416,7 @@ func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.
 	return connect.NewResponse(res), nil
 }
 
-func buildSortedEntityInstances(entityType string, entityInstances map[string]*entities.Entity) []*apiv1.Entity {
+func buildSortedEntityInstances(entityType string, entityInstances map[string]*entities.Entity, properties []config.EntityProperty) []*apiv1.Entity {
 	instanceKeys := make([]string, 0, len(entityInstances))
 	for key := range entityInstances {
 		instanceKeys = append(instanceKeys, key)
@@ -1410,6 +1430,7 @@ func buildSortedEntityInstances(entityType string, entityInstances map[string]*e
 			Title:     e.Title,
 			UniqueKey: e.UniqueKey,
 			Type:      entityType,
+			Fields:    entityListFields(e.Data, properties),
 		})
 	}
 	return instances
@@ -1511,17 +1532,100 @@ func (api *oliveTinAPI) GetEntity(ctx ctx.Context, req *connect.Request[apiv1.Ge
 		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity with unique key %s not found in type %s", req.Msg.UniqueKey, req.Msg.Type))
 	}
 
-	res := buildEntityResponse(entity, req.Msg.Type, api.cfg.Dashboards)
+	res := buildEntityResponse(entity, req.Msg.Type, api.cfg)
+	res.RelatedActions = api.relatedActionsForEntity(user, req.Msg.Type, entity)
 	return connect.NewResponse(res), nil
 }
 
-func buildEntityResponse(entity *entities.Entity, entityType string, dashboards []*config.DashboardComponent) *apiv1.Entity {
+func entityTypeIcon(cfg *config.Config, entityType string) string {
+	entityFile := entityFileForType(cfg, entityType)
+	if entityFile == nil {
+		return ""
+	}
+
+	return entityFile.Icon
+}
+
+func entityFileForType(cfg *config.Config, entityType string) *config.EntityFile {
+	for _, entityFile := range cfg.Entities {
+		if entityFile != nil && entityFile.Name == entityType {
+			return entityFile
+		}
+	}
+
+	return nil
+}
+
+func entityPropertiesFromFile(entityFile *config.EntityFile) []config.EntityProperty {
+	if entityFile == nil {
+		return nil
+	}
+
+	return entityFile.Properties
+}
+
+func entityDefinitionProperties(properties []config.EntityProperty) []*apiv1.EntityProperty {
+	if len(properties) == 0 {
+		return nil
+	}
+
+	result := make([]*apiv1.EntityProperty, 0, len(properties))
+	for _, property := range properties {
+		result = append(result, &apiv1.EntityProperty{
+			Name:  property.Name,
+			Title: property.Title,
+		})
+	}
+
+	return result
+}
+
+func entityListFields(data any, properties []config.EntityProperty) map[string]string {
+	if len(properties) == 0 {
+		return nil
+	}
+
+	fields := make(map[string]string, len(properties))
+	for _, property := range properties {
+		fields[property.Name] = entityPropertyValue(data, property.Name)
+	}
+
+	return fields
+}
+
+func entityPropertyValue(data any, propertyName string) string {
+	dataMap, ok := data.(map[string]any)
+	if !ok {
+		return ""
+	}
+
+	if value, found := dataMap[propertyName]; found {
+		return fmt.Sprintf("%v", value)
+	}
+
+	return entityPropertyValueCaseInsensitive(dataMap, propertyName)
+}
+
+func entityPropertyValueCaseInsensitive(dataMap map[string]any, propertyName string) string {
+	propertyNameLower := strings.ToLower(propertyName)
+	for key, value := range dataMap {
+		if strings.ToLower(key) == propertyNameLower {
+			return fmt.Sprintf("%v", value)
+		}
+	}
+
+	return ""
+}
+
+func buildEntityResponse(entity *entities.Entity, entityType string, cfg *config.Config) *apiv1.Entity {
+	properties := entityPropertiesFromFile(entityFileForType(cfg, entityType))
 	res := &apiv1.Entity{
 		Title:       entity.Title,
 		UniqueKey:   entity.UniqueKey,
 		Type:        entityType,
-		Directories: findDirectoriesInEntityFieldsets(entityType, dashboards),
-		Fields:      serializeEntityFields(entity.Data),
+		Directories: findDirectoriesInEntityFieldsets(entityType, cfg.Dashboards),
+		Fields:      entityFieldsForResponse(entity.Data, properties),
+		Icon:        entityTypeIcon(cfg, entityType),
 	}
 	return res
 }

+ 154 - 0
service/internal/api/api_entities_list.go

@@ -0,0 +1,154 @@
+package api
+
+import (
+	"sort"
+	"strings"
+
+	apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
+	config "github.com/OliveTin/OliveTin/internal/config"
+	"github.com/OliveTin/OliveTin/internal/entities"
+)
+
+const (
+	defaultEntityInstancesPageSize = 10
+	maxEntityInstancesPageSize     = 100
+)
+
+func (api *oliveTinAPI) buildEntityDefinitionsResponse(req *apiv1.GetEntitiesRequest, entityMap entities.EntitiesByClass) []*apiv1.EntityDefinition {
+	if req != nil && req.EntityType != "" {
+		return api.buildFilteredEntityDefinitions(req, entityMap)
+	}
+
+	return api.buildAllEntityDefinitions(entityMap)
+}
+
+func (api *oliveTinAPI) buildAllEntityDefinitions(entityMap entities.EntitiesByClass) []*apiv1.EntityDefinition {
+	entityNames := sortedEntityTypeNames(entityMap)
+	entityDefinitions := make([]*apiv1.EntityDefinition, 0, len(entityNames))
+
+	for _, name := range entityNames {
+		entityFile := entityFileForType(api.cfg, name)
+		properties := entityPropertiesFromFile(entityFile)
+		instances := buildSortedEntityInstances(name, entityMap[name], properties)
+
+		def := &apiv1.EntityDefinition{
+			Title:            name,
+			UsedOnDashboards: findDashboardsForEntity(name, api.cfg.Dashboards),
+			Icon:             entityTypeIcon(api.cfg, name),
+			Properties:       entityDefinitionProperties(properties),
+			TotalInstances:   int32(len(instances)),
+		}
+
+		if len(properties) == 0 {
+			def.Instances = instances
+		}
+
+		entityDefinitions = append(entityDefinitions, def)
+	}
+
+	return entityDefinitions
+}
+
+func (api *oliveTinAPI) buildFilteredEntityDefinitions(req *apiv1.GetEntitiesRequest, entityMap entities.EntitiesByClass) []*apiv1.EntityDefinition {
+	entityInstances, ok := entityMap[req.EntityType]
+	if !ok || len(entityInstances) == 0 {
+		return nil
+	}
+
+	entityFile := entityFileForType(api.cfg, req.EntityType)
+	properties := entityPropertiesFromFile(entityFile)
+	instances := buildSortedEntityInstances(req.EntityType, entityInstances, properties)
+	filtered := filterEntityInstances(instances, req.Filter)
+	pageSize := normalizeEntityInstancesPageSize(req.PageSize)
+	page := normalizeEntityInstancesPage(req.Page)
+
+	def := &apiv1.EntityDefinition{
+		Title:            req.EntityType,
+		UsedOnDashboards: findDashboardsForEntity(req.EntityType, api.cfg.Dashboards),
+		Icon:             entityTypeIcon(api.cfg, req.EntityType),
+		Properties:       entityDefinitionProperties(properties),
+		TotalInstances:   int32(len(filtered)),
+		Instances:        paginateEntityInstances(filtered, page, pageSize),
+	}
+
+	return []*apiv1.EntityDefinition{def}
+}
+
+func sortedEntityTypeNames(entityMap entities.EntitiesByClass) []string {
+	entityNames := make([]string, 0, len(entityMap))
+	for name := range entityMap {
+		entityNames = append(entityNames, name)
+	}
+	sort.Strings(entityNames)
+	return entityNames
+}
+
+func normalizeEntityInstancesPage(page int32) int32 {
+	if page < 1 {
+		return 1
+	}
+	return page
+}
+
+func normalizeEntityInstancesPageSize(pageSize int32) int32 {
+	if pageSize < 1 {
+		return defaultEntityInstancesPageSize
+	}
+	if pageSize > maxEntityInstancesPageSize {
+		return maxEntityInstancesPageSize
+	}
+	return pageSize
+}
+
+func filterEntityInstances(instances []*apiv1.Entity, filter string) []*apiv1.Entity {
+	filter = strings.TrimSpace(strings.ToLower(filter))
+	if filter == "" {
+		return instances
+	}
+
+	filtered := make([]*apiv1.Entity, 0, len(instances))
+	for _, instance := range instances {
+		if entityInstanceMatchesFilter(instance, filter) {
+			filtered = append(filtered, instance)
+		}
+	}
+
+	return filtered
+}
+
+func entityInstanceMatchesFilter(instance *apiv1.Entity, filter string) bool {
+	if strings.Contains(strings.ToLower(instance.Title), filter) {
+		return true
+	}
+
+	for _, value := range instance.Fields {
+		if strings.Contains(strings.ToLower(value), filter) {
+			return true
+		}
+	}
+
+	return false
+}
+
+func paginateEntityInstances(instances []*apiv1.Entity, page, pageSize int32) []*apiv1.Entity {
+	count := int64(len(instances))
+	start := int64(page-1) * int64(pageSize)
+	if start >= count {
+		return []*apiv1.Entity{}
+	}
+
+	end := start + int64(pageSize)
+	if end > count {
+		end = count
+	}
+
+	return instances[int(start):int(end)]
+}
+
+func entityFieldsForResponse(data any, properties []config.EntityProperty) map[string]string {
+	if len(properties) > 0 {
+		return entityListFields(data, properties)
+	}
+
+	return serializeEntityFields(data)
+}

+ 162 - 0
service/internal/api/api_entities_list_test.go

@@ -0,0 +1,162 @@
+package api
+
+import (
+	"context"
+	"testing"
+
+	"connectrpc.com/connect"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
+	config "github.com/OliveTin/OliveTin/internal/config"
+	"github.com/OliveTin/OliveTin/internal/entities"
+	"github.com/OliveTin/OliveTin/internal/executor"
+)
+
+func TestGetEntitiesPaginatesAndFiltersInstances(t *testing.T) {
+	entities.ClearEntitiesOfType("server")
+	entities.AddEntity("server", "0", map[string]any{"name": "alpha", "hostname": "alpha.example.com", "ip": "10.0.0.1"})
+	entities.AddEntity("server", "1", map[string]any{"name": "beta", "hostname": "beta.example.com", "ip": "10.0.0.2"})
+	entities.AddEntity("server", "2", map[string]any{"name": "gamma", "hostname": "gamma.example.com", "ip": "10.0.0.3"})
+	t.Cleanup(func() {
+		entities.ClearEntitiesOfType("server")
+	})
+
+	cfg := config.DefaultConfig()
+	cfg.Entities = []*config.EntityFile{
+		{
+			Name: "server",
+			Properties: []config.EntityProperty{
+				{Name: "hostname", Title: "Hostname"},
+				{Name: "ip", Title: "IP"},
+			},
+		},
+	}
+	cfg.Sanitize()
+
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
+	defer ts.Close()
+
+	filteredResp, err := client.GetEntities(context.Background(), connect.NewRequest(&apiv1.GetEntitiesRequest{
+		EntityType: "server",
+		Filter:     "beta",
+		Page:       1,
+		PageSize:   10,
+	}))
+	require.NoError(t, err)
+	require.Len(t, filteredResp.Msg.EntityDefinitions, 1)
+	assert.Equal(t, int32(1), filteredResp.Msg.EntityDefinitions[0].TotalInstances)
+	require.Len(t, filteredResp.Msg.EntityDefinitions[0].Instances, 1)
+	assert.Equal(t, "beta.example.com", filteredResp.Msg.EntityDefinitions[0].Instances[0].Fields["hostname"])
+
+	pagedResp, err := client.GetEntities(context.Background(), connect.NewRequest(&apiv1.GetEntitiesRequest{
+		EntityType: "server",
+		Page:       2,
+		PageSize:   1,
+	}))
+	require.NoError(t, err)
+	require.Len(t, pagedResp.Msg.EntityDefinitions, 1)
+	assert.Equal(t, int32(3), pagedResp.Msg.EntityDefinitions[0].TotalInstances)
+	require.Len(t, pagedResp.Msg.EntityDefinitions[0].Instances, 1)
+	assert.Equal(t, "1", pagedResp.Msg.EntityDefinitions[0].Instances[0].UniqueKey)
+}
+
+func TestGetEntitiesUnfilteredIncludesConfiguredProperties(t *testing.T) {
+	entities.ClearEntitiesOfType("server")
+	entities.AddEntity("server", "0", map[string]any{"name": "alpha", "hostname": "alpha.example.com", "ip": "10.0.0.1"})
+	t.Cleanup(func() {
+		entities.ClearEntitiesOfType("server")
+	})
+
+	cfg := config.DefaultConfig()
+	cfg.Entities = []*config.EntityFile{
+		{
+			Name: "server",
+			Properties: []config.EntityProperty{
+				{Name: "hostname", Title: "Hostname"},
+			},
+		},
+	}
+	cfg.Sanitize()
+
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
+	defer ts.Close()
+
+	resp, err := client.GetEntities(context.Background(), connect.NewRequest(&apiv1.GetEntitiesRequest{}))
+	require.NoError(t, err)
+
+	serverDef := findEntityDefinition(resp.Msg.EntityDefinitions, "server")
+	require.NotNil(t, serverDef)
+	require.Len(t, serverDef.Properties, 1)
+	assert.Equal(t, "hostname", serverDef.Properties[0].Name)
+	assert.Equal(t, int32(1), serverDef.TotalInstances)
+	assert.Empty(t, serverDef.Instances)
+}
+
+func TestPaginateEntityInstancesHandlesLargePageValues(t *testing.T) {
+	instances := []*apiv1.Entity{
+		{UniqueKey: "0"},
+		{UniqueKey: "1"},
+	}
+
+	assert.Empty(t, paginateEntityInstances(instances, 1<<30, 1))
+	assert.Empty(t, paginateEntityInstances(instances, 2, 1<<30))
+	assert.Equal(t, []*apiv1.Entity{{UniqueKey: "1"}}, paginateEntityInstances(instances, 2, 1))
+}
+
+func findEntityDefinition(definitions []*apiv1.EntityDefinition, title string) *apiv1.EntityDefinition {
+	for _, definition := range definitions {
+		if definition.Title == title {
+			return definition
+		}
+	}
+
+	return nil
+}
+
+func TestGetEntityRestrictsFieldsToConfiguredProperties(t *testing.T) {
+	entities.ClearEntitiesOfType("server")
+	entities.AddEntity("server", "0", map[string]any{
+		"name":     "alpha",
+		"hostname": "alpha.example.com",
+		"ip":       "10.0.0.1",
+		"groups":   []string{"admins"},
+	})
+	t.Cleanup(func() {
+		entities.ClearEntitiesOfType("server")
+	})
+
+	cfg := config.DefaultConfig()
+	cfg.Entities = []*config.EntityFile{
+		{
+			Name: "server",
+			Properties: []config.EntityProperty{
+				{Name: "hostname", Title: "Hostname"},
+				{Name: "ip", Title: "IP"},
+			},
+		},
+	}
+	cfg.Sanitize()
+
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
+	defer ts.Close()
+
+	resp, err := client.GetEntity(context.Background(), connect.NewRequest(&apiv1.GetEntityRequest{
+		Type:      "server",
+		UniqueKey: "0",
+	}))
+	require.NoError(t, err)
+	require.NotNil(t, resp.Msg)
+
+	assert.Equal(t, "alpha.example.com", resp.Msg.Fields["hostname"])
+	assert.Equal(t, "10.0.0.1", resp.Msg.Fields["ip"])
+	assert.NotContains(t, resp.Msg.Fields, "groups")
+	assert.NotContains(t, resp.Msg.Fields, "name")
+}

+ 130 - 0
service/internal/api/api_entity_related_actions.go

@@ -0,0 +1,130 @@
+package api
+
+import (
+	"sort"
+
+	apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
+	authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
+	config "github.com/OliveTin/OliveTin/internal/config"
+	"github.com/OliveTin/OliveTin/internal/entities"
+	"github.com/OliveTin/OliveTin/internal/executor"
+	"github.com/OliveTin/OliveTin/internal/tpl"
+)
+
+type relatedActionCandidate struct {
+	binding   *executor.ActionBinding
+	prefilled map[string]string
+}
+
+func (api *oliveTinAPI) relatedActionsForEntity(user *authpublic.AuthenticatedUser, entityType string, entity *entities.Entity) []*apiv1.EntityRelatedAction {
+	renderRequest := api.createDashboardRenderRequest(user, entityType, entity.UniqueKey)
+	populateActiveBindingStates(renderRequest)
+
+	candidates := collectRelatedActionCandidates(api, user, entityType, entity)
+	sortRelatedActionCandidates(candidates)
+
+	return buildEntityRelatedActions(candidates, renderRequest)
+}
+
+func collectRelatedActionCandidates(api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityType string, entity *entities.Entity) []relatedActionCandidate {
+	seen := make(map[string]bool)
+	candidates := make([]relatedActionCandidate, 0)
+
+	api.executor.MapActionBindingsLock.RLock()
+	defer api.executor.MapActionBindingsLock.RUnlock()
+
+	for _, binding := range api.executor.MapActionBindings {
+		tryAppendRelatedCandidate(&candidates, seen, api, user, entityType, entity, binding)
+	}
+
+	return candidates
+}
+
+func tryAppendRelatedCandidate(candidates *[]relatedActionCandidate, seen map[string]bool, api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityType string, entity *entities.Entity, binding *executor.ActionBinding) {
+	prefilled, ok := relatedPrefillForBinding(binding, entityType, entity)
+	if !ok || !bindingViewableForRelated(seen, api, user, binding) {
+		return
+	}
+
+	seen[binding.ID] = true
+	*candidates = append(*candidates, relatedActionCandidate{
+		binding:   binding,
+		prefilled: prefilled,
+	})
+}
+
+func bindingViewableForRelated(seen map[string]bool, api *oliveTinAPI, user *authpublic.AuthenticatedUser, binding *executor.ActionBinding) bool {
+	return binding != nil && binding.Action != nil && !seen[binding.ID] && api.userCanViewAction(user, binding.Action)
+}
+
+func relatedPrefillForBinding(binding *executor.ActionBinding, entityType string, entity *entities.Entity) (map[string]string, bool) {
+	if isEntityBoundBindingFor(binding, entityType, entity) {
+		return nil, true
+	}
+
+	return argumentEntityPrefill(binding, entityType, entity)
+}
+
+func argumentEntityPrefill(binding *executor.ActionBinding, entityType string, entity *entities.Entity) (map[string]string, bool) {
+	if binding == nil || binding.Entity != nil || binding.Action == nil {
+		return nil, false
+	}
+
+	prefilled := buildPrefilledArgumentsForEntity(binding.Action, entityType, entity)
+	return prefilled, len(prefilled) > 0
+}
+
+func isEntityBoundBindingFor(binding *executor.ActionBinding, entityType string, entity *entities.Entity) bool {
+	if entity == nil || !bindingHasEntity(binding) {
+		return false
+	}
+
+	return binding.Action.Entity == entityType && binding.Entity.UniqueKey == entity.UniqueKey
+}
+
+func bindingHasEntity(binding *executor.ActionBinding) bool {
+	return binding != nil && binding.Entity != nil && binding.Action != nil
+}
+
+func buildPrefilledArgumentsForEntity(action *config.Action, entityType string, entity *entities.Entity) map[string]string {
+	prefilled := make(map[string]string)
+
+	for i := range action.Arguments {
+		arg := &action.Arguments[i]
+		if arg.Entity != entityType || len(arg.Choices) != 1 {
+			continue
+		}
+
+		prefilled[arg.Name] = tpl.ParseTemplateOfActionBeforeExec(arg.Choices[0].Value, entity)
+	}
+
+	return prefilled
+}
+
+func sortRelatedActionCandidates(candidates []relatedActionCandidate) {
+	sort.SliceStable(candidates, func(i, j int) bool {
+		if candidates[i].binding.ConfigOrder != candidates[j].binding.ConfigOrder {
+			return candidates[i].binding.ConfigOrder < candidates[j].binding.ConfigOrder
+		}
+
+		return candidates[i].binding.ID < candidates[j].binding.ID
+	})
+}
+
+func buildEntityRelatedActions(candidates []relatedActionCandidate, rr *DashboardRenderRequest) []*apiv1.EntityRelatedAction {
+	result := make([]*apiv1.EntityRelatedAction, 0, len(candidates))
+
+	for _, candidate := range candidates {
+		action := buildAction(candidate.binding, rr)
+		if action == nil {
+			continue
+		}
+
+		result = append(result, &apiv1.EntityRelatedAction{
+			Action:             action,
+			PrefilledArguments: candidate.prefilled,
+		})
+	}
+
+	return result
+}

+ 270 - 0
service/internal/api/api_entity_related_actions_test.go

@@ -0,0 +1,270 @@
+package api
+
+import (
+	"context"
+	"testing"
+
+	"connectrpc.com/connect"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
+	authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
+	config "github.com/OliveTin/OliveTin/internal/config"
+	"github.com/OliveTin/OliveTin/internal/entities"
+	"github.com/OliveTin/OliveTin/internal/executor"
+)
+
+func setupHostEntityTestData(t *testing.T) {
+	t.Helper()
+	entities.ClearEntitiesOfType("host")
+	entities.AddEntity("host", "0", map[string]any{"name": "stuffbox", "hostname": "192.168.66.8"})
+	entities.AddEntity("host", "1", map[string]any{"name": "lurker", "hostname": "192.168.66.1"})
+	t.Cleanup(func() {
+		entities.ClearEntitiesOfType("host")
+	})
+}
+
+func buildRelatedActionsTestConfig(t *testing.T) (*config.Config, *authpublic.AuthenticatedUser, *authpublic.AuthenticatedUser) {
+	t.Helper()
+
+	cfg := config.DefaultConfig()
+	cfg.DefaultPermissions.View = false
+	cfg.DefaultPermissions.Exec = false
+
+	cfg.Actions = append(cfg.Actions,
+		&config.Action{
+			Title:  "Secret Entity Action",
+			Shell:  "echo secret",
+			Entity: "host",
+		},
+		&config.Action{
+			Title:  "Hidden Host Action",
+			Shell:  "echo hidden",
+			Entity: "host",
+			Hidden: true,
+		},
+		&config.Action{
+			ID:    "run_playbook",
+			Title: "Run Automation Playbook",
+			Shell: "host '{{ ansible_host }}'",
+			Arguments: []config.ActionArgument{
+				{
+					Name:   "ansible_host",
+					Title:  "Host",
+					Entity: "host",
+					Choices: []config.ActionArgumentChoice{
+						{Title: "{{ host.name }} ({{ host.hostname }})", Value: "{{ host.hostname }}"},
+					},
+				},
+			},
+		},
+		&config.Action{
+			Title:  "Public Host Action",
+			Shell:  "echo public",
+			Entity: "host",
+		},
+	)
+
+	cfg.AccessControlLists = append(cfg.AccessControlLists,
+		&config.AccessControlList{
+			Name:             "restricted",
+			MatchUsernames:   []string{"low"},
+			AddToEveryAction: true,
+			Permissions:      config.PermissionsList{View: false, Exec: false, Logs: false, Kill: false},
+		},
+		&config.AccessControlList{
+			Name:             "full",
+			MatchUsernames:   []string{"admin"},
+			AddToEveryAction: true,
+			Permissions:      config.PermissionsList{View: true, Exec: true, Logs: true, Kill: true},
+		},
+	)
+
+	cfg.Entities = []*config.EntityFile{
+		{File: "hosts.yaml", Name: "host", Icon: "ssh"},
+	}
+	cfg.Sanitize()
+
+	lowUser := &authpublic.AuthenticatedUser{Username: "low", Acls: []string{"restricted"}}
+	adminUser := &authpublic.AuthenticatedUser{Username: "admin", Acls: []string{"full"}}
+
+	return cfg, lowUser, adminUser
+}
+
+func getEntityRelatedActionTitles(t *testing.T, api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityType, entityKey string) []string {
+	t.Helper()
+
+	entity, ok := entities.GetEntityInstances(entityType)[entityKey]
+	require.True(t, ok, "entity %s/%s must exist", entityType, entityKey)
+
+	related := api.relatedActionsForEntity(user, entityType, entity)
+	titles := make([]string, 0, len(related))
+	for _, item := range related {
+		if item.Action != nil {
+			titles = append(titles, item.Action.Title)
+		}
+	}
+	return titles
+}
+
+func getEntityRelatedBindingIDs(t *testing.T, api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityType, entityKey string) []string {
+	t.Helper()
+
+	entity, ok := entities.GetEntityInstances(entityType)[entityKey]
+	require.True(t, ok, "entity %s/%s must exist", entityType, entityKey)
+
+	related := api.relatedActionsForEntity(user, entityType, entity)
+	ids := make([]string, 0, len(related))
+	for _, item := range related {
+		if item.Action != nil {
+			ids = append(ids, item.Action.BindingId)
+		}
+	}
+	return ids
+}
+
+func TestGetEntityRelatedActionsDeniesRestrictedView(t *testing.T) {
+	setupHostEntityTestData(t)
+	cfg, lowUser, _ := buildRelatedActionsTestConfig(t)
+
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	api := newServer(ex)
+
+	ids := getEntityRelatedBindingIDs(t, api, lowUser, "host", "0")
+	assert.Empty(t, ids)
+
+	titles := getEntityRelatedActionTitles(t, api, lowUser, "host", "0")
+	assert.Empty(t, titles)
+}
+
+func TestGetEntityRelatedActionsAllowsAdminView(t *testing.T) {
+	setupHostEntityTestData(t)
+	cfg, _, adminUser := buildRelatedActionsTestConfig(t)
+
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	api := newServer(ex)
+
+	titles := getEntityRelatedActionTitles(t, api, adminUser, "host", "0")
+	assert.Contains(t, titles, "Run Automation Playbook")
+	assert.Contains(t, titles, "Public Host Action")
+	assert.Contains(t, titles, "Secret Entity Action")
+}
+
+func TestGetEntityRelatedActionsExcludesHiddenActions(t *testing.T) {
+	setupHostEntityTestData(t)
+	cfg, _, adminUser := buildRelatedActionsTestConfig(t)
+
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	api := newServer(ex)
+
+	titles := getEntityRelatedActionTitles(t, api, adminUser, "host", "0")
+	assert.NotContains(t, titles, "Hidden Host Action")
+}
+
+func TestGetEntityRelatedActionsEntityBoundMatchesInstanceOnly(t *testing.T) {
+	setupHostEntityTestData(t)
+	cfg := config.DefaultConfig()
+	cfg.Actions = append(cfg.Actions, &config.Action{
+		Title:  "{{ host.name }} Wake",
+		Shell:  "echo wake",
+		Entity: "host",
+	})
+
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	api := newServer(ex)
+	user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
+
+	titlesHost0 := getEntityRelatedActionTitles(t, api, user, "host", "0")
+	assert.Len(t, titlesHost0, 1)
+	assert.Equal(t, "stuffbox Wake", titlesHost0[0])
+
+	titlesHost1 := getEntityRelatedActionTitles(t, api, user, "host", "1")
+	assert.Len(t, titlesHost1, 1)
+	assert.Equal(t, "lurker Wake", titlesHost1[0])
+}
+
+func TestGetEntityRelatedActionsPrefillsArgumentEntityValues(t *testing.T) {
+	setupHostEntityTestData(t)
+	cfg := config.DefaultConfig()
+	cfg.Actions = append(cfg.Actions, &config.Action{
+		ID:    "run_playbook",
+		Title: "Run Automation Playbook",
+		Shell: "host '{{ ansible_host }}'",
+		Arguments: []config.ActionArgument{
+			{
+				Name:   "ansible_host",
+				Title:  "Host",
+				Entity: "host",
+				Choices: []config.ActionArgumentChoice{
+					{Title: "{{ host.name }} ({{ host.hostname }})", Value: "{{ host.hostname }}"},
+				},
+			},
+		},
+	})
+
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	api := newServer(ex)
+	user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
+
+	entity := entities.GetEntityInstances("host")["0"]
+	related := api.relatedActionsForEntity(user, "host", entity)
+	require.Len(t, related, 1)
+	require.NotNil(t, related[0].Action)
+	assert.Equal(t, "run_playbook", related[0].Action.BindingId)
+	assert.Equal(t, "192.168.66.8", related[0].PrefilledArguments["ansible_host"])
+}
+
+func TestGetEntityDeniesGuestsWhenLoginRequired(t *testing.T) {
+	setupHostEntityTestData(t)
+	cfg := config.DefaultConfig()
+	cfg.AuthRequireGuestsToLogin = true
+
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
+	defer ts.Close()
+
+	_, err := client.GetEntity(context.Background(), connect.NewRequest(&apiv1.GetEntityRequest{
+		Type:      "host",
+		UniqueKey: "0",
+	}))
+	require.Error(t, err)
+	assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err))
+}
+
+func TestGetEntityReturnsRelatedActionsForAdmin(t *testing.T) {
+	setupHostEntityTestData(t)
+	cfg, _, adminUser := buildRelatedActionsTestConfig(t)
+	cfg.AuthHttpHeaderUsername = "X-Ot-User"
+
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
+	defer ts.Close()
+
+	req := connect.NewRequest(&apiv1.GetEntityRequest{
+		Type:      "host",
+		UniqueKey: "0",
+	})
+	req.Header().Set("X-Ot-User", adminUser.Username)
+
+	resp, err := client.GetEntity(context.Background(), req)
+	require.NoError(t, err)
+	require.NotNil(t, resp.Msg)
+	assert.Equal(t, "&#128272;", resp.Msg.Icon)
+
+	foundPlaybook := false
+	for _, related := range resp.Msg.RelatedActions {
+		if related.Action != nil && related.Action.BindingId == "run_playbook" {
+			foundPlaybook = true
+			assert.Equal(t, "192.168.66.8", related.PrefilledArguments["ansible_host"])
+		}
+	}
+	assert.True(t, foundPlaybook, "admin should see argument-entity related action in GetEntity response")
+}

+ 54 - 1
service/internal/api/api_justification.go

@@ -9,7 +9,10 @@ import (
 	apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
 	authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
 	"github.com/OliveTin/OliveTin/internal/config"
+	"github.com/OliveTin/OliveTin/internal/entities"
 	"github.com/OliveTin/OliveTin/internal/executor"
+	"github.com/OliveTin/OliveTin/internal/tpl"
+	log "github.com/sirupsen/logrus"
 )
 
 func validateJustificationRequired(action *config.Action, justification string, user *authpublic.AuthenticatedUser) error {
@@ -21,7 +24,7 @@ func validateJustificationRequired(action *config.Action, justification string,
 }
 
 func actionRequiresJustificationConfig(action *config.Action) bool {
-	return action != nil && action.Justification
+	return action != nil && action.RequiresJustification()
 }
 
 func justificationProvided(justification string, user *authpublic.AuthenticatedUser) bool {
@@ -40,6 +43,56 @@ func startActionArgumentsFromProto(args []*apiv1.StartActionArgument) map[string
 	return result
 }
 
+func resolveStartJustification(action *config.Action, binding *executor.ActionBinding, clientJustification string, args map[string]string) string {
+	if strings.TrimSpace(clientJustification) != "" {
+		return clientJustification
+	}
+
+	return resolveJustificationFromTemplate(action, binding, clientJustification, args)
+}
+
+func resolveJustificationFromTemplate(action *config.Action, binding *executor.ActionBinding, fallback string, args map[string]string) string {
+	templateText := action.JustificationTemplateText()
+	if templateText == "" {
+		return fallback
+	}
+
+	resolved, err := tpl.ParseTemplateWithActionContext(templateText, bindingEntity(binding), args)
+	if err != nil {
+		log.WithFields(justificationTemplateErrorFields(templateText, binding, err)).Warn("Failed to resolve justification template")
+		return fallback
+	}
+
+	return resolved
+}
+
+func justificationTemplateErrorFields(templateText string, binding *executor.ActionBinding, err error) log.Fields {
+	fields := log.Fields{
+		"template": templateText,
+		"error":    err,
+	}
+
+	entity := bindingEntity(binding)
+	if entity == nil {
+		return fields
+	}
+
+	fields["entityKey"] = entity.UniqueKey
+	if binding.Action != nil && binding.Action.Entity != "" {
+		fields["entityType"] = binding.Action.Entity
+	}
+
+	return fields
+}
+
+func bindingEntity(binding *executor.ActionBinding) *entities.Entity {
+	if binding == nil {
+		return nil
+	}
+
+	return binding.Entity
+}
+
 func restartRequiresJustificationError() error {
 	return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("justification is required for this action; use StartAction with a justification instead"))
 }

+ 87 - 10
service/internal/api/api_justification_test.go

@@ -3,7 +3,6 @@ package api
 import (
 	"context"
 	"testing"
-	"time"
 
 	"connectrpc.com/connect"
 	"github.com/google/uuid"
@@ -21,7 +20,7 @@ func TestStartActionRequiresJustificationForGuest(t *testing.T) {
 	action := &config.Action{
 		Title:         "Send email",
 		ID:            "send_email",
-		Justification: true,
+		Justification: config.JustificationRequiredNoTemplate,
 		Shell:         "echo done",
 	}
 	cfg.Actions = append(cfg.Actions, action)
@@ -49,19 +48,39 @@ func TestStartActionRequiresJustificationForGuest(t *testing.T) {
 	require.NoError(t, err)
 	require.NotEmpty(t, resp.Msg.ExecutionTrackingId)
 
-	time.Sleep(200 * time.Millisecond)
+	waitForLogJustification(t, ex, resp.Msg.ExecutionTrackingId, "New user registration foo@example.com")
+}
+
+func TestBuildActionExposesJustificationTemplate(t *testing.T) {
+	cfg := config.DefaultConfig()
+	action := &config.Action{
+		Title:         "Audited action",
+		ID:            "audited",
+		Justification: "{{ target }}",
+		Shell:         "echo hi",
+	}
+	cfg.Actions = append(cfg.Actions, action)
+
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	binding := ex.FindBindingWithNoEntity(action)
+	require.NotNil(t, binding)
 
-	entry, ok := ex.GetLog(resp.Msg.ExecutionTrackingId)
-	require.True(t, ok)
-	assert.Equal(t, "New user registration foo@example.com", entry.Justification)
+	pb := buildAction(binding, &DashboardRenderRequest{
+		cfg: cfg,
+		ex:  ex,
+	})
+
+	require.NotNil(t, pb)
+	assert.Equal(t, "{{ target }}", pb.Justification)
 }
 
-func TestBuildActionExposesJustificationFlag(t *testing.T) {
+func TestBuildActionExposesBlankRequiredJustification(t *testing.T) {
 	cfg := config.DefaultConfig()
 	action := &config.Action{
 		Title:         "Audited action",
 		ID:            "audited",
-		Justification: true,
+		Justification: config.JustificationRequiredNoTemplate,
 		Shell:         "echo hi",
 	}
 	cfg.Actions = append(cfg.Actions, action)
@@ -77,12 +96,70 @@ func TestBuildActionExposesJustificationFlag(t *testing.T) {
 	})
 
 	require.NotNil(t, pb)
-	assert.True(t, pb.Justification)
+	assert.Equal(t, config.JustificationRequiredNoTemplate, pb.Justification)
+}
+
+func TestResolveStartJustificationUsesTemplateWhenClientValueEmpty(t *testing.T) {
+	action := &config.Action{
+		Justification: "{{ ansible_host }}",
+	}
+	binding := &executor.ActionBinding{}
+
+	got := resolveStartJustification(action, binding, "", map[string]string{
+		"ansible_host": "192.168.66.8",
+	})
+	assert.Equal(t, "192.168.66.8", got)
+}
+
+func TestResolveStartJustificationPrefersClientValue(t *testing.T) {
+	action := &config.Action{
+		Justification: "{{ ansible_host }}",
+	}
+	binding := &executor.ActionBinding{}
+
+	got := resolveStartJustification(action, binding, "manual reason", map[string]string{
+		"ansible_host": "192.168.66.8",
+	})
+	assert.Equal(t, "manual reason", got)
+}
+
+func TestStartActionResolvesJustificationTemplateForGuest(t *testing.T) {
+	cfg := config.DefaultConfig()
+	action := &config.Action{
+		Title:         "Run playbook",
+		ID:            "run_playbook",
+		Justification: "{{ ansible_host }}",
+		Shell:         "echo done",
+		Arguments: []config.ActionArgument{
+			{Name: "ansible_host", Title: "Host"},
+		},
+	}
+	cfg.Actions = append(cfg.Actions, action)
+
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	binding := ex.FindBindingWithNoEntity(action)
+	require.NotNil(t, binding)
+
+	ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
+	defer ts.Close()
+
+	resp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
+		BindingId:        binding.ID,
+		UniqueTrackingId: uuid.NewString(),
+		Arguments: []*apiv1.StartActionArgument{
+			{Name: "ansible_host", Value: "stuffbox"},
+		},
+	}))
+	require.NoError(t, err)
+	require.NotEmpty(t, resp.Msg.ExecutionTrackingId)
+
+	waitForLogJustification(t, ex, resp.Msg.ExecutionTrackingId, "stuffbox")
 }
 
 func TestValidateJustificationRequiredAllowsSystemUser(t *testing.T) {
 	cfg := config.DefaultConfig()
-	action := &config.Action{Title: "Cron job", Justification: true}
+	action := &config.Action{Title: "Cron job", Justification: config.JustificationRequiredNoTemplate}
 
 	err := validateJustificationRequired(action, "", auth.UserFromSystem(cfg, "cron"))
 	require.NoError(t, err)

+ 1 - 1
service/internal/api/api_log_arguments.go

@@ -47,7 +47,7 @@ func restartArgumentsIncompleteError() error {
 }
 
 func validateRestartLogEntry(entry *executor.InternalLogEntry) error {
-	if entry.Binding.Action.Justification && strings.TrimSpace(entry.Justification) == "" {
+	if entry.Binding.Action.RequiresJustification() && strings.TrimSpace(entry.Justification) == "" {
 		return restartRequiresJustificationError()
 	}
 

+ 20 - 2
service/internal/api/api_log_arguments_test.go

@@ -40,6 +40,22 @@ func waitForLogArguments(t *testing.T, ex *executor.Executor, trackingID string)
 	return nil
 }
 
+func waitForLogFinished(t *testing.T, ex *executor.Executor, trackingID string) {
+	t.Helper()
+
+	deadline := time.Now().Add(2 * time.Second)
+	for time.Now().Before(deadline) {
+		entry, ok := ex.GetLog(trackingID)
+		if ok && entry.ExecutionFinished {
+			return
+		}
+
+		time.Sleep(5 * time.Millisecond)
+	}
+
+	t.Fatalf("timed out waiting for execution to finish on log %s", trackingID)
+}
+
 func waitForLogJustification(t *testing.T, ex *executor.Executor, trackingID, expected string) {
 	t.Helper()
 
@@ -166,6 +182,7 @@ func TestRestartActionReusesStoredArguments(t *testing.T) {
 
 	originalArgs := waitForLogArguments(t, ex, startResp.Msg.ExecutionTrackingId)
 	assert.Equal(t, "server-a", originalArgs["host"])
+	waitForLogFinished(t, ex, startResp.Msg.ExecutionTrackingId)
 
 	restartResp, err := client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{
 		ExecutionTrackingId: startResp.Msg.ExecutionTrackingId,
@@ -286,7 +303,7 @@ func TestRestartActionRequiresJustificationWhenMissingFromStoredLog(t *testing.T
 			Title:         "Dangerous action",
 			Shell:         "echo ok",
 			MaxConcurrent: 1,
-			Justification: true,
+			Justification: config.JustificationRequiredNoTemplate,
 		},
 	}
 
@@ -319,7 +336,7 @@ func TestRestartActionReusesStoredJustificationViaStartActionPath(t *testing.T)
 			Title:         "Dangerous action",
 			Shell:         "echo ok",
 			MaxConcurrent: 1,
-			Justification: true,
+			Justification: config.JustificationRequiredNoTemplate,
 		},
 	}
 
@@ -338,6 +355,7 @@ func TestRestartActionReusesStoredJustificationViaStartActionPath(t *testing.T)
 	require.NoError(t, err)
 
 	waitForLogJustification(t, ex, startResp.Msg.ExecutionTrackingId, "maintenance window")
+	waitForLogFinished(t, ex, startResp.Msg.ExecutionTrackingId)
 
 	restartResp, err := client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{
 		ExecutionTrackingId: startResp.Msg.ExecutionTrackingId,

+ 102 - 4
service/internal/api/api_test.go

@@ -120,6 +120,15 @@ func TestGetActionsAndStart(t *testing.T) {
 
 func TestGetEntities(t *testing.T) {
 	cfg := config.DefaultConfig()
+	cfg.Entities = []*config.EntityFile{
+		{
+			Name: "server",
+			Properties: []config.EntityProperty{
+				{Name: "hostname", Title: "Hostname"},
+			},
+		},
+	}
+	cfg.Sanitize()
 
 	ts, client := getNewTestServerAndClient(cfg)
 	defer ts.Close()
@@ -138,6 +147,26 @@ func TestGetEntities(t *testing.T) {
 	validateEntityOrderAndStructure(t, entityDefinitions)
 	validateNoDuplicates(t, entityDefinitions)
 	validateConsistency(t, client, entityDefinitions)
+	validateEntityListProperties(t, client)
+}
+
+func validateEntityListProperties(t *testing.T, client apiv1connect.OliveTinApiServiceClient) {
+	resp, err := client.GetEntities(context.Background(), connect.NewRequest(&apiv1.GetEntitiesRequest{
+		EntityType: "server",
+		Page:       1,
+		PageSize:   10,
+	}))
+	require.NoError(t, err)
+	require.Len(t, resp.Msg.EntityDefinitions, 1)
+
+	serverDef := resp.Msg.EntityDefinitions[0]
+	require.NotNil(t, serverDef, "server entity definition should be present")
+	require.Len(t, serverDef.Properties, 1)
+	assert.Equal(t, "hostname", serverDef.Properties[0].Name)
+	assert.Equal(t, "Hostname", serverDef.Properties[0].Title)
+	assert.Equal(t, int32(3), serverDef.TotalInstances)
+	require.Len(t, serverDef.Instances, 3)
+	assert.Equal(t, "alpha.example.com", serverDef.Instances[0].Fields["hostname"])
 }
 
 func setupTestEntities() {
@@ -166,10 +195,8 @@ func validateEntityOrderAndStructure(t *testing.T, entityDefinitions []*apiv1.En
 	assert.Equal(t, "postgres", entityDefinitions[1].Instances[1].UniqueKey, "Second database instance should be 'postgres' (alphabetically second)")
 
 	assert.Equal(t, "server", entityDefinitions[2].Title, "Third entity should be 'server' (alphabetically third)")
-	assert.Equal(t, 3, len(entityDefinitions[2].Instances), "Server should have 3 instances")
-	assert.Equal(t, "alpha", entityDefinitions[2].Instances[0].UniqueKey, "First server instance should be 'alpha' (alphabetically first)")
-	assert.Equal(t, "beta", entityDefinitions[2].Instances[1].UniqueKey, "Second server instance should be 'beta' (alphabetically second)")
-	assert.Equal(t, "zebra", entityDefinitions[2].Instances[2].UniqueKey, "Third server instance should be 'zebra' (alphabetically third)")
+	assert.Equal(t, 0, len(entityDefinitions[2].Instances), "Server instances should not be included in bulk list response")
+	assert.Equal(t, int32(3), entityDefinitions[2].TotalInstances, "Server should report total instance count")
 }
 
 func validateNoDuplicates(t *testing.T, entityDefinitions []*apiv1.EntityDefinition) {
@@ -358,6 +385,54 @@ func testWithEntity(t *testing.T, binding *executor.ActionBinding, rr *Dashboard
 	assert.Equal(t, expectedCanExec, actionResult.CanExec, message)
 }
 
+// buildExecWithoutLogsTestConfig returns config for GHSA-jm28-2wcr-qf3h: user "runner" may exec but not read logs.
+func buildExecWithoutLogsTestConfig(t *testing.T) (*config.Config, *authpublic.AuthenticatedUser) {
+	t.Helper()
+	cfg := config.DefaultConfig()
+	cfg.AuthHttpHeaderUsername = "X-Ot-User"
+	cfg.DefaultPermissions.View = false
+	cfg.DefaultPermissions.Exec = false
+	cfg.DefaultPermissions.Logs = false
+
+	cfg.Actions = append(cfg.Actions, &config.Action{
+		ID:    "run_only",
+		Title: "Run Only",
+		Shell: "echo sensitive-output",
+		Icon:  "🔒",
+	})
+
+	cfg.AccessControlLists = append(cfg.AccessControlLists, &config.AccessControlList{
+		Name:             "runner",
+		MatchUsernames:   []string{"runner"},
+		AddToEveryAction: true,
+		Permissions:      config.PermissionsList{View: true, Exec: true, Logs: false, Kill: false},
+	})
+
+	runner := &authpublic.AuthenticatedUser{Username: "runner"}
+	runner.BuildUserAcls(cfg)
+	return cfg, runner
+}
+
+// TestStartActionAndWaitDeniesLogsPermission (GHSA-jm28-2wcr-qf3h) asserts sync execution endpoints
+// enforce logs ACL and do not return action output to users allowed to exec but not read logs.
+func TestStartActionAndWaitDeniesLogsPermission(t *testing.T) {
+	cfg, _ := buildExecWithoutLogsTestConfig(t)
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
+	defer ts.Close()
+
+	req := connect.NewRequest(&apiv1.StartActionAndWaitRequest{
+		ActionId: "run_only",
+	})
+	req.Header().Set("X-Ot-User", "runner")
+
+	_, err := client.StartActionAndWait(context.Background(), req)
+	require.Error(t, err)
+	assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err),
+		"user with exec:true and logs:false must not receive log output from StartActionAndWait")
+}
+
 // buildViewPermissionTestConfig returns config and users for GHSA view-permission tests:
 // one action "secret_action", ACL "restricted" (view:false, logs:false) for user "low", ACL "full" (view:true, logs:true) for user "admin".
 func buildViewPermissionTestConfig(t *testing.T) (*config.Config, *authpublic.AuthenticatedUser, *authpublic.AuthenticatedUser) {
@@ -919,3 +994,26 @@ func TestBuildActionIncludesGroups(t *testing.T) {
 	assert.Equal(t, "missing", actionResult.Groups[1].Name)
 	assert.Equal(t, int32(0), actionResult.Groups[1].MaxConcurrent)
 }
+
+func TestBuildChoicesExpandsChecklistEntityChoices(t *testing.T) {
+	entities.AddEntity("room", "0", map[string]any{"hostname": "attic"})
+	entities.AddEntity("room", "1", map[string]any{"hostname": "basement"})
+	t.Cleanup(func() {
+		entities.ClearEntitiesOfType("room")
+	})
+
+	arg := config.ActionArgument{
+		Type:   "checklist",
+		Entity: "room",
+		Choices: []config.ActionArgumentChoice{
+			{Title: "{{ room.hostname }}", Value: "{{ room.hostname }}"},
+		},
+	}
+
+	choices := buildChoices(arg)
+	require.Len(t, choices, 2)
+	assert.Equal(t, "attic", choices[0].Value)
+	assert.Equal(t, "attic", choices[0].Title)
+	assert.Equal(t, "basement", choices[1].Value)
+	assert.Equal(t, "basement", choices[1].Title)
+}

+ 32 - 0
service/internal/auth/otoauth2/restapi_auth_oauth2.go

@@ -62,8 +62,14 @@ type oauth2State struct {
 	providerName   string
 	Username       string
 	Usergroup      string
+	createdAt      time.Time
 }
 
+const (
+	oauthStateMaxAge     = 900 // matches olivetin-sid-oauth cookie MaxAge
+	oauthStateMaxEntries = 10000
+)
+
 func assignIfEmpty(target *string, value string) {
 	if *target == "" {
 		*target = value
@@ -129,6 +135,19 @@ func (h *OAuth2Handler) setOAuthCallbackCookie(w http.ResponseWriter, r *http.Re
 	http.SetCookie(w, cookie)
 }
 
+func (h *OAuth2Handler) deleteOAuthStateLocked(state string) {
+	delete(h.registeredStates, state)
+}
+
+func (h *OAuth2Handler) sweepExpiredOAuthStatesLocked(now time.Time) {
+	cutoff := now.Add(-oauthStateMaxAge * time.Second)
+	for state, entry := range h.registeredStates {
+		if entry.createdAt.Before(cutoff) {
+			delete(h.registeredStates, state)
+		}
+	}
+}
+
 func (h *OAuth2Handler) HandleOAuthLogin(w http.ResponseWriter, r *http.Request) {
 	state, err := randString(16)
 
@@ -147,10 +166,17 @@ func (h *OAuth2Handler) HandleOAuthLogin(w http.ResponseWriter, r *http.Request)
 	}
 
 	h.mu.Lock()
+	h.sweepExpiredOAuthStatesLocked(time.Now())
+	if len(h.registeredStates) >= oauthStateMaxEntries {
+		h.mu.Unlock()
+		http.Error(w, "OAuth login temporarily unavailable", http.StatusServiceUnavailable)
+		return
+	}
 	h.registeredStates[state] = &oauth2State{
 		providerConfig: provider,
 		providerName:   providerName,
 		Username:       "",
+		createdAt:      time.Now(),
 	}
 	h.mu.Unlock()
 
@@ -177,6 +203,9 @@ func (h *OAuth2Handler) checkOAuthCallbackCookie(w http.ResponseWriter, r *http.
 
 	if !h.validateStateMatch(r.URL.Query().Get("state"), state) {
 		log.Errorf("State mismatch: %v != %v", r.URL.Query().Get("state"), state)
+		h.mu.Lock()
+		h.deleteOAuthStateLocked(state)
+		h.mu.Unlock()
 		http.Error(w, "State mismatch", http.StatusBadRequest)
 		return nil, state, false
 	}
@@ -186,6 +215,9 @@ func (h *OAuth2Handler) checkOAuthCallbackCookie(w http.ResponseWriter, r *http.
 	h.mu.RUnlock()
 	if !ok {
 		log.Errorf("State not found in server: %v", state)
+		h.mu.Lock()
+		h.deleteOAuthStateLocked(state)
+		h.mu.Unlock()
 		http.Error(w, "State not found in server", http.StatusBadRequest)
 		return nil, state, false
 	}

+ 66 - 0
service/internal/auth/otoauth2/restapi_auth_oauth2_test.go

@@ -0,0 +1,66 @@
+package otoauth2
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"strconv"
+	"testing"
+	"time"
+
+	config "github.com/OliveTin/OliveTin/internal/config"
+	"github.com/stretchr/testify/assert"
+	"golang.org/x/oauth2"
+)
+
+func TestSweepExpiredOAuthStatesLocked(t *testing.T) {
+	h := &OAuth2Handler{
+		registeredStates: make(map[string]*oauth2State),
+	}
+
+	h.registeredStates["fresh"] = &oauth2State{
+		providerName: "test",
+		createdAt:    time.Now(),
+	}
+	h.registeredStates["stale"] = &oauth2State{
+		providerName: "test",
+		createdAt:    time.Now().Add(-2 * oauthStateMaxAge * time.Second),
+	}
+
+	h.sweepExpiredOAuthStatesLocked(time.Now())
+
+	_, freshFound := h.registeredStates["fresh"]
+	_, staleFound := h.registeredStates["stale"]
+	assert.True(t, freshFound)
+	assert.False(t, staleFound)
+}
+
+func TestHandleOAuthLoginRejectsWhenStateMapFull(t *testing.T) {
+	cfg := config.DefaultConfig()
+	cfg.AuthOAuth2Providers = map[string]*config.OAuth2Provider{
+		"test": {
+			Name:         "test",
+			ClientID:     "id",
+			ClientSecret: "secret",
+			AuthUrl:      "https://example.com/auth",
+			TokenUrl:     "https://example.com/token",
+		},
+	}
+
+	h := NewOAuth2Handler(cfg)
+	h.registeredStates = make(map[string]*oauth2State, oauthStateMaxEntries)
+	for i := 0; i < oauthStateMaxEntries; i++ {
+		h.registeredStates[strconv.Itoa(i)] = &oauth2State{
+			providerConfig: &oauth2.Config{},
+			providerName:   "test",
+			createdAt:      time.Now(),
+		}
+	}
+
+	req := httptest.NewRequest(http.MethodGet, "/oauth/login?provider=test", nil)
+	rec := httptest.NewRecorder()
+
+	h.HandleOAuthLogin(rec, req)
+
+	assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
+	assert.Equal(t, oauthStateMaxEntries, len(h.registeredStates))
+}

+ 55 - 0
service/internal/config/checklist.go

@@ -0,0 +1,55 @@
+package config
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+)
+
+// ParseChecklistValue parses a checklist argument wire value.
+// Values must be JSON arrays, or a single choice without commas.
+func ParseChecklistValue(value string) ([]string, error) {
+	trimmed := strings.TrimSpace(value)
+	if trimmed == "" {
+		return nil, nil
+	}
+
+	if strings.HasPrefix(trimmed, "[") {
+		return parseJSONChecklistValue(trimmed)
+	}
+
+	if strings.Contains(trimmed, ",") {
+		return nil, fmt.Errorf("checklist value uses legacy comma-separated format; use a JSON array instead")
+	}
+
+	return []string{trimmed}, nil
+}
+
+func parseJSONChecklistValue(value string) ([]string, error) {
+	var values []string
+	if err := json.Unmarshal([]byte(value), &values); err != nil {
+		return nil, fmt.Errorf("invalid checklist JSON value: %w", err)
+	}
+
+	for _, segment := range values {
+		if strings.TrimSpace(segment) == "" {
+			return nil, fmt.Errorf("checklist value contains an empty segment")
+		}
+	}
+
+	return values, nil
+}
+
+// FormatChecklistValue serializes selected checklist values for API transport.
+func FormatChecklistValue(values []string) (string, error) {
+	if len(values) == 0 {
+		return "", nil
+	}
+
+	encoded, err := json.Marshal(values)
+	if err != nil {
+		return "", fmt.Errorf("encoding checklist value: %w", err)
+	}
+
+	return string(encoded), nil
+}

+ 58 - 0
service/internal/config/checklist_test.go

@@ -0,0 +1,58 @@
+package config
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestParseChecklistValueJSON(t *testing.T) {
+	t.Parallel()
+
+	values, err := ParseChecklistValue(`["documents","photos"]`)
+	require.NoError(t, err)
+	assert.Equal(t, []string{"documents", "photos"}, values)
+
+	values, err = ParseChecklistValue(`["kitchen,bedroom","hallway"]`)
+	require.NoError(t, err)
+	assert.Equal(t, []string{"kitchen,bedroom", "hallway"}, values)
+}
+
+func TestParseChecklistValueSingleValue(t *testing.T) {
+	t.Parallel()
+
+	values, err := ParseChecklistValue("documents")
+	require.NoError(t, err)
+	assert.Equal(t, []string{"documents"}, values)
+}
+
+func TestParseChecklistValueRejectsLegacyCommaSeparated(t *testing.T) {
+	t.Parallel()
+
+	_, err := ParseChecklistValue("documents, photos")
+	require.Error(t, err)
+}
+
+func TestParseChecklistValueRejectsEmptyJSONSegment(t *testing.T) {
+	t.Parallel()
+
+	_, err := ParseChecklistValue(`["documents","","photos"]`)
+	require.Error(t, err)
+}
+
+func TestFormatChecklistValueJSON(t *testing.T) {
+	t.Parallel()
+
+	encoded, err := FormatChecklistValue([]string{"documents", "photos"})
+	require.NoError(t, err)
+	assert.Equal(t, `["documents","photos"]`, encoded)
+
+	encoded, err = FormatChecklistValue([]string{"kitchen,bedroom"})
+	require.NoError(t, err)
+	assert.Equal(t, `["kitchen,bedroom"]`, encoded)
+
+	encoded, err = FormatChecklistValue(nil)
+	require.NoError(t, err)
+	assert.Empty(t, encoded)
+}

+ 30 - 4
service/internal/config/config.go

@@ -7,6 +7,9 @@ import (
 // ReservedArgumentNamePrefix is reserved for OliveTin-injected system arguments.
 const ReservedArgumentNamePrefix = "ot_"
 
+// JustificationRequiredNoTemplate requires a manual justification with no prefilled template.
+const JustificationRequiredNoTemplate = " "
+
 // Action represents the core functionality of OliveTin - commands that show up
 // as buttons in the UI.
 type Action struct {
@@ -35,7 +38,23 @@ type Action struct {
 	SaveLogs               SaveLogsConfig   `koanf:"saveLogs"`
 	EnabledExpression      string           `koanf:"enabledExpression"`
 	Groups                 []string         `koanf:"groups"`
-	Justification          bool             `koanf:"justification"`
+	Justification          string           `koanf:"justification"`
+}
+
+func (action *Action) RequiresJustification() bool {
+	return action != nil && action.Justification != ""
+}
+
+func (action *Action) JustificationTemplateText() string {
+	if !action.RequiresJustification() {
+		return ""
+	}
+
+	if action.Justification == JustificationRequiredNoTemplate {
+		return ""
+	}
+
+	return action.Justification
 }
 
 // ActionGroup defines shared limits and metadata for a set of actions.
@@ -87,9 +106,16 @@ type WebhookConfig struct {
 // Entity represents a "thing" that can have multiple actions associated with it.
 // for example, a media player with a start and stop action.
 type EntityFile struct {
-	File string `koanf:"file"`
-	Name string `koanf:"name"`
-	Icon string `koanf:"icon"`
+	File       string           `koanf:"file"`
+	Name       string           `koanf:"name"`
+	Icon       string           `koanf:"icon"`
+	Properties []EntityProperty `koanf:"properties"`
+}
+
+// EntityProperty defines a column shown when listing entity instances in the UI.
+type EntityProperty struct {
+	Name  string `koanf:"name"`
+	Title string `koanf:"title"`
 }
 
 // PermissionsList defines what users can do with an action.

+ 13 - 0
service/internal/config/config_reloader.go

@@ -55,6 +55,7 @@ func unmarshalRoot(k *koanf.Koanf, cfg *Config) bool {
 		DecoderConfig: &mapstructure.DecoderConfig{
 			DecodeHook: mapstructure.ComposeDecodeHookFunc(
 				envDecodeHookFunc,
+				justificationDecodeHookFunc,
 				mapstructure.StringToTimeDurationHookFunc(),
 				mapstructure.TextUnmarshallerHookFunc(),
 			),
@@ -259,6 +260,18 @@ func mergeFunc(src map[string]interface{}, dest map[string]interface{}) error {
 
 var envRegex = regexp.MustCompile(`\${{ *?(\S+) *?}}`)
 
+func justificationDecodeHookFunc(from reflect.Type, to reflect.Type, data any) (any, error) {
+	if to.Kind() != reflect.String || from.Kind() != reflect.Bool {
+		return data, nil
+	}
+
+	if data.(bool) {
+		return JustificationRequiredNoTemplate, nil
+	}
+
+	return "", nil
+}
+
 func envDecodeHookFunc(from reflect.Type, to reflect.Type, data any) (any, error) {
 	log.Debugf("envDecodeHookFunc called: from=%v, to=%v, data=%v", from, to, data)
 	if from.Kind() != reflect.String {

+ 58 - 0
service/internal/config/justification_compat_test.go

@@ -0,0 +1,58 @@
+package config
+
+import (
+	"testing"
+
+	"github.com/knadh/koanf/parsers/yaml"
+	"github.com/knadh/koanf/providers/rawbytes"
+	"github.com/knadh/koanf/v2"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestJustificationDecodeHookMigratesLegacyBooleanFalse(t *testing.T) {
+	cfg := loadJustificationCompatConfig(t, `
+actions:
+  - title: Legacy disabled
+    shell: echo hi
+    justification: false
+`)
+
+	require.Len(t, cfg.Actions, 1)
+	assert.Empty(t, cfg.Actions[0].Justification)
+}
+
+func TestJustificationDecodeHookMigratesLegacyBooleanTrue(t *testing.T) {
+	cfg := loadJustificationCompatConfig(t, `
+actions:
+  - title: Legacy required
+    shell: echo hi
+    justification: true
+`)
+
+	require.Len(t, cfg.Actions, 1)
+	assert.Equal(t, JustificationRequiredNoTemplate, cfg.Actions[0].Justification)
+}
+
+func TestSanitizeJustificationMigratesWeaklyTypedLegacyStrings(t *testing.T) {
+	action := &Action{Justification: "false"}
+	action.sanitizeJustification()
+	assert.Empty(t, action.Justification)
+
+	action.Justification = "true"
+	action.sanitizeJustification()
+	assert.Equal(t, JustificationRequiredNoTemplate, action.Justification)
+}
+
+func loadJustificationCompatConfig(t *testing.T, yamlBody string) *Config {
+	t.Helper()
+
+	k := koanf.New(".")
+	require.NoError(t, k.Load(rawbytes.Provider([]byte(yamlBody)), yaml.Parser()))
+
+	cfg := DefaultConfig()
+	require.True(t, unmarshalRoot(k, cfg))
+	cfg.Sanitize()
+
+	return cfg
+}

+ 105 - 0
service/internal/config/sanitize.go

@@ -30,10 +30,15 @@ func (cfg *Config) Sanitize() {
 
 	cfg.sanitizeActionGroups()
 	cfg.sanitizeActionGroupReferences()
+	cfg.sanitizeEntities()
 
 	if err := cfg.validateReservedActionArgumentNames(); err != nil {
 		log.Fatalf("%v", err)
 	}
+
+	if err := cfg.validateChecklistChoiceValues(); err != nil {
+		log.Fatalf("%v", err)
+	}
 }
 
 func (cfg *Config) validateReservedActionArgumentNames() error {
@@ -60,6 +65,48 @@ func (action *Action) validateReservedArgumentNames() error {
 	return nil
 }
 
+func (cfg *Config) validateChecklistChoiceValues() error {
+	for _, action := range cfg.Actions {
+		if err := action.validateChecklistChoiceValues(); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func (action *Action) validateChecklistChoiceValues() error {
+	if action == nil {
+		return nil
+	}
+
+	for _, arg := range action.Arguments {
+		if err := validateChecklistChoicesForArgument(action.Title, arg); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func validateChecklistChoicesForArgument(actionTitle string, arg ActionArgument) error {
+	if arg.Type != "checklist" {
+		return nil
+	}
+
+	for _, choice := range arg.Choices {
+		if strings.TrimSpace(choice.Value) == "" {
+			return fmt.Errorf(
+				`action %q argument %q choice value must not be empty`,
+				actionTitle,
+				arg.Name,
+			)
+		}
+	}
+
+	return nil
+}
+
 func (cfg *Config) sanitizeDashboardsForInlineActions() {
 	for _, dashboard := range cfg.Dashboards {
 		cfg.sanitizeDashboardComponentForInlineActions(dashboard)
@@ -180,6 +227,7 @@ func (action *Action) sanitize(cfg *Config) {
 	action.ID = getActionID(action)
 	action.Icon = lookupHTMLIcon(action.Icon, cfg.DefaultIconForActions)
 	migrateActionOnClick(action)
+	action.sanitizeJustification()
 	action.OnClick = sanitizeOnClick(action.OnClick, cfg)
 	action.PopupOnStart = action.OnClick
 
@@ -243,6 +291,25 @@ func (cfg *Config) sanitizeActionGroupReferences() {
 	}
 }
 
+func (cfg *Config) sanitizeEntities() {
+	for _, entityFile := range cfg.Entities {
+		if entityFile == nil {
+			continue
+		}
+
+		entityFile.Icon = lookupHTMLIcon(entityFile.Icon, "")
+		sanitizeEntityProperties(entityFile)
+	}
+}
+
+func sanitizeEntityProperties(entityFile *EntityFile) {
+	for idx := range entityFile.Properties {
+		if entityFile.Properties[idx].Title == "" {
+			entityFile.Properties[idx].Title = entityFile.Properties[idx].Name
+		}
+	}
+}
+
 func (cfg *Config) warnInvalidActionGroupReference(action *Action, groupName string) {
 	group, found := cfg.ActionGroups[groupName]
 	if !found {
@@ -402,6 +469,15 @@ func migrateActionOnClick(action *Action) {
 	}
 }
 
+func (action *Action) sanitizeJustification() {
+	switch action.Justification {
+	case "false":
+		action.Justification = ""
+	case "true":
+		action.Justification = JustificationRequiredNoTemplate
+	}
+}
+
 func shouldMigrateDefaultOnClickFromPopup(onClick, popupOnStart string) bool {
 	if popupOnStart == "" {
 		return false
@@ -441,10 +517,39 @@ func (arg *ActionArgument) sanitize() {
 	}
 
 	arg.sanitizeNoType()
+	arg.sanitizeChecklist()
 
 	// Default value validation runs in executor at config load (validateArgumentDefaults).
 }
 
+func (arg *ActionArgument) sanitizeChecklist() {
+	if arg.Type != "checklist" {
+		return
+	}
+
+	arg.warnMissingChecklistChoices()
+	arg.warnInvalidChecklistEntityTemplate()
+}
+
+func (arg *ActionArgument) warnMissingChecklistChoices() {
+	if len(arg.Choices) == 0 {
+		log.WithFields(log.Fields{
+			"arg": arg.Name,
+		}).Warn("Checklist argument has no choices defined")
+	}
+}
+
+func (arg *ActionArgument) warnInvalidChecklistEntityTemplate() {
+	if arg.Entity == "" || len(arg.Choices) == 1 {
+		return
+	}
+
+	log.WithFields(log.Fields{
+		"arg":    arg.Name,
+		"entity": arg.Entity,
+	}).Warn("Checklist argument with entity should define exactly one choice template")
+}
+
 func (arg *ActionArgument) sanitizeNoType() {
 	if len(arg.Choices) == 0 && arg.Type == "" {
 		log.WithFields(log.Fields{

+ 22 - 0
service/internal/config/sanitize_test.go

@@ -271,3 +271,25 @@ func TestValidateUniqueLocalUserAPIKeys(t *testing.T) {
 	})
 	require.NoError(t, err)
 }
+
+func TestValidateChecklistChoiceValuesAllowsCommas(t *testing.T) {
+	t.Parallel()
+
+	c := DefaultConfig()
+	c.Actions = append(c.Actions, &Action{
+		Title: "Checklist commas",
+		Shell: "true",
+		Arguments: []ActionArgument{
+			{
+				Name: "segments",
+				Type: "checklist",
+				Choices: []ActionArgumentChoice{
+					{Value: "kitchen,bedroom"},
+				},
+			},
+		},
+	})
+
+	err := c.validateChecklistChoiceValues()
+	require.NoError(t, err)
+}

+ 182 - 14
service/internal/executor/arguments.go

@@ -191,6 +191,10 @@ func typecheckActionArgumentFound(value string, arg *config.ActionArgument) erro
 		return typecheckNull(arg)
 	}
 
+	if arg.Type == "checklist" {
+		return typecheckChecklist(value, arg)
+	}
+
 	if len(arg.Choices) > 0 {
 		return typecheckChoice(value, arg)
 	}
@@ -211,6 +215,8 @@ func TypeSafetyCheck(name string, value string, argumentType string) error {
 		return nil
 	case "checkbox":
 		return nil
+	case "checklist":
+		return nil
 	case "email":
 		return typeSafetyCheckEmail(value)
 	case "url":
@@ -230,6 +236,37 @@ func typecheckNull(arg *config.ActionArgument) error {
 	return nil
 }
 
+func typecheckChecklist(value string, arg *config.ActionArgument) error {
+	if len(arg.Choices) == 0 {
+		return fmt.Errorf("checklist argument %q requires choices", arg.Name)
+	}
+
+	segments, err := config.ParseChecklistValue(value)
+	if err != nil {
+		return err
+	}
+
+	return typecheckChecklistSegments(segments, arg)
+}
+
+func typecheckChecklistSegments(segments []string, arg *config.ActionArgument) error {
+	for _, segment := range segments {
+		if err := typecheckChecklistSegment(segment, arg); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func typecheckChecklistSegment(segment string, arg *config.ActionArgument) error {
+	if segment == "" {
+		return fmt.Errorf("checklist argument %q contains an empty segment", arg.Name)
+	}
+
+	return typecheckChoice(segment, arg)
+}
+
 func typecheckChoice(value string, arg *config.ActionArgument) error {
 	if arg.Entity != "" {
 		return typecheckChoiceEntity(value, arg)
@@ -277,11 +314,17 @@ func typeSafetyCheckDatetime(value string) error {
 	return nil
 }
 
+func anchorCustomRegexPattern(pattern string) string {
+	return "^(?:" + pattern + ")$"
+}
+
 func typeSafetyCheckRegex(name string, value string, argumentType string) error {
 	pattern := ""
+	isCustomRegex := strings.HasPrefix(argumentType, "regex:")
 
-	if strings.HasPrefix(argumentType, "regex:") {
-		pattern = strings.Replace(argumentType, "regex:", "", 1)
+	if isCustomRegex {
+		pattern = strings.TrimPrefix(argumentType, "regex:")
+		pattern = anchorCustomRegexPattern(pattern)
 	} else {
 		found := false
 		pattern, found = typecheckRegex[argumentType]
@@ -308,21 +351,50 @@ func typeSafetyCheckRegex(name string, value string, argumentType string) error
 }
 
 func typeSafetyCheckUrl(value string) error {
-	_, err := url.ParseRequestURI(value)
+	parsed, err := url.ParseRequestURI(value)
+	if err != nil {
+		return err
+	}
+
+	scheme := strings.ToLower(parsed.Scheme)
+	if scheme != "http" && scheme != "https" {
+		return fmt.Errorf("url scheme %q is not allowed; only http and https are permitted", parsed.Scheme)
+	}
+
+	return nil
+}
 
-	return err
+var shellUnsafeArgumentTypes = map[string]struct{}{
+	"url":                       {},
+	"email":                     {},
+	"raw_string_multiline":      {},
+	"very_dangerous_raw_string": {},
+	"password":                  {},
+	"html":                      {},
+	"confirmation":              {},
+}
+
+func isUnsafeShellArgumentType(arg *config.ActionArgument) bool {
+	if strings.HasPrefix(arg.Type, "regex:") {
+		return true
+	}
+
+	_, inMap := shellUnsafeArgumentTypes[arg.Type]
+	return inMap || (arg.Type == "checkbox" && len(arg.Choices) == 0)
 }
 
 func checkShellArgumentSafety(action *config.Action) error {
 	if action.Shell == "" {
 		return nil
 	}
-	unsafe := map[string]struct{}{"url": {}, "email": {}, "raw_string_multiline": {}, "very_dangerous_raw_string": {}, "password": {}}
-	for _, arg := range action.Arguments {
-		if _, bad := unsafe[arg.Type]; bad {
+
+	for i := range action.Arguments {
+		arg := &action.Arguments[i]
+		if isUnsafeShellArgumentType(arg) {
 			return fmt.Errorf("unsafe argument type '%s' cannot be used with Shell execution. Use 'exec' instead. See https://docs.olivetin.app/action_execution/shellvsexec.html", arg.Type)
 		}
 	}
+
 	return nil
 }
 
@@ -333,6 +405,7 @@ func mangleInvalidArgumentValues(req *ExecutionRequest) {
 		}
 
 		mangleCheckboxValues(req, &arg)
+		mangleChecklistValues(req, &arg)
 	}
 }
 
@@ -389,15 +462,20 @@ func MangleArgumentValue(arg *config.ActionArgument, value string, actionTitle s
 		return value
 	}
 
-	if arg.Type == "datetime" {
-		return mangleDatetimeValue(arg, value, actionTitle)
-	}
+	return mangleArgumentValueByType(arg, value, actionTitle)
+}
 
-	if arg.Type == "checkbox" {
+func mangleArgumentValueByType(arg *config.ActionArgument, value string, actionTitle string) string {
+	switch arg.Type {
+	case "datetime":
+		return mangleDatetimeValue(arg, value, actionTitle)
+	case "checkbox":
 		return mangleCheckboxValue(arg, value, actionTitle)
+	case "checklist":
+		return mangleChecklistValue(arg, value, actionTitle)
+	default:
+		return value
 	}
-
-	return value
 }
 
 func mangleDatetimeValue(arg *config.ActionArgument, value string, actionTitle string) string {
@@ -430,6 +508,96 @@ func mangleCheckboxValue(arg *config.ActionArgument, value string, actionTitle s
 		return value
 	}
 
+	return mangleChoiceSegment(arg, value, actionTitle)
+}
+
+func mangleChecklistValues(req *ExecutionRequest, arg *config.ActionArgument) {
+	if arg.Type != "checklist" {
+		return
+	}
+
+	value, exists := req.Arguments[arg.Name]
+	if !exists || value == "" {
+		return
+	}
+
+	req.Arguments[arg.Name] = mangleChecklistValue(arg, value, req.Binding.Action.Title)
+}
+
+func mangleChecklistValue(arg *config.ActionArgument, value string, actionTitle string) string {
+	if arg == nil || value == "" {
+		return value
+	}
+
+	segments, err := config.ParseChecklistValue(value)
+	if err != nil {
+		return value
+	}
+
+	return mangleChecklistSegments(arg, segments, value, actionTitle)
+}
+
+func mangleChecklistSegments(arg *config.ActionArgument, segments []string, fallback string, actionTitle string) string {
+	mangled := make([]string, len(segments))
+	for i, segment := range segments {
+		mangled[i] = mangleChecklistSegment(arg, segment, actionTitle)
+	}
+
+	formatted, err := config.FormatChecklistValue(mangled)
+	if err != nil {
+		return fallback
+	}
+
+	return formatted
+}
+
+func mangleChecklistSegment(arg *config.ActionArgument, segment string, actionTitle string) string {
+	trimmed := strings.TrimSpace(segment)
+	if trimmed == "" {
+		return ""
+	}
+
+	return mangleChoiceSegment(arg, trimmed, actionTitle)
+}
+
+func mangleChoiceSegment(arg *config.ActionArgument, value string, actionTitle string) string {
+	if mapped, ok := mangleChoiceSegmentEntity(arg, value, actionTitle); ok {
+		return mapped
+	}
+
+	return mangleChoiceSegmentStatic(arg, value, actionTitle)
+}
+
+func mangleChoiceSegmentEntity(arg *config.ActionArgument, value string, actionTitle string) (string, bool) {
+	if arg.Entity == "" || len(arg.Choices) == 0 {
+		return value, false
+	}
+
+	return mangleEntityTemplateChoiceSegment(arg.Choices[0], arg.Entity, arg.Name, value, actionTitle)
+}
+
+func mangleEntityTemplateChoiceSegment(templateChoice config.ActionArgumentChoice, entityName string, argName string, value string, actionTitle string) (string, bool) {
+	for _, ent := range entities.GetEntityInstancesOrdered(entityName) {
+		expandedTitle := tpl.ParseTemplateOfActionBeforeExec(templateChoice.Title, ent)
+		if value != expandedTitle {
+			continue
+		}
+
+		expandedValue := tpl.ParseTemplateOfActionBeforeExec(templateChoice.Value, ent)
+		log.WithFields(log.Fields{
+			"arg":         argName,
+			"oldValue":    value,
+			"newValue":    expandedValue,
+			"actionTitle": actionTitle,
+		}).Infof("Mangled entity choice segment")
+
+		return expandedValue, true
+	}
+
+	return value, false
+}
+
+func mangleChoiceSegmentStatic(arg *config.ActionArgument, value string, actionTitle string) string {
 	for _, choice := range arg.Choices {
 		if value == choice.Title {
 			log.WithFields(log.Fields{
@@ -437,7 +605,7 @@ func mangleCheckboxValue(arg *config.ActionArgument, value string, actionTitle s
 				"oldValue":    value,
 				"newValue":    choice.Value,
 				"actionTitle": actionTitle,
-			}).Infof("Mangled checkbox value")
+			}).Infof("Mangled choice segment")
 
 			return choice.Value
 		}

+ 236 - 0
service/internal/executor/arguments_test.go

@@ -115,6 +115,169 @@ func TestValidateArgumentCheckboxWithChoices(t *testing.T) {
 	assert.NotNil(t, err, "Expected unknown checkbox title to be rejected against choices")
 }
 
+func checklistTestArg() config.ActionArgument {
+	return config.ActionArgument{
+		Name: "directories",
+		Type: "checklist",
+		Choices: []config.ActionArgumentChoice{
+			{Title: "Documents", Value: "documents"},
+			{Title: "Photos", Value: "photos"},
+			{Title: "Music", Value: "music"},
+		},
+	}
+}
+
+func TestValidateArgumentChecklistSelections(t *testing.T) {
+	log.SetLevel(log.PanicLevel)
+
+	arg := checklistTestArg()
+	action := config.Action{Title: "Test checklist"}
+
+	err := ValidateArgument(&arg, "documents", &action)
+	assert.Nil(t, err)
+
+	err = ValidateArgument(&arg, `["documents","photos"]`, &action)
+	assert.Nil(t, err)
+
+	err = ValidateArgument(&arg, `["documents","unknown"]`, &action)
+	assert.NotNil(t, err)
+}
+
+func TestValidateArgumentChecklistTitleMangling(t *testing.T) {
+	log.SetLevel(log.PanicLevel)
+
+	arg := checklistTestArg()
+	action := config.Action{Title: "Test checklist title mangling"}
+
+	err := ValidateArgument(&arg, `["Documents","Photos"]`, &action)
+	assert.Nil(t, err)
+}
+
+func TestValidateArgumentChecklistEmptySelection(t *testing.T) {
+	log.SetLevel(log.PanicLevel)
+
+	arg := checklistTestArg()
+	action := config.Action{Title: "Test checklist empty"}
+
+	err := ValidateArgument(&arg, "", &action)
+	assert.Nil(t, err)
+
+	arg.RejectNull = true
+	err = ValidateArgument(&arg, "", &action)
+	assert.NotNil(t, err)
+}
+
+func TestValidateArgumentChecklistWithoutChoices(t *testing.T) {
+	log.SetLevel(log.PanicLevel)
+
+	arg := config.ActionArgument{
+		Name: "directories",
+		Type: "checklist",
+	}
+	action := config.Action{Title: "Test checklist without choices"}
+
+	err := ValidateArgument(&arg, "documents", &action)
+	assert.NotNil(t, err)
+}
+
+func TestValidateArgumentChecklistRejectsEmptySegment(t *testing.T) {
+	log.SetLevel(log.PanicLevel)
+
+	arg := checklistTestArg()
+	action := config.Action{Title: "Test checklist empty segment"}
+
+	err := ValidateArgument(&arg, `["documents","","photos"]`, &action)
+	assert.NotNil(t, err)
+}
+
+func TestMangleArgumentValueChecklist(t *testing.T) {
+	log.SetLevel(log.PanicLevel)
+
+	arg := checklistTestArg()
+
+	out := MangleArgumentValue(&arg, `["Documents","Music"]`, "Test action")
+	assert.Equal(t, `["documents","music"]`, out)
+
+	out = MangleArgumentValue(&arg, `["documents","photos"]`, "Test action")
+	assert.Equal(t, `["documents","photos"]`, out)
+}
+
+func checklistEntityTestArg() config.ActionArgument {
+	return config.ActionArgument{
+		Name:   "rooms",
+		Type:   "checklist",
+		Entity: "room",
+		Choices: []config.ActionArgumentChoice{
+			{Title: "{{ room.hostname }}", Value: "{{ room.hostname }}"},
+		},
+	}
+}
+
+func TestValidateArgumentChecklistEntitySelections(t *testing.T) {
+	log.SetLevel(log.PanicLevel)
+
+	entities.AddEntity("room", "0", map[string]any{"hostname": "attic"})
+	entities.AddEntity("room", "1", map[string]any{"hostname": "basement"})
+
+	arg := checklistEntityTestArg()
+	action := config.Action{Title: "Test checklist entity"}
+
+	err := ValidateArgument(&arg, "attic", &action)
+	assert.Nil(t, err)
+
+	err = ValidateArgument(&arg, `["attic","basement"]`, &action)
+	assert.Nil(t, err)
+
+	err = ValidateArgument(&arg, `["attic","unknown"]`, &action)
+	assert.NotNil(t, err)
+}
+
+func TestMangleArgumentValueChecklistEntityTitles(t *testing.T) {
+	log.SetLevel(log.PanicLevel)
+
+	entities.AddEntity("room", "0", map[string]any{"hostname": "attic"})
+	entities.AddEntity("room", "1", map[string]any{"hostname": "basement"})
+
+	arg := config.ActionArgument{
+		Name:   "rooms",
+		Type:   "checklist",
+		Entity: "room",
+		Choices: []config.ActionArgumentChoice{
+			{Title: "{{ room.hostname }} room", Value: "{{ room.hostname }}"},
+		},
+	}
+
+	out := MangleArgumentValue(&arg, `["attic room","basement room"]`, "Test checklist entity titles")
+	assert.Equal(t, `["attic","basement"]`, out)
+}
+
+func TestParseActionArgumentsChecklistEmptySelection(t *testing.T) {
+	req := newExecRequest()
+	req.Binding.Action = &config.Action{
+		Title: "Test checklist empty selection",
+		Shell: "echo 'Selected segments: {{ segments }}'",
+		Arguments: []config.ActionArgument{
+			{
+				Name: "segments",
+				Type: "checklist",
+				Choices: []config.ActionArgumentChoice{
+					{Value: "kitchen"},
+					{Value: "bedroom"},
+				},
+			},
+		},
+	}
+	req.Arguments = map[string]string{
+		"segments": "",
+	}
+
+	mangleInvalidArgumentValues(req)
+	out, err := parseActionArguments(req)
+
+	assert.Nil(t, err)
+	assert.Equal(t, "echo 'Selected segments: '", out)
+}
+
 func newExecRequest() *ExecutionRequest {
 	return &ExecutionRequest{
 		Arguments: make(map[string]string),
@@ -336,13 +499,72 @@ func TestCheckShellArgumentSafetyWithPasswordAndExec(t *testing.T) {
 	assert.Nil(t, err)
 }
 
+func TestCheckShellArgumentSafetyWithHTML(t *testing.T) {
+	a1 := config.Action{
+		Title: "HTML shell",
+		Shell: "echo {{ body }}",
+		Arguments: []config.ActionArgument{
+			{Name: "body", Type: "html"},
+		},
+	}
+
+	err := checkShellArgumentSafety(&a1)
+	assert.NotNil(t, err)
+	assert.Contains(t, err.Error(), "unsafe argument type 'html'")
+}
+
+func TestCheckShellArgumentSafetyWithConfirmation(t *testing.T) {
+	a1 := config.Action{
+		Title: "Confirm shell",
+		Shell: "echo ok",
+		Arguments: []config.ActionArgument{
+			{Name: "agree", Type: "confirmation"},
+		},
+	}
+
+	err := checkShellArgumentSafety(&a1)
+	assert.NotNil(t, err)
+	assert.Contains(t, err.Error(), "unsafe argument type 'confirmation'")
+}
+
+func TestCheckShellArgumentSafetyWithChoicelessCheckbox(t *testing.T) {
+	a1 := config.Action{
+		Title: "Checkbox shell",
+		Shell: "echo {{ flag }}",
+		Arguments: []config.ActionArgument{
+			{Name: "flag", Type: "checkbox"},
+		},
+	}
+
+	err := checkShellArgumentSafety(&a1)
+	assert.NotNil(t, err)
+	assert.Contains(t, err.Error(), "unsafe argument type 'checkbox'")
+}
+
+func TestCheckShellArgumentSafetyWithCustomRegex(t *testing.T) {
+	a1 := config.Action{
+		Title: "Regex shell",
+		Shell: "curl {{ host }}",
+		Arguments: []config.ActionArgument{
+			{Name: "host", Type: "regex:[a-zA-Z0-9.-]+"},
+		},
+	}
+
+	err := checkShellArgumentSafety(&a1)
+	assert.NotNil(t, err)
+	assert.Contains(t, err.Error(), "unsafe argument type 'regex:[a-zA-Z0-9.-]+'")
+}
+
 func TestTypeSafetyCheckUrl(t *testing.T) {
 	assert.Nil(t, TypeSafetyCheck("test1", "http://google.com", "url"), "Test URL: google.com")
 	assert.Nil(t, TypeSafetyCheck("test2", "http://technowax.net:80?foo=bar", "url"), "Test URL: technowax.net with query arguments")
 	assert.Nil(t, TypeSafetyCheck("test3", "http://localhost:80?foo=bar", "url"), "Test URL: localhost with query arguments")
+	assert.Nil(t, TypeSafetyCheck("test7", "https://example.com/path", "url"), "Test URL: https scheme")
 	assert.NotNil(t, TypeSafetyCheck("test4", "http://lo  host:80", "url"), "Test a badly formed URL")
 	assert.NotNil(t, TypeSafetyCheck("test5", "12345", "url"), "Test a badly formed URL")
 	assert.NotNil(t, TypeSafetyCheck("test6", "_!23;", "url"), "Test a badly formed URL")
+	assert.NotNil(t, TypeSafetyCheck("test8", "file:///etc/passwd", "url"), "file:// scheme must be rejected")
+	assert.NotNil(t, TypeSafetyCheck("test9", "gopher://example.com", "url"), "gopher:// scheme must be rejected")
 }
 
 func TestTypeSafetyCheckRegex(t *testing.T) {
@@ -367,6 +589,20 @@ func TestTypeSafetyCheckRegex(t *testing.T) {
 			value:    "James1234",
 			hasError: true,
 		},
+		{
+			name:     "GHSA-gvxq - reject partial regex match",
+			field:    "host",
+			pattern:  "regex:[a-zA-Z0-9.-]+",
+			value:    "example.com; id",
+			hasError: true,
+		},
+		{
+			name:     "reject alternation bypass when pattern looks anchored",
+			field:    "host",
+			pattern:  "regex:^safe$|bad",
+			value:    "xxxbad",
+			hasError: true,
+		},
 	}
 
 	for _, tt := range tests {

+ 52 - 6
service/internal/executor/executor.go

@@ -1238,26 +1238,72 @@ func stepExecAfter(req *ExecutionRequest) bool {
 	return true
 }
 
-func buildShellAfterCommand(ctx context.Context, req *ExecutionRequest, stdout, stderr *bytes.Buffer) (*exec.Cmd, map[string]string, error) {
+func shellAfterCompletedAction(req *ExecutionRequest) (*config.Action, bool) {
+	if req == nil {
+		return nil, false
+	}
+	if !hasBindingAndAction(req) {
+		return nil, false
+	}
 	if req.Binding.Action.ShellAfterCompleted == "" {
-		return nil, nil, nil
+		return nil, false
 	}
+	return req.Binding.Action, true
+}
 
-	args, err := buildShellAfterArgs(req)
-	if err != nil {
-		return nil, nil, err
+func substituteShellAfterCompletedEnvRefs(command string) string {
+	replacements := []struct{ old, new string }{
+		{"{{ output }}", `"$OUTPUT"`},
+		{"{{output}}", `"$OUTPUT"`},
+		{"{{ exitCode }}", `"$EXITCODE"`},
+		{"{{exitCode}}", `"$EXITCODE"`},
+		{"{{ exitCode}}", `"$EXITCODE"`},
+		{"{{exitCode }}", `"$EXITCODE"`},
+	}
+
+	for _, replacement := range replacements {
+		command = strings.ReplaceAll(command, replacement.old, replacement.new)
 	}
 
-	finalParsedCommand, err := tpl.ParseTemplateWithActionContext(req.Binding.Action.ShellAfterCompleted, req.Binding.Entity, args)
+	return command
+}
+
+func parseShellAfterCompletedCommand(req *ExecutionRequest, commandTemplate string, args map[string]string) (string, error) {
+	finalParsedCommand, err := tpl.ParseTemplateWithActionContext(commandTemplate, req.Binding.Entity, args)
 	if err != nil {
 		msg := "Could not prepare shellAfterCompleted command: " + err.Error() + "\n"
 		req.mutateLogEntry(func(entry *InternalLogEntry) {
 			entry.Output += msg
 		})
 		log.Warn(msg)
+		return "", err
+	}
+
+	return finalParsedCommand, nil
+}
+
+//gocyclo:ignore
+func buildShellAfterCommand(ctx context.Context, req *ExecutionRequest, stdout, stderr *bytes.Buffer) (*exec.Cmd, map[string]string, error) {
+	action, ok := shellAfterCompletedAction(req)
+	if !ok {
 		return nil, nil, nil
 	}
 
+	if hasWebhookTag(req) {
+		return nil, nil, fmt.Errorf("webhooks cannot use shellAfterCompleted; use exec without after-completion shell instead. See https://docs.olivetin.app/action_execution/shellvsexec.html")
+	}
+
+	args, err := buildShellAfterArgs(req)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	commandTemplate := substituteShellAfterCompletedEnvRefs(action.ShellAfterCompleted)
+	finalParsedCommand, err := parseShellAfterCompletedCommand(req, commandTemplate, args)
+	if err != nil {
+		return nil, nil, err
+	}
+
 	cmd := wrapCommandInShell(ctx, finalParsedCommand)
 	cmd.Stdout = stdout
 	cmd.Stderr = stderr

+ 63 - 0
service/internal/executor/executor_test.go

@@ -1,6 +1,8 @@
 package executor
 
 import (
+	"os"
+	"path/filepath"
 	"strings"
 	"testing"
 	"time"
@@ -385,6 +387,67 @@ func TestWebhookAllowsExecExecution(t *testing.T) {
 	assert.Contains(t, req.logEntry.Output, "hello")
 }
 
+func TestWebhookRejectsShellAfterCompleted(t *testing.T) {
+	cfg := config.DefaultConfig()
+	e := DefaultExecutor(cfg)
+	a1 := &config.Action{
+		Title:               "Webhook After Shell Reject",
+		Exec:                []string{"echo", "{{ msg }}"},
+		ShellAfterCompleted: "echo after",
+		Arguments: []config.ActionArgument{
+			{Name: "msg", Type: "ascii"},
+		},
+	}
+	cfg.Actions = append(cfg.Actions, a1)
+	cfg.Sanitize()
+	e.RebuildActionMap()
+
+	req := ExecutionRequest{
+		Tags:              []string{"webhook"},
+		AuthenticatedUser: auth.UserFromSystem(cfg, "webhook"),
+		Cfg:               cfg,
+		Arguments:         map[string]string{"msg": "hello"},
+		Binding:           e.FindBindingWithNoEntity(a1),
+	}
+
+	wg, _ := e.ExecRequest(&req)
+	wg.Wait()
+
+	assert.NotNil(t, req.logEntry)
+	assert.Contains(t, req.logEntry.Output, "webhooks cannot use shellAfterCompleted")
+}
+
+func TestShellAfterCompletedUsesOutputEnvSafely(t *testing.T) {
+	cfg := config.DefaultConfig()
+	e := DefaultExecutor(cfg)
+	injectedPath := filepath.Join(t.TempDir(), "olivetin-injected")
+	expectedMainOutput := "'; touch " + injectedPath + "; echo '"
+	a1 := &config.Action{
+		Title:               "After completion escape",
+		Shell:               "printf %s \"" + expectedMainOutput + "\"",
+		ShellAfterCompleted: "printf %s {{ output }}",
+	}
+	cfg.Actions = append(cfg.Actions, a1)
+	cfg.Sanitize()
+	e.RebuildActionMap()
+
+	req := ExecutionRequest{
+		AuthenticatedUser: auth.UserFromSystem(cfg, "cron"),
+		Cfg:               cfg,
+		Binding:           e.FindBindingWithNoEntity(a1),
+	}
+
+	wg, _ := e.ExecRequest(&req)
+	wg.Wait()
+
+	assert.NotNil(t, req.logEntry)
+	assert.Equal(t, int32(0), req.logEntry.ExitCode)
+	assert.True(t, strings.HasPrefix(req.logEntry.Output, expectedMainOutput))
+	assert.Contains(t, req.logEntry.Output, "OliveTin::shellAfterCompleted stdout\n"+expectedMainOutput)
+	_, err := os.Stat(injectedPath)
+	assert.True(t, os.IsNotExist(err), "shellAfterCompleted must not execute injected commands from output")
+}
+
 func TestFilterToDefinedArgumentsOnly(t *testing.T) {
 	req := newExecRequest()
 	req.Binding.Action = &config.Action{

+ 1 - 1
service/internal/executor/justification.go

@@ -45,7 +45,7 @@ func ResolveJustification(req *ExecutionRequest) string {
 }
 
 func actionRequiresJustification(req *ExecutionRequest) bool {
-	return req != nil && req.Binding != nil && req.Binding.Action != nil && req.Binding.Action.Justification
+	return req != nil && req.Binding != nil && req.Binding.Action != nil && req.Binding.Action.RequiresJustification()
 }
 
 func defaultJustificationForRequest(req *ExecutionRequest) string {

+ 5 - 5
service/internal/executor/justification_test.go

@@ -11,7 +11,7 @@ import (
 
 func TestResolveJustificationUsesProvidedValue(t *testing.T) {
 	cfg := config.DefaultConfig()
-	action := &config.Action{Title: "Send email", Justification: true, Shell: "echo hi"}
+	action := &config.Action{Title: "Send email", Justification: config.JustificationRequiredNoTemplate, Shell: "echo hi"}
 	cfg.Actions = append(cfg.Actions, action)
 	ex := DefaultExecutor(cfg)
 	ex.RebuildActionMap()
@@ -29,7 +29,7 @@ func TestResolveJustificationUsesProvidedValue(t *testing.T) {
 
 func TestResolveJustificationCronDefault(t *testing.T) {
 	cfg := config.DefaultConfig()
-	action := &config.Action{Title: "Nightly backup", Justification: true, Shell: "echo hi"}
+	action := &config.Action{Title: "Nightly backup", Justification: config.JustificationRequiredNoTemplate, Shell: "echo hi"}
 	cfg.Actions = append(cfg.Actions, action)
 	ex := DefaultExecutor(cfg)
 	ex.RebuildActionMap()
@@ -45,7 +45,7 @@ func TestResolveJustificationCronDefault(t *testing.T) {
 
 func TestResolveJustificationStartupDefault(t *testing.T) {
 	cfg := config.DefaultConfig()
-	action := &config.Action{Title: "Init", Justification: true, Shell: "echo hi"}
+	action := &config.Action{Title: "Init", Justification: config.JustificationRequiredNoTemplate, Shell: "echo hi"}
 	cfg.Actions = append(cfg.Actions, action)
 	ex := DefaultExecutor(cfg)
 	ex.RebuildActionMap()
@@ -61,7 +61,7 @@ func TestResolveJustificationStartupDefault(t *testing.T) {
 
 func TestResolveJustificationWebhookDefault(t *testing.T) {
 	cfg := config.DefaultConfig()
-	action := &config.Action{Title: "Deploy", Justification: true, Exec: []string{"echo", "deploy"}}
+	action := &config.Action{Title: "Deploy", Justification: config.JustificationRequiredNoTemplate, Exec: []string{"echo", "deploy"}}
 	cfg.Actions = append(cfg.Actions, action)
 	ex := DefaultExecutor(cfg)
 	ex.RebuildActionMap()
@@ -95,7 +95,7 @@ func TestJustificationNotPassedToShellArgs(t *testing.T) {
 	cfg := config.DefaultConfig()
 	action := &config.Action{
 		Title:         "Echo",
-		Justification: true,
+		Justification: config.JustificationRequiredNoTemplate,
 		Shell:         "echo {{ message }}",
 		Arguments: []config.ActionArgument{
 			{Name: "message", Type: "ascii_sentence"},

+ 23 - 0
service/internal/executor/log_arguments_test.go

@@ -79,6 +79,29 @@ func TestStorableArgumentsFromRequestStoresMangledCheckboxValue(t *testing.T) {
 	assert.Equal(t, "1", args["mode"])
 }
 
+func TestStorableArgumentsFromRequestStoresMangledChecklistValue(t *testing.T) {
+	req := newExecRequest()
+	req.Binding.Action.Arguments = []config.ActionArgument{
+		{
+			Name: "directories",
+			Type: "checklist",
+			Choices: []config.ActionArgumentChoice{
+				{Title: "Documents", Value: "documents"},
+				{Title: "Photos", Value: "photos"},
+			},
+		},
+	}
+	req.Arguments = map[string]string{
+		"directories": `["Documents","Photos"]`,
+	}
+
+	mangleInvalidArgumentValues(req)
+	args := storableArgumentsFromRequest(req)
+
+	require.Len(t, args, 1)
+	assert.Equal(t, `["documents","photos"]`, args["directories"])
+}
+
 func TestCopyStorableArgumentsToLogEntry(t *testing.T) {
 	req := newExecRequest()
 	req.logEntry = &InternalLogEntry{}

+ 1 - 1
var/macos/config.yaml

@@ -147,7 +147,7 @@ actions:
   # Using a path under the user's home is more natural on macOS.
   - title: Delete old backups
     icon: ashtonished
-    justification: true
+    justification: " "
     shell: rm -rf "$HOME/Backups/old/"
     arguments:
       - name: confirm

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff