Jelajahi Sumber

Merge branch 'next' of github.com:OliveTin/OliveTin into next

jamesread 1 bulan lalu
induk
melakukan
08d0f6d574

+ 0 - 132
.github/dependabot.yml

@@ -1,132 +0,0 @@
-version: 2
-updates:
-  # npm updates for frontend - targeting "next" branch
-  - package-ecosystem: "npm"
-    directory: "/frontend"
-    schedule:
-      interval: "weekly"
-    target-branch: "next"
-    open-pull-requests-limit: 10
-    labels:
-      - "3k"
-      - "dependencies"
-    cooldown:
-      default-days: 7
-
-  # npm updates for frontend - targeting "release/2k" branch (security updates only)
-  - package-ecosystem: "npm"
-    directory: "/frontend"
-    schedule:
-      interval: "weekly"
-    target-branch: "release/2k"
-    open-pull-requests-limit: 0
-    labels:
-      - "2k"
-      - "dependencies"
-    cooldown:
-      default-days: 7
-
-  # npm updates for integration-tests - targeting "next" branch
-  - package-ecosystem: "npm"
-    directory: "/integration-tests"
-    schedule:
-      interval: "weekly"
-    target-branch: "next"
-    open-pull-requests-limit: 10
-    labels:
-      - "3k"
-      - "dependencies"
-    cooldown:
-      default-days: 7
-
-  # npm updates for integration-tests - targeting "release/2k" branch (security updates only)
-  - package-ecosystem: "npm"
-    directory: "/integration-tests"
-    schedule:
-      interval: "weekly"
-    target-branch: "release/2k"
-    open-pull-requests-limit: 0
-    labels:
-      - "2k"
-      - "dependencies"
-    cooldown:
-      default-days: 7
-
-  # Go modules updates for service - targeting "next" branch
-  - package-ecosystem: "gomod"
-    directory: "/service"
-    schedule:
-      interval: "weekly"
-    target-branch: "next"
-    open-pull-requests-limit: 10
-    labels:
-      - "3k"
-      - "dependencies"
-    cooldown:
-      default-days: 7
-
-  # Go modules updates for service - targeting "release/2k" branch (security updates only)
-  - package-ecosystem: "gomod"
-    directory: "/service"
-    schedule:
-      interval: "weekly"
-    target-branch: "release/2k"
-    open-pull-requests-limit: 0
-    labels:
-      - "2k"
-      - "dependencies"
-    cooldown:
-      default-days: 7
-
-  # Go modules updates for lang - targeting "next" branch
-  - package-ecosystem: "gomod"
-    directory: "/lang"
-    schedule:
-      interval: "weekly"
-    target-branch: "next"
-    open-pull-requests-limit: 10
-    labels:
-      - "3k"
-      - "dependencies"
-    cooldown:
-      default-days: 7
-
-  # Go modules updates for lang - targeting "release/2k" branch (security updates only)
-  - package-ecosystem: "gomod"
-    directory: "/lang"
-    schedule:
-      interval: "weekly"
-    target-branch: "release/2k"
-    open-pull-requests-limit: 0
-    labels:
-      - "2k"
-      - "dependencies"
-    cooldown:
-      default-days: 7
-
-  # Docker updates - targeting "next" branch
-  - package-ecosystem: "docker"
-    directory: "/"
-    schedule:
-      interval: "weekly"
-    target-branch: "next"
-    open-pull-requests-limit: 10
-    labels:
-      - "3k"
-      - "dependencies"
-    cooldown:
-      default-days: 7
-
-  # Docker updates - targeting "release/2k" branch (security updates only)
-  - package-ecosystem: "docker"
-    directory: "/"
-    schedule:
-      interval: "weekly"
-    target-branch: "release/2k"
-    open-pull-requests-limit: 0
-    labels:
-      - "2k"
-      - "dependencies"
-    cooldown:
-      default-days: 7
-

+ 1 - 0
docs/modules/ROOT/pages/args/types.adoc

@@ -10,6 +10,7 @@ A full list of argument types are below;
 | (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 `_`. 
+| 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 `,`. 
 | 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.

+ 1 - 1
frontend/package.json

@@ -10,7 +10,7 @@
 		"stylelint-config-standard": "^40.0.0"
 	},
 	"scripts": {
-		"test": "echo \"Error: no test specified\" && exit 1"
+		"test": "node --test resources/vue/components/*.test.mjs"
 	},
 	"author": "",
 	"parcelIgnore": [

+ 10 - 0
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts

@@ -111,6 +111,16 @@ export declare type ActionWebhookExecHint = Message<"olivetin.api.v1.ActionWebho
    * @generated from field: string match_path = 2;
    */
   matchPath: string;
+
+  /**
+   * @generated from field: map<string, string> match_headers = 3;
+   */
+  matchHeaders: { [key: string]: string };
+
+  /**
+   * @generated from field: map<string, string> match_query = 4;
+   */
+  matchQuery: { [key: string]: string };
 };
 
 /**

File diff ditekan karena terlalu besar
+ 0 - 0
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js


+ 4 - 10
frontend/resources/vue/components/ActionIconGlyph.vue

@@ -7,6 +7,7 @@
 			height="1em"
 			class="action-icon-glyph-svg"
 		/>
+		<span v-else-if="decodedTextGlyphIsHtml" v-html="decodedTextGlyph"></span>
 		<span v-else v-text="decodedTextGlyph"></span>
 	</span>
 </template>
@@ -15,6 +16,7 @@
 import { computed } from 'vue'
 import { HugeiconsIcon } from '@hugeicons/vue'
 import { CommandLineIcon } from '@hugeicons/core-free-icons'
+import { decodeHtmlEntities, glyphLooksLikeHtml } from './actionIconGlyphHelpers.mjs'
 
 const hugeiconsPrefix = 'hugeicons:'
 
@@ -46,16 +48,6 @@ const hugeiconsModel = computed(() => {
 	return iconModel ?? CommandLineIcon
 })
 
-function decodeHtmlEntities(text) {
-	return text.replace(/&#x([0-9a-fA-F]+);?/g, (_, hex) => {
-		const codePoint = Number.parseInt(hex, 16)
-		return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : ''
-	}).replace(/&#(\d+);?/g, (_, decimal) => {
-		const codePoint = Number.parseInt(decimal, 10)
-		return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : ''
-	})
-}
-
 const decodedTextGlyph = computed(() => {
 	if (hugeiconsModel.value) {
 		return ''
@@ -63,6 +55,8 @@ const decodedTextGlyph = computed(() => {
 
 	return decodeHtmlEntities(props.glyph)
 })
+
+const decodedTextGlyphIsHtml = computed(() => glyphLooksLikeHtml(decodedTextGlyph.value))
 </script>
 
 <style scoped>

+ 38 - 0
frontend/resources/vue/components/actionIconGlyphHelpers.mjs

@@ -0,0 +1,38 @@
+const fallbackNamedHtmlEntities = {
+	amp: '&',
+	apos: "'",
+	darr: '\u2193',
+	gt: '>',
+	laquo: '\u00ab',
+	larr: '\u2190',
+	nbsp: '\u00a0',
+	quot: '"',
+	raquo: '\u00bb',
+	rarr: '\u2192',
+	uarr: '\u2191',
+}
+
+export function decodeHtmlEntities(text) {
+	if (typeof document !== 'undefined') {
+		const textarea = document.createElement('textarea')
+		textarea.innerHTML = text
+
+		return textarea.value
+	}
+
+	return text.replace(/&#x([0-9a-fA-F]+);?/g, (_, hex) => {
+		const codePoint = Number.parseInt(hex, 16)
+		return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : ''
+	}).replace(/&#(\d+);?/g, (_, decimal) => {
+		const codePoint = Number.parseInt(decimal, 10)
+		return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : ''
+	}).replace(/&([a-zA-Z][a-zA-Z0-9]+);?/g, (entity, name) => {
+		return fallbackNamedHtmlEntities[name] ?? entity
+	})
+}
+
+export function glyphLooksLikeHtml(text) {
+	const trimmedText = text.trim()
+
+	return trimmedText.startsWith('<') || /<img\b/i.test(text) || /\/custom-webui\//i.test(text)
+}

+ 20 - 0
frontend/resources/vue/components/actionIconGlyphHelpers.test.mjs

@@ -0,0 +1,20 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { decodeHtmlEntities, glyphLooksLikeHtml } from './actionIconGlyphHelpers.mjs'
+
+test('decodeHtmlEntities decodes named entity icons as plain glyph text', () => {
+	assert.equal(decodeHtmlEntities('&laquo;'), '\u00ab')
+	assert.equal(decodeHtmlEntities('&rarr;'), '\u2192')
+	assert.equal(decodeHtmlEntities('&laquo; next &rarr;'), '\u00ab next \u2192')
+})
+
+test('decoded named entity icons are not treated as HTML markup', () => {
+	const decodedGlyph = decodeHtmlEntities('&rarr;')
+
+	assert.equal(glyphLooksLikeHtml(decodedGlyph), false)
+})
+
+test('decodeHtmlEntities keeps existing numeric entity icon support', () => {
+	assert.equal(decodeHtmlEntities('&#x1f4a9;'), '\ud83d\udca9')
+	assert.equal(decodeHtmlEntities('&#128190;'), '\ud83d\udcbe')
+})

+ 11 - 1
frontend/resources/vue/views/ActionExecConditionsView.vue

@@ -76,7 +76,9 @@
           <li v-for="(wh, idx) in action.execOnWebhooks" :key="'wh-' + idx">
             <span v-if="wh.template">template: <code>{{ wh.template }}</code></span>
             <span v-if="wh.matchPath"> · matchPath: <code>{{ wh.matchPath }}</code></span>
-            <span v-if="!wh.template && !wh.matchPath">Webhook trigger (no template or match path in response)</span>
+            <span v-if="nonEmptyObject(wh.matchHeaders)"> · matchHeaders: <code>{{ wh.matchHeaders }}</code></span>
+            <span v-if="nonEmptyObject(wh.matchQuery)"> · matchQuery: <code>{{ wh.matchQuery }}</code></span>
+            <span v-if="!webhookHasCondition(wh)">Webhook trigger (no conditions in response)</span>
           </li>
         </ul>
       </template>
@@ -117,6 +119,14 @@ function nonEmptyList(list) {
   return Array.isArray(list) && list.length > 0
 }
 
+function nonEmptyObject(object) {
+  return object && Object.keys(object).length > 0
+}
+
+function webhookHasCondition(webhook) {
+  return webhook.template || webhook.matchPath || nonEmptyObject(webhook.matchHeaders) || nonEmptyObject(webhook.matchQuery)
+}
+
 const hasConfiguredTriggers = computed(() => {
   const a = action.value
   if (!a) {

+ 1 - 1
frontend/resources/vue/views/ArgumentForm.vue

@@ -174,7 +174,7 @@ function getInputType(arg) {
     return 'checkbox'
   }
 
-  if (arg.type === 'ascii_identifier' || arg.type === 'ascii' || arg.type === 'ascii_sentence') {
+  if (arg.type === 'ascii_identifier' || arg.type === 'shell_safe_identifier' || arg.type === 'ascii' || arg.type === 'ascii_sentence') {
     return 'text'
   }
 

+ 2 - 0
proto/olivetin/api/v1/olivetin.proto

@@ -25,6 +25,8 @@ message Action {
 message ActionWebhookExecHint {
 	string template = 1;
 	string match_path = 2;
+	map<string, string> match_headers = 3;
+	map<string, string> match_query = 4;
 }
 
 message ActionArgument {

+ 122 - 93
service/gen/olivetin/api/v1/olivetin.pb.go

@@ -181,6 +181,8 @@ type ActionWebhookExecHint struct {
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	Template      string                 `protobuf:"bytes,1,opt,name=template,proto3" json:"template,omitempty"`
 	MatchPath     string                 `protobuf:"bytes,2,opt,name=match_path,json=matchPath,proto3" json:"match_path,omitempty"`
+	MatchHeaders  map[string]string      `protobuf:"bytes,3,rep,name=match_headers,json=matchHeaders,proto3" json:"match_headers,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
+	MatchQuery    map[string]string      `protobuf:"bytes,4,rep,name=match_query,json=matchQuery,proto3" json:"match_query,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
 	unknownFields protoimpl.UnknownFields
 	sizeCache     protoimpl.SizeCache
 }
@@ -229,6 +231,20 @@ func (x *ActionWebhookExecHint) GetMatchPath() string {
 	return ""
 }
 
+func (x *ActionWebhookExecHint) GetMatchHeaders() map[string]string {
+	if x != nil {
+		return x.MatchHeaders
+	}
+	return nil
+}
+
+func (x *ActionWebhookExecHint) GetMatchQuery() map[string]string {
+	if x != nil {
+		return x.MatchQuery
+	}
+	return nil
+}
+
 type ActionArgument struct {
 	state                 protoimpl.MessageState  `protogen:"open.v1"`
 	Name                  string                  `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
@@ -4022,11 +4038,20 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
 	"\x1bexec_on_file_created_in_dir\x18\f \x03(\tR\x16execOnFileCreatedInDir\x12;\n" +
 	"\x1bexec_on_file_changed_in_dir\x18\r \x03(\tR\x16execOnFileChangedInDir\x121\n" +
 	"\x15exec_on_calendar_file\x18\x0e \x01(\tR\x12execOnCalendarFile\x12P\n" +
-	"\x10exec_on_webhooks\x18\x0f \x03(\v2&.olivetin.api.v1.ActionWebhookExecHintR\x0eexecOnWebhooks\"R\n" +
+	"\x10exec_on_webhooks\x18\x0f \x03(\v2&.olivetin.api.v1.ActionWebhookExecHintR\x0eexecOnWebhooks\"\x8a\x03\n" +
 	"\x15ActionWebhookExecHint\x12\x1a\n" +
 	"\btemplate\x18\x01 \x01(\tR\btemplate\x12\x1d\n" +
 	"\n" +
-	"match_path\x18\x02 \x01(\tR\tmatchPath\"\xa2\x03\n" +
+	"match_path\x18\x02 \x01(\tR\tmatchPath\x12]\n" +
+	"\rmatch_headers\x18\x03 \x03(\v28.olivetin.api.v1.ActionWebhookExecHint.MatchHeadersEntryR\fmatchHeaders\x12W\n" +
+	"\vmatch_query\x18\x04 \x03(\v26.olivetin.api.v1.ActionWebhookExecHint.MatchQueryEntryR\n" +
+	"matchQuery\x1a?\n" +
+	"\x11MatchHeadersEntry\x12\x10\n" +
+	"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
+	"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a=\n" +
+	"\x0fMatchQueryEntry\x12\x10\n" +
+	"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
+	"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xa2\x03\n" +
 	"\x0eActionArgument\x12\x12\n" +
 	"\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" +
 	"\x05title\x18\x02 \x01(\tR\x05title\x12\x12\n" +
@@ -4330,7 +4355,7 @@ func file_olivetin_api_v1_olivetin_proto_rawDescGZIP() []byte {
 	return file_olivetin_api_v1_olivetin_proto_rawDescData
 }
 
-var file_olivetin_api_v1_olivetin_proto_msgTypes = make([]protoimpl.MessageInfo, 73)
+var file_olivetin_api_v1_olivetin_proto_msgTypes = make([]protoimpl.MessageInfo, 75)
 var file_olivetin_api_v1_olivetin_proto_goTypes = []any{
 	(*Action)(nil),                          // 0: olivetin.api.v1.Action
 	(*ActionWebhookExecHint)(nil),           // 1: olivetin.api.v1.ActionWebhookExecHint
@@ -4401,99 +4426,103 @@ var file_olivetin_api_v1_olivetin_proto_goTypes = []any{
 	(*EntityDefinition)(nil),                // 66: olivetin.api.v1.EntityDefinition
 	(*GetEntityRequest)(nil),                // 67: olivetin.api.v1.GetEntityRequest
 	(*RestartActionRequest)(nil),            // 68: olivetin.api.v1.RestartActionRequest
-	nil,                                     // 69: olivetin.api.v1.ActionArgument.SuggestionsEntry
-	nil,                                     // 70: olivetin.api.v1.Entity.FieldsEntry
-	nil,                                     // 71: olivetin.api.v1.DumpVarsResponse.ContentsEntry
-	nil,                                     // 72: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry
+	nil,                                     // 69: olivetin.api.v1.ActionWebhookExecHint.MatchHeadersEntry
+	nil,                                     // 70: olivetin.api.v1.ActionWebhookExecHint.MatchQueryEntry
+	nil,                                     // 71: olivetin.api.v1.ActionArgument.SuggestionsEntry
+	nil,                                     // 72: olivetin.api.v1.Entity.FieldsEntry
+	nil,                                     // 73: olivetin.api.v1.DumpVarsResponse.ContentsEntry
+	nil,                                     // 74: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry
 }
 var file_olivetin_api_v1_olivetin_proto_depIdxs = []int32{
 	2,  // 0: olivetin.api.v1.Action.arguments:type_name -> olivetin.api.v1.ActionArgument
 	1,  // 1: olivetin.api.v1.Action.exec_on_webhooks:type_name -> olivetin.api.v1.ActionWebhookExecHint
-	3,  // 2: olivetin.api.v1.ActionArgument.choices:type_name -> olivetin.api.v1.ActionArgumentChoice
-	69, // 3: olivetin.api.v1.ActionArgument.suggestions:type_name -> olivetin.api.v1.ActionArgument.SuggestionsEntry
-	70, // 4: olivetin.api.v1.Entity.fields:type_name -> olivetin.api.v1.Entity.FieldsEntry
-	8,  // 5: olivetin.api.v1.GetDashboardResponse.dashboard:type_name -> olivetin.api.v1.Dashboard
-	9,  // 6: olivetin.api.v1.Dashboard.contents:type_name -> olivetin.api.v1.DashboardComponent
-	9,  // 7: olivetin.api.v1.DashboardComponent.contents:type_name -> olivetin.api.v1.DashboardComponent
-	0,  // 8: olivetin.api.v1.DashboardComponent.action:type_name -> olivetin.api.v1.Action
-	11, // 9: olivetin.api.v1.StartActionRequest.arguments:type_name -> olivetin.api.v1.StartActionArgument
-	11, // 10: olivetin.api.v1.StartActionAndWaitRequest.arguments:type_name -> olivetin.api.v1.StartActionArgument
-	20, // 11: olivetin.api.v1.StartActionAndWaitResponse.log_entry:type_name -> olivetin.api.v1.LogEntry
-	20, // 12: olivetin.api.v1.StartActionByGetAndWaitResponse.log_entry:type_name -> olivetin.api.v1.LogEntry
-	20, // 13: olivetin.api.v1.GetLogsResponse.logs:type_name -> olivetin.api.v1.LogEntry
-	20, // 14: olivetin.api.v1.GetActionLogsResponse.logs:type_name -> olivetin.api.v1.LogEntry
-	20, // 15: olivetin.api.v1.ExecutionStatusResponse.log_entry:type_name -> olivetin.api.v1.LogEntry
-	71, // 16: olivetin.api.v1.DumpVarsResponse.contents:type_name -> olivetin.api.v1.DumpVarsResponse.ContentsEntry
-	72, // 17: olivetin.api.v1.DumpPublicIdActionMapResponse.contents:type_name -> olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry
-	44, // 18: olivetin.api.v1.EventStreamResponse.entity_changed:type_name -> olivetin.api.v1.EventEntityChanged
-	45, // 19: olivetin.api.v1.EventStreamResponse.config_changed:type_name -> olivetin.api.v1.EventConfigChanged
-	46, // 20: olivetin.api.v1.EventStreamResponse.execution_finished:type_name -> olivetin.api.v1.EventExecutionFinished
-	47, // 21: olivetin.api.v1.EventStreamResponse.execution_started:type_name -> olivetin.api.v1.EventExecutionStarted
-	43, // 22: olivetin.api.v1.EventStreamResponse.output_chunk:type_name -> olivetin.api.v1.EventOutputChunk
-	20, // 23: olivetin.api.v1.EventExecutionFinished.log_entry:type_name -> olivetin.api.v1.LogEntry
-	20, // 24: olivetin.api.v1.EventExecutionStarted.log_entry:type_name -> olivetin.api.v1.LogEntry
-	61, // 25: olivetin.api.v1.InitResponse.oAuth2Providers:type_name -> olivetin.api.v1.OAuth2Provider
-	60, // 26: olivetin.api.v1.InitResponse.additionalLinks:type_name -> olivetin.api.v1.AdditionalLink
-	6,  // 27: olivetin.api.v1.InitResponse.effective_policy:type_name -> olivetin.api.v1.EffectivePolicy
-	0,  // 28: olivetin.api.v1.GetActionBindingResponse.action:type_name -> olivetin.api.v1.Action
-	66, // 29: olivetin.api.v1.GetEntitiesResponse.entity_definitions:type_name -> olivetin.api.v1.EntityDefinition
-	4,  // 30: olivetin.api.v1.EntityDefinition.instances:type_name -> olivetin.api.v1.Entity
-	36, // 31: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry.value:type_name -> olivetin.api.v1.DebugBinding
-	7,  // 32: olivetin.api.v1.OliveTinApiService.GetDashboard:input_type -> olivetin.api.v1.GetDashboardRequest
-	10, // 33: olivetin.api.v1.OliveTinApiService.StartAction:input_type -> olivetin.api.v1.StartActionRequest
-	13, // 34: olivetin.api.v1.OliveTinApiService.StartActionAndWait:input_type -> olivetin.api.v1.StartActionAndWaitRequest
-	15, // 35: olivetin.api.v1.OliveTinApiService.StartActionByGet:input_type -> olivetin.api.v1.StartActionByGetRequest
-	17, // 36: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:input_type -> olivetin.api.v1.StartActionByGetAndWaitRequest
-	68, // 37: olivetin.api.v1.OliveTinApiService.RestartAction:input_type -> olivetin.api.v1.RestartActionRequest
-	48, // 38: olivetin.api.v1.OliveTinApiService.KillAction:input_type -> olivetin.api.v1.KillActionRequest
-	28, // 39: olivetin.api.v1.OliveTinApiService.ExecutionStatus:input_type -> olivetin.api.v1.ExecutionStatusRequest
-	19, // 40: olivetin.api.v1.OliveTinApiService.GetLogs:input_type -> olivetin.api.v1.GetLogsRequest
-	22, // 41: olivetin.api.v1.OliveTinApiService.GetActionLogs:input_type -> olivetin.api.v1.GetActionLogsRequest
-	24, // 42: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:input_type -> olivetin.api.v1.ValidateArgumentTypeRequest
-	30, // 43: olivetin.api.v1.OliveTinApiService.WhoAmI:input_type -> olivetin.api.v1.WhoAmIRequest
-	32, // 44: olivetin.api.v1.OliveTinApiService.SosReport:input_type -> olivetin.api.v1.SosReportRequest
-	34, // 45: olivetin.api.v1.OliveTinApiService.DumpVars:input_type -> olivetin.api.v1.DumpVarsRequest
-	37, // 46: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:input_type -> olivetin.api.v1.DumpPublicIdActionMapRequest
-	39, // 47: olivetin.api.v1.OliveTinApiService.GetReadyz:input_type -> olivetin.api.v1.GetReadyzRequest
-	50, // 48: olivetin.api.v1.OliveTinApiService.LocalUserLogin:input_type -> olivetin.api.v1.LocalUserLoginRequest
-	52, // 49: olivetin.api.v1.OliveTinApiService.PasswordHash:input_type -> olivetin.api.v1.PasswordHashRequest
-	54, // 50: olivetin.api.v1.OliveTinApiService.Logout:input_type -> olivetin.api.v1.LogoutRequest
-	41, // 51: olivetin.api.v1.OliveTinApiService.EventStream:input_type -> olivetin.api.v1.EventStreamRequest
-	56, // 52: olivetin.api.v1.OliveTinApiService.GetDiagnostics:input_type -> olivetin.api.v1.GetDiagnosticsRequest
-	58, // 53: olivetin.api.v1.OliveTinApiService.Init:input_type -> olivetin.api.v1.InitRequest
-	62, // 54: olivetin.api.v1.OliveTinApiService.GetActionBinding:input_type -> olivetin.api.v1.GetActionBindingRequest
-	64, // 55: olivetin.api.v1.OliveTinApiService.GetEntities:input_type -> olivetin.api.v1.GetEntitiesRequest
-	67, // 56: olivetin.api.v1.OliveTinApiService.GetEntity:input_type -> olivetin.api.v1.GetEntityRequest
-	5,  // 57: olivetin.api.v1.OliveTinApiService.GetDashboard:output_type -> olivetin.api.v1.GetDashboardResponse
-	12, // 58: olivetin.api.v1.OliveTinApiService.StartAction:output_type -> olivetin.api.v1.StartActionResponse
-	14, // 59: olivetin.api.v1.OliveTinApiService.StartActionAndWait:output_type -> olivetin.api.v1.StartActionAndWaitResponse
-	16, // 60: olivetin.api.v1.OliveTinApiService.StartActionByGet:output_type -> olivetin.api.v1.StartActionByGetResponse
-	18, // 61: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:output_type -> olivetin.api.v1.StartActionByGetAndWaitResponse
-	12, // 62: olivetin.api.v1.OliveTinApiService.RestartAction:output_type -> olivetin.api.v1.StartActionResponse
-	49, // 63: olivetin.api.v1.OliveTinApiService.KillAction:output_type -> olivetin.api.v1.KillActionResponse
-	29, // 64: olivetin.api.v1.OliveTinApiService.ExecutionStatus:output_type -> olivetin.api.v1.ExecutionStatusResponse
-	21, // 65: olivetin.api.v1.OliveTinApiService.GetLogs:output_type -> olivetin.api.v1.GetLogsResponse
-	23, // 66: olivetin.api.v1.OliveTinApiService.GetActionLogs:output_type -> olivetin.api.v1.GetActionLogsResponse
-	25, // 67: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:output_type -> olivetin.api.v1.ValidateArgumentTypeResponse
-	31, // 68: olivetin.api.v1.OliveTinApiService.WhoAmI:output_type -> olivetin.api.v1.WhoAmIResponse
-	33, // 69: olivetin.api.v1.OliveTinApiService.SosReport:output_type -> olivetin.api.v1.SosReportResponse
-	35, // 70: olivetin.api.v1.OliveTinApiService.DumpVars:output_type -> olivetin.api.v1.DumpVarsResponse
-	38, // 71: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:output_type -> olivetin.api.v1.DumpPublicIdActionMapResponse
-	40, // 72: olivetin.api.v1.OliveTinApiService.GetReadyz:output_type -> olivetin.api.v1.GetReadyzResponse
-	51, // 73: olivetin.api.v1.OliveTinApiService.LocalUserLogin:output_type -> olivetin.api.v1.LocalUserLoginResponse
-	53, // 74: olivetin.api.v1.OliveTinApiService.PasswordHash:output_type -> olivetin.api.v1.PasswordHashResponse
-	55, // 75: olivetin.api.v1.OliveTinApiService.Logout:output_type -> olivetin.api.v1.LogoutResponse
-	42, // 76: olivetin.api.v1.OliveTinApiService.EventStream:output_type -> olivetin.api.v1.EventStreamResponse
-	57, // 77: olivetin.api.v1.OliveTinApiService.GetDiagnostics:output_type -> olivetin.api.v1.GetDiagnosticsResponse
-	59, // 78: olivetin.api.v1.OliveTinApiService.Init:output_type -> olivetin.api.v1.InitResponse
-	63, // 79: olivetin.api.v1.OliveTinApiService.GetActionBinding:output_type -> olivetin.api.v1.GetActionBindingResponse
-	65, // 80: olivetin.api.v1.OliveTinApiService.GetEntities:output_type -> olivetin.api.v1.GetEntitiesResponse
-	4,  // 81: olivetin.api.v1.OliveTinApiService.GetEntity:output_type -> olivetin.api.v1.Entity
-	57, // [57:82] is the sub-list for method output_type
-	32, // [32:57] is the sub-list for method input_type
-	32, // [32:32] is the sub-list for extension type_name
-	32, // [32:32] is the sub-list for extension extendee
-	0,  // [0:32] is the sub-list for field type_name
+	69, // 2: olivetin.api.v1.ActionWebhookExecHint.match_headers:type_name -> olivetin.api.v1.ActionWebhookExecHint.MatchHeadersEntry
+	70, // 3: olivetin.api.v1.ActionWebhookExecHint.match_query:type_name -> olivetin.api.v1.ActionWebhookExecHint.MatchQueryEntry
+	3,  // 4: olivetin.api.v1.ActionArgument.choices:type_name -> olivetin.api.v1.ActionArgumentChoice
+	71, // 5: olivetin.api.v1.ActionArgument.suggestions:type_name -> olivetin.api.v1.ActionArgument.SuggestionsEntry
+	72, // 6: olivetin.api.v1.Entity.fields:type_name -> olivetin.api.v1.Entity.FieldsEntry
+	8,  // 7: olivetin.api.v1.GetDashboardResponse.dashboard:type_name -> olivetin.api.v1.Dashboard
+	9,  // 8: olivetin.api.v1.Dashboard.contents:type_name -> olivetin.api.v1.DashboardComponent
+	9,  // 9: olivetin.api.v1.DashboardComponent.contents:type_name -> olivetin.api.v1.DashboardComponent
+	0,  // 10: olivetin.api.v1.DashboardComponent.action:type_name -> olivetin.api.v1.Action
+	11, // 11: olivetin.api.v1.StartActionRequest.arguments:type_name -> olivetin.api.v1.StartActionArgument
+	11, // 12: olivetin.api.v1.StartActionAndWaitRequest.arguments:type_name -> olivetin.api.v1.StartActionArgument
+	20, // 13: olivetin.api.v1.StartActionAndWaitResponse.log_entry:type_name -> olivetin.api.v1.LogEntry
+	20, // 14: olivetin.api.v1.StartActionByGetAndWaitResponse.log_entry:type_name -> olivetin.api.v1.LogEntry
+	20, // 15: olivetin.api.v1.GetLogsResponse.logs:type_name -> olivetin.api.v1.LogEntry
+	20, // 16: olivetin.api.v1.GetActionLogsResponse.logs:type_name -> olivetin.api.v1.LogEntry
+	20, // 17: olivetin.api.v1.ExecutionStatusResponse.log_entry:type_name -> olivetin.api.v1.LogEntry
+	73, // 18: olivetin.api.v1.DumpVarsResponse.contents:type_name -> olivetin.api.v1.DumpVarsResponse.ContentsEntry
+	74, // 19: olivetin.api.v1.DumpPublicIdActionMapResponse.contents:type_name -> olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry
+	44, // 20: olivetin.api.v1.EventStreamResponse.entity_changed:type_name -> olivetin.api.v1.EventEntityChanged
+	45, // 21: olivetin.api.v1.EventStreamResponse.config_changed:type_name -> olivetin.api.v1.EventConfigChanged
+	46, // 22: olivetin.api.v1.EventStreamResponse.execution_finished:type_name -> olivetin.api.v1.EventExecutionFinished
+	47, // 23: olivetin.api.v1.EventStreamResponse.execution_started:type_name -> olivetin.api.v1.EventExecutionStarted
+	43, // 24: olivetin.api.v1.EventStreamResponse.output_chunk:type_name -> olivetin.api.v1.EventOutputChunk
+	20, // 25: olivetin.api.v1.EventExecutionFinished.log_entry:type_name -> olivetin.api.v1.LogEntry
+	20, // 26: olivetin.api.v1.EventExecutionStarted.log_entry:type_name -> olivetin.api.v1.LogEntry
+	61, // 27: olivetin.api.v1.InitResponse.oAuth2Providers:type_name -> olivetin.api.v1.OAuth2Provider
+	60, // 28: olivetin.api.v1.InitResponse.additionalLinks:type_name -> olivetin.api.v1.AdditionalLink
+	6,  // 29: olivetin.api.v1.InitResponse.effective_policy:type_name -> olivetin.api.v1.EffectivePolicy
+	0,  // 30: olivetin.api.v1.GetActionBindingResponse.action:type_name -> olivetin.api.v1.Action
+	66, // 31: olivetin.api.v1.GetEntitiesResponse.entity_definitions:type_name -> olivetin.api.v1.EntityDefinition
+	4,  // 32: olivetin.api.v1.EntityDefinition.instances:type_name -> olivetin.api.v1.Entity
+	36, // 33: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry.value:type_name -> olivetin.api.v1.DebugBinding
+	7,  // 34: olivetin.api.v1.OliveTinApiService.GetDashboard:input_type -> olivetin.api.v1.GetDashboardRequest
+	10, // 35: olivetin.api.v1.OliveTinApiService.StartAction:input_type -> olivetin.api.v1.StartActionRequest
+	13, // 36: olivetin.api.v1.OliveTinApiService.StartActionAndWait:input_type -> olivetin.api.v1.StartActionAndWaitRequest
+	15, // 37: olivetin.api.v1.OliveTinApiService.StartActionByGet:input_type -> olivetin.api.v1.StartActionByGetRequest
+	17, // 38: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:input_type -> olivetin.api.v1.StartActionByGetAndWaitRequest
+	68, // 39: olivetin.api.v1.OliveTinApiService.RestartAction:input_type -> olivetin.api.v1.RestartActionRequest
+	48, // 40: olivetin.api.v1.OliveTinApiService.KillAction:input_type -> olivetin.api.v1.KillActionRequest
+	28, // 41: olivetin.api.v1.OliveTinApiService.ExecutionStatus:input_type -> olivetin.api.v1.ExecutionStatusRequest
+	19, // 42: olivetin.api.v1.OliveTinApiService.GetLogs:input_type -> olivetin.api.v1.GetLogsRequest
+	22, // 43: olivetin.api.v1.OliveTinApiService.GetActionLogs:input_type -> olivetin.api.v1.GetActionLogsRequest
+	24, // 44: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:input_type -> olivetin.api.v1.ValidateArgumentTypeRequest
+	30, // 45: olivetin.api.v1.OliveTinApiService.WhoAmI:input_type -> olivetin.api.v1.WhoAmIRequest
+	32, // 46: olivetin.api.v1.OliveTinApiService.SosReport:input_type -> olivetin.api.v1.SosReportRequest
+	34, // 47: olivetin.api.v1.OliveTinApiService.DumpVars:input_type -> olivetin.api.v1.DumpVarsRequest
+	37, // 48: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:input_type -> olivetin.api.v1.DumpPublicIdActionMapRequest
+	39, // 49: olivetin.api.v1.OliveTinApiService.GetReadyz:input_type -> olivetin.api.v1.GetReadyzRequest
+	50, // 50: olivetin.api.v1.OliveTinApiService.LocalUserLogin:input_type -> olivetin.api.v1.LocalUserLoginRequest
+	52, // 51: olivetin.api.v1.OliveTinApiService.PasswordHash:input_type -> olivetin.api.v1.PasswordHashRequest
+	54, // 52: olivetin.api.v1.OliveTinApiService.Logout:input_type -> olivetin.api.v1.LogoutRequest
+	41, // 53: olivetin.api.v1.OliveTinApiService.EventStream:input_type -> olivetin.api.v1.EventStreamRequest
+	56, // 54: olivetin.api.v1.OliveTinApiService.GetDiagnostics:input_type -> olivetin.api.v1.GetDiagnosticsRequest
+	58, // 55: olivetin.api.v1.OliveTinApiService.Init:input_type -> olivetin.api.v1.InitRequest
+	62, // 56: olivetin.api.v1.OliveTinApiService.GetActionBinding:input_type -> olivetin.api.v1.GetActionBindingRequest
+	64, // 57: olivetin.api.v1.OliveTinApiService.GetEntities:input_type -> olivetin.api.v1.GetEntitiesRequest
+	67, // 58: olivetin.api.v1.OliveTinApiService.GetEntity:input_type -> olivetin.api.v1.GetEntityRequest
+	5,  // 59: olivetin.api.v1.OliveTinApiService.GetDashboard:output_type -> olivetin.api.v1.GetDashboardResponse
+	12, // 60: olivetin.api.v1.OliveTinApiService.StartAction:output_type -> olivetin.api.v1.StartActionResponse
+	14, // 61: olivetin.api.v1.OliveTinApiService.StartActionAndWait:output_type -> olivetin.api.v1.StartActionAndWaitResponse
+	16, // 62: olivetin.api.v1.OliveTinApiService.StartActionByGet:output_type -> olivetin.api.v1.StartActionByGetResponse
+	18, // 63: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:output_type -> olivetin.api.v1.StartActionByGetAndWaitResponse
+	12, // 64: olivetin.api.v1.OliveTinApiService.RestartAction:output_type -> olivetin.api.v1.StartActionResponse
+	49, // 65: olivetin.api.v1.OliveTinApiService.KillAction:output_type -> olivetin.api.v1.KillActionResponse
+	29, // 66: olivetin.api.v1.OliveTinApiService.ExecutionStatus:output_type -> olivetin.api.v1.ExecutionStatusResponse
+	21, // 67: olivetin.api.v1.OliveTinApiService.GetLogs:output_type -> olivetin.api.v1.GetLogsResponse
+	23, // 68: olivetin.api.v1.OliveTinApiService.GetActionLogs:output_type -> olivetin.api.v1.GetActionLogsResponse
+	25, // 69: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:output_type -> olivetin.api.v1.ValidateArgumentTypeResponse
+	31, // 70: olivetin.api.v1.OliveTinApiService.WhoAmI:output_type -> olivetin.api.v1.WhoAmIResponse
+	33, // 71: olivetin.api.v1.OliveTinApiService.SosReport:output_type -> olivetin.api.v1.SosReportResponse
+	35, // 72: olivetin.api.v1.OliveTinApiService.DumpVars:output_type -> olivetin.api.v1.DumpVarsResponse
+	38, // 73: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:output_type -> olivetin.api.v1.DumpPublicIdActionMapResponse
+	40, // 74: olivetin.api.v1.OliveTinApiService.GetReadyz:output_type -> olivetin.api.v1.GetReadyzResponse
+	51, // 75: olivetin.api.v1.OliveTinApiService.LocalUserLogin:output_type -> olivetin.api.v1.LocalUserLoginResponse
+	53, // 76: olivetin.api.v1.OliveTinApiService.PasswordHash:output_type -> olivetin.api.v1.PasswordHashResponse
+	55, // 77: olivetin.api.v1.OliveTinApiService.Logout:output_type -> olivetin.api.v1.LogoutResponse
+	42, // 78: olivetin.api.v1.OliveTinApiService.EventStream:output_type -> olivetin.api.v1.EventStreamResponse
+	57, // 79: olivetin.api.v1.OliveTinApiService.GetDiagnostics:output_type -> olivetin.api.v1.GetDiagnosticsResponse
+	59, // 80: olivetin.api.v1.OliveTinApiService.Init:output_type -> olivetin.api.v1.InitResponse
+	63, // 81: olivetin.api.v1.OliveTinApiService.GetActionBinding:output_type -> olivetin.api.v1.GetActionBindingResponse
+	65, // 82: olivetin.api.v1.OliveTinApiService.GetEntities:output_type -> olivetin.api.v1.GetEntitiesResponse
+	4,  // 83: olivetin.api.v1.OliveTinApiService.GetEntity:output_type -> olivetin.api.v1.Entity
+	59, // [59:84] is the sub-list for method output_type
+	34, // [34:59] is the sub-list for method input_type
+	34, // [34:34] is the sub-list for extension type_name
+	34, // [34:34] is the sub-list for extension extendee
+	0,  // [0:34] is the sub-list for field type_name
 }
 
 func init() { file_olivetin_api_v1_olivetin_proto_init() }
@@ -4514,7 +4543,7 @@ func file_olivetin_api_v1_olivetin_proto_init() {
 			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
 			RawDescriptor: unsafe.Slice(unsafe.StringData(file_olivetin_api_v1_olivetin_proto_rawDesc), len(file_olivetin_api_v1_olivetin_proto_rawDesc)),
 			NumEnums:      0,
-			NumMessages:   73,
+			NumMessages:   75,
 			NumExtensions: 0,
 			NumServices:   1,
 		},

+ 4 - 2
service/internal/api/apiActionExecTriggers.go

@@ -18,8 +18,10 @@ func applyActionExecTriggers(pb *apiv1.Action, cfg *config.Action) {
 
 	for _, wh := range cfg.ExecOnWebhook {
 		pb.ExecOnWebhooks = append(pb.ExecOnWebhooks, &apiv1.ActionWebhookExecHint{
-			Template:  wh.Template,
-			MatchPath: wh.MatchPath,
+			Template:     wh.Template,
+			MatchPath:    wh.MatchPath,
+			MatchHeaders: wh.MatchHeaders,
+			MatchQuery:   wh.MatchQuery,
 		})
 	}
 }

+ 18 - 0
service/internal/api/api_test.go

@@ -52,6 +52,24 @@ func getNewTestServerAndClient(injectedConfig *config.Config) (*httptest.Server,
 	return ts, client
 }
 
+func TestApplyActionExecTriggersIncludesWebhookHeaderAndQueryMatches(t *testing.T) {
+	cfg := &config.Action{
+		ExecOnWebhook: []config.WebhookConfig{
+			{
+				MatchHeaders: map[string]string{"X-GitHub-Event": "push"},
+				MatchQuery:   map[string]string{"source": "github"},
+			},
+		},
+	}
+	pb := &apiv1.Action{}
+
+	applyActionExecTriggers(pb, cfg)
+
+	require.Len(t, pb.ExecOnWebhooks, 1)
+	assert.Equal(t, cfg.ExecOnWebhook[0].MatchHeaders, pb.ExecOnWebhooks[0].MatchHeaders)
+	assert.Equal(t, cfg.ExecOnWebhook[0].MatchQuery, pb.ExecOnWebhooks[0].MatchQuery)
+}
+
 func TestGetActionsAndStart(t *testing.T) {
 	cfg := config.DefaultConfig()
 

+ 3 - 0
service/internal/config/config.go

@@ -4,6 +4,9 @@ import (
 	"fmt"
 )
 
+// ReservedArgumentNamePrefix is reserved for OliveTin-injected system arguments.
+const ReservedArgumentNamePrefix = "ot_"
+
 // Action represents the core functionality of OliveTin - commands that show up
 // as buttons in the UI.
 type Action struct {

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

@@ -26,6 +26,34 @@ func (cfg *Config) Sanitize() {
 	}
 
 	cfg.sanitizeDashboardsForInlineActions()
+
+	if err := cfg.validateReservedActionArgumentNames(); err != nil {
+		log.Fatalf("%v", err)
+	}
+}
+
+func (cfg *Config) validateReservedActionArgumentNames() error {
+	for _, action := range cfg.Actions {
+		if err := action.validateReservedArgumentNames(); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func (action *Action) validateReservedArgumentNames() error {
+	if action == nil {
+		return nil
+	}
+
+	for _, arg := range action.Arguments {
+		if strings.HasPrefix(arg.Name, ReservedArgumentNamePrefix) {
+			return fmt.Errorf("action %q argument %q uses reserved prefix %q", action.Title, arg.Name, ReservedArgumentNamePrefix)
+		}
+	}
+
+	return nil
 }
 
 func (cfg *Config) sanitizeDashboardsForInlineActions() {

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

@@ -92,6 +92,59 @@ func TestSanitizeConfigInlineDashboardActions(t *testing.T) {
 	}
 }
 
+func TestValidateReservedActionArgumentNames(t *testing.T) {
+	c := DefaultConfig()
+	c.Actions = append(c.Actions, &Action{
+		Title: "Reserved arg",
+		Arguments: []ActionArgument{
+			{Name: "ot_custom", Type: "ascii"},
+		},
+	})
+
+	err := c.validateReservedActionArgumentNames()
+
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), `action "Reserved arg" argument "ot_custom" uses reserved prefix "ot_"`)
+}
+
+func TestValidateReservedActionArgumentNamesAllowsNonReserved(t *testing.T) {
+	c := DefaultConfig()
+	c.Actions = append(c.Actions, &Action{
+		Title: "Allowed arg",
+		Arguments: []ActionArgument{
+			{Name: "target", Type: "ascii"},
+		},
+	})
+
+	require.NoError(t, c.validateReservedActionArgumentNames())
+}
+
+func TestValidateReservedActionArgumentNamesChecksInlineActions(t *testing.T) {
+	c := DefaultConfig()
+	c.Dashboards = []*DashboardComponent{
+		{
+			Title: "Dashboard",
+			Contents: []*DashboardComponent{
+				{
+					Title: "Inline reserved arg",
+					InlineAction: &Action{
+						Shell: "echo test",
+						Arguments: []ActionArgument{
+							{Name: "ot_custom", Type: "ascii"},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	c.sanitizeDashboardsForInlineActions()
+	err := c.validateReservedActionArgumentNames()
+
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), `action "Inline reserved arg" argument "ot_custom" uses reserved prefix "ot_"`)
+}
+
 func TestValidateUniqueLocalUserAPIKeys(t *testing.T) {
 	t.Parallel()
 

+ 1 - 0
service/internal/executor/arguments.go

@@ -21,6 +21,7 @@ var (
 		"unicode_identifier":        `^[\w\-\.\_\d]+$`,
 		"ascii":                     `^[a-zA-Z0-9]+$`,
 		"ascii_identifier":          `^[a-zA-Z0-9\-\._]+$`,
+		"shell_safe_identifier":     `^[a-zA-Z0-9@\.\_\+\-]+$`,
 		"ascii_sentence":            `^[a-zA-Z0-9\-\._, ]+$`,
 	}
 )

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

@@ -576,6 +576,38 @@ func TestTypeSafetyCheckAsciiIdentifier(t *testing.T) {
 	}
 }
 
+func TestTypeSafetyCheckShellSafeIdentifier(t *testing.T) {
+	tests := []struct {
+		name     string
+		value    string
+		hasError bool
+	}{
+		{"Simple username", "alice123", false},
+		{"Email username", "alice@example.com", false},
+		{"Plus addressing", "alice+test@example.com", false},
+		{"Hyphen underscore dot", "alice-test_user.example", false},
+		{"Invalid space", "alice example", true},
+		{"Invalid shell substitution", "$(whoami)", true},
+		{"Invalid backtick", "`whoami`", true},
+		{"Invalid semicolon", "alice;id", true},
+		{"Invalid ampersand", "alice&id", true},
+		{"Invalid pipe", "alice|id", true},
+		{"Invalid quote", "alice'example", true},
+		{"Invalid slash", "alice/example", true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := TypeSafetyCheck("username", tt.value, "shell_safe_identifier")
+			if tt.hasError {
+				assert.NotNil(t, err, "Expected error for value '%s'", tt.value)
+			} else {
+				assert.Nil(t, err, "Expected no error for value '%s', but got: %v", tt.value, err)
+			}
+		})
+	}
+}
+
 func TestTypeSafetyCheckAsciiSentence(t *testing.T) {
 	tests := []struct {
 		name     string

+ 77 - 25
service/internal/executor/executor.go

@@ -666,13 +666,15 @@ func stepACLCheck(req *ExecutionRequest) bool {
 
 func stepParseArgs(req *ExecutionRequest) bool {
 	ensureArgumentMap(req)
-	injectSystemArgs(req)
 
 	if !hasBindingAndAction(req) {
 		return fail(req, fmt.Errorf("cannot parse arguments: Binding or Action is nil"))
 	}
 
 	filterToDefinedArgumentsOnly(req)
+	if err := injectSystemArgs(req); err != nil {
+		return fail(req, err)
+	}
 	mangleInvalidArgumentValues(req)
 
 	if hasExec(req) {
@@ -735,7 +737,7 @@ func filterToDefinedArgumentsOnly(req *ExecutionRequest) {
 
 func keepArgument(name string, definedNames map[string]struct{}) bool {
 	_, ok := definedNames[name]
-	return ok || strings.HasPrefix(name, "ot_")
+	return ok
 }
 
 func hasWebhookTag(req *ExecutionRequest) bool {
@@ -747,9 +749,38 @@ func hasWebhookTag(req *ExecutionRequest) bool {
 	return false
 }
 
-func injectSystemArgs(req *ExecutionRequest) {
-	req.Arguments["ot_executionTrackingId"] = req.TrackingID
-	req.Arguments["ot_username"] = req.AuthenticatedUser.Username
+var systemArgumentDefinitions = []config.ActionArgument{
+	{Name: "ot_executionTrackingId", Type: "ascii_identifier", RejectNull: true},
+	{Name: "ot_username", Type: "shell_safe_identifier", RejectNull: true},
+}
+
+func injectSystemArgs(req *ExecutionRequest) error {
+	args, err := validatedSystemArgs(req)
+	if err != nil {
+		return err
+	}
+
+	for name, value := range args {
+		req.Arguments[name] = value
+	}
+
+	return nil
+}
+
+func validatedSystemArgs(req *ExecutionRequest) (map[string]string, error) {
+	values := map[string]string{
+		"ot_executionTrackingId": req.TrackingID,
+		"ot_username":            req.AuthenticatedUser.Username,
+	}
+
+	for i := range systemArgumentDefinitions {
+		arg := &systemArgumentDefinitions[i]
+		if err := ValidateArgument(arg, values[arg.Name], req.Binding.Action); err != nil {
+			return nil, fmt.Errorf("system argument %q failed validation: %w", arg.Name, err)
+		}
+	}
+
+	return values, nil
 }
 
 func hasBindingAndAction(req *ExecutionRequest) bool {
@@ -939,36 +970,20 @@ func prepareCommand(cmd *exec.Cmd, streamer *OutputStreamer, req *ExecutionReque
 }
 
 func stepExecAfter(req *ExecutionRequest) bool {
-	if req.Binding.Action.ShellAfterCompleted == "" {
-		return true
-	}
-
 	ctx, cancel := newTimeoutContext(context.Background(), time.Duration(req.Binding.Action.Timeout)*time.Second, req.executor)
 	defer cancel()
 
 	var stdout bytes.Buffer
 	var stderr bytes.Buffer
 
-	args := map[string]string{
-		"output":                 req.logEntry.Output,
-		"exitCode":               fmt.Sprintf("%v", req.logEntry.ExitCode),
-		"ot_executionTrackingId": req.TrackingID,
-		"ot_username":            req.AuthenticatedUser.Username,
-	}
-
-	finalParsedCommand, err := tpl.ParseTemplateWithActionContext(req.Binding.Action.ShellAfterCompleted, req.Binding.Entity, args)
-
+	cmd, args, err := buildShellAfterCommand(ctx, req, &stdout, &stderr)
 	if err != nil {
-		msg := "Could not prepare shellAfterCompleted command: " + err.Error() + "\n"
-		req.logEntry.Output += msg
-		log.Warn(msg)
+		return fail(req, err)
+	}
+	if cmd == nil {
 		return true
 	}
 
-	cmd := wrapCommandInShell(ctx, finalParsedCommand)
-	cmd.Stdout = &stdout
-	cmd.Stderr = &stderr
-
 	cmd.Env = buildEnv(args)
 
 	runerr := cmd.Start()
@@ -998,6 +1013,43 @@ func stepExecAfter(req *ExecutionRequest) bool {
 	return true
 }
 
+func buildShellAfterCommand(ctx context.Context, req *ExecutionRequest, stdout, stderr *bytes.Buffer) (*exec.Cmd, map[string]string, error) {
+	if req.Binding.Action.ShellAfterCompleted == "" {
+		return nil, nil, nil
+	}
+
+	args, err := buildShellAfterArgs(req)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	finalParsedCommand, err := tpl.ParseTemplateWithActionContext(req.Binding.Action.ShellAfterCompleted, req.Binding.Entity, args)
+	if err != nil {
+		msg := "Could not prepare shellAfterCompleted command: " + err.Error() + "\n"
+		req.logEntry.Output += msg
+		log.Warn(msg)
+		return nil, nil, nil
+	}
+
+	cmd := wrapCommandInShell(ctx, finalParsedCommand)
+	cmd.Stdout = stdout
+	cmd.Stderr = stderr
+
+	return cmd, args, nil
+}
+
+func buildShellAfterArgs(req *ExecutionRequest) (map[string]string, error) {
+	args, err := validatedSystemArgs(req)
+	if err != nil {
+		return nil, err
+	}
+
+	args["output"] = req.logEntry.Output
+	args["exitCode"] = fmt.Sprintf("%v", req.logEntry.ExitCode)
+
+	return args, nil
+}
+
 //gocyclo:ignore
 func stepTrigger(req *ExecutionRequest) bool {
 	if req.Binding.Action.Triggers == nil {

+ 206 - 4
service/internal/executor/executor_test.go

@@ -1,6 +1,7 @@
 package executor
 
 import (
+	"strings"
 	"testing"
 	"time"
 
@@ -37,7 +38,7 @@ func TestCreateExecutorAndExec(t *testing.T) {
 	e, cfg := testingExecutor()
 
 	req := ExecutionRequest{
-		AuthenticatedUser: &authpublic.AuthenticatedUser{Username: "Mr Tickle"},
+		AuthenticatedUser: &authpublic.AuthenticatedUser{Username: "MrTickle"},
 		Cfg:               cfg,
 		Arguments: map[string]string{
 			"person": "yourself",
@@ -379,7 +380,7 @@ func TestFilterToDefinedArgumentsOnly(t *testing.T) {
 	assert.Empty(t, req.Arguments["extra_undefined"])
 }
 
-func TestFilterToDefinedArgumentsPreservesSystemArgs(t *testing.T) {
+func TestFilterToDefinedArgumentsDropsReservedPrefixArgs(t *testing.T) {
 	req := newExecRequest()
 	req.Binding.Action = &config.Action{
 		Title:     "Filter test",
@@ -393,8 +394,209 @@ func TestFilterToDefinedArgumentsPreservesSystemArgs(t *testing.T) {
 
 	filterToDefinedArgumentsOnly(req)
 
-	assert.Equal(t, "track-123", req.Arguments["ot_executionTrackingId"])
-	assert.Equal(t, "webhook", req.Arguments["ot_username"])
+	assert.Empty(t, req.Arguments["ot_executionTrackingId"])
+	assert.Empty(t, req.Arguments["ot_username"])
+}
+
+func TestStepParseArgsInjectsSystemArgsAfterFiltering(t *testing.T) {
+	req := newExecRequest()
+	req.TrackingID = "server-track-456"
+	req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice"}
+	req.Binding.Action = &config.Action{
+		Title: "Filter then inject",
+		Shell: "echo test",
+		Arguments: []config.ActionArgument{
+			{Name: "name", Type: "ascii"},
+		},
+	}
+	req.Arguments = map[string]string{
+		"name":                   "Alice",
+		"ot_executionTrackingId": "attacker-track",
+		"ot_username":            "mallory",
+		"ot_custom":              "polluted",
+	}
+
+	assert.True(t, stepParseArgs(req))
+	assert.Equal(t, "Alice", req.Arguments["name"])
+	assert.Equal(t, "server-track-456", req.Arguments["ot_executionTrackingId"])
+	assert.Equal(t, "alice", req.Arguments["ot_username"])
+	assert.Empty(t, req.Arguments["ot_custom"])
+}
+
+func TestStepParseArgsDropsReservedPrefixArgsFromEnvironment(t *testing.T) {
+	req := newExecRequest()
+	req.TrackingID = "server-track-456"
+	req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice@example.com"}
+	req.Binding.Action = &config.Action{
+		Title:     "No reserved prefix pollution",
+		Shell:     "echo test",
+		Arguments: []config.ActionArgument{},
+	}
+	req.Arguments = map[string]string{
+		"ot_custom": "polluted",
+	}
+
+	assert.True(t, stepParseArgs(req))
+	env := buildEnv(req.Arguments)
+
+	assert.False(t, containsEnvPrefix(env, "OT_CUSTOM="))
+	assert.True(t, containsEnvPrefix(env, "OT_USERNAME=alice@example.com"))
+	assert.True(t, containsEnvPrefix(env, "OT_EXECUTIONTRACKINGID=server-track-456"))
+}
+
+func TestSystemArgumentDefinitionsAreReservedAndShellSafe(t *testing.T) {
+	unsafeTypes := map[string]struct{}{
+		"email":                     {},
+		"password":                  {},
+		"raw_string_multiline":      {},
+		"url":                       {},
+		"very_dangerous_raw_string": {},
+	}
+	seen := map[string]struct{}{}
+
+	for _, arg := range systemArgumentDefinitions {
+		assert.True(t, strings.HasPrefix(arg.Name, config.ReservedArgumentNamePrefix))
+		assert.NotEmpty(t, arg.Type)
+		assert.True(t, arg.RejectNull)
+
+		_, duplicate := seen[arg.Name]
+		assert.False(t, duplicate, "duplicate system argument definition %q", arg.Name)
+		seen[arg.Name] = struct{}{}
+
+		_, unsafe := unsafeTypes[arg.Type]
+		assert.False(t, unsafe, "system argument %q uses unsafe type %q", arg.Name, arg.Type)
+	}
+}
+
+func TestValidatedSystemArgsMatchesSystemArgumentDefinitions(t *testing.T) {
+	req := newExecRequest()
+	req.TrackingID = "server-track-456"
+	req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice@example.com"}
+
+	args, err := validatedSystemArgs(req)
+
+	assert.Nil(t, err)
+	assert.Len(t, args, len(systemArgumentDefinitions))
+	for _, arg := range systemArgumentDefinitions {
+		assert.Contains(t, args, arg.Name)
+	}
+}
+
+func TestBuildShellAfterArgsOnlyAddsExpectedNonSystemArgs(t *testing.T) {
+	req := newExecRequest()
+	req.logEntry = &InternalLogEntry{
+		Output:   "hello",
+		ExitCode: 7,
+	}
+	req.TrackingID = "server-track-456"
+	req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice@example.com"}
+	req.Binding.Action = &config.Action{ShellAfterCompleted: "echo test"}
+
+	args, err := buildShellAfterArgs(req)
+
+	assert.Nil(t, err)
+	assert.Len(t, args, len(systemArgumentDefinitions)+2)
+	assert.Contains(t, args, "output")
+	assert.Contains(t, args, "exitCode")
+	for _, arg := range systemArgumentDefinitions {
+		assert.Contains(t, args, arg.Name)
+	}
+}
+
+func TestStepParseArgsAllowsEmailUsernameSystemArg(t *testing.T) {
+	req := newExecRequest()
+	req.logEntry = &InternalLogEntry{}
+	req.TrackingID = "server-track-456"
+	req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice@example.com"}
+	req.Binding.Action = &config.Action{
+		Title:     "Email username",
+		Shell:     "echo test",
+		Arguments: []config.ActionArgument{},
+	}
+
+	assert.True(t, stepParseArgs(req))
+	assert.Equal(t, "alice@example.com", req.Arguments["ot_username"])
+}
+
+func TestStepParseArgsFailsWhenUsernameSystemArgIsInvalid(t *testing.T) {
+	req := newExecRequest()
+	req.logEntry = &InternalLogEntry{}
+	req.TrackingID = "server-track-456"
+	req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice;id"}
+	req.Binding.Action = &config.Action{
+		Title:     "Invalid system arg",
+		Shell:     "echo test",
+		Arguments: []config.ActionArgument{},
+	}
+
+	assert.False(t, stepParseArgs(req))
+	assert.Contains(t, req.logEntry.Output, `system argument "ot_username" failed validation`)
+	assert.Empty(t, req.Arguments["ot_username"])
+}
+
+func TestStepParseArgsFailsWhenTrackingIDSystemArgIsInvalid(t *testing.T) {
+	req := newExecRequest()
+	req.logEntry = &InternalLogEntry{}
+	req.TrackingID = "track/../../bad"
+	req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice"}
+	req.Binding.Action = &config.Action{
+		Title:     "Invalid tracking ID",
+		Shell:     "echo test",
+		Arguments: []config.ActionArgument{},
+	}
+
+	assert.False(t, stepParseArgs(req))
+	assert.Contains(t, req.logEntry.Output, `system argument "ot_executionTrackingId" failed validation`)
+	assert.Empty(t, req.Arguments["ot_executionTrackingId"])
+}
+
+func TestBuildShellAfterArgsUsesValidatedSystemArgs(t *testing.T) {
+	req := newExecRequest()
+	req.logEntry = &InternalLogEntry{
+		Output:   "hello",
+		ExitCode: 7,
+	}
+	req.TrackingID = "server-track-456"
+	req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice@example.com"}
+	req.Binding.Action = &config.Action{
+		Title:               "Shell after",
+		ShellAfterCompleted: "echo test",
+	}
+
+	args, err := buildShellAfterArgs(req)
+
+	assert.Nil(t, err)
+	assert.Equal(t, "alice@example.com", args["ot_username"])
+	assert.Equal(t, "server-track-456", args["ot_executionTrackingId"])
+	assert.Equal(t, "hello", args["output"])
+	assert.Equal(t, "7", args["exitCode"])
+}
+
+func TestBuildShellAfterArgsFailsWhenSystemArgIsInvalid(t *testing.T) {
+	req := newExecRequest()
+	req.logEntry = &InternalLogEntry{}
+	req.TrackingID = "server-track-456"
+	req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice;id"}
+	req.Binding.Action = &config.Action{
+		Title:               "Shell after invalid username",
+		ShellAfterCompleted: "echo test",
+	}
+
+	args, err := buildShellAfterArgs(req)
+
+	assert.Nil(t, args)
+	assert.NotNil(t, err)
+	assert.Contains(t, err.Error(), `system argument "ot_username" failed validation`)
+}
+
+func containsEnvPrefix(env []string, prefix string) bool {
+	for _, item := range env {
+		if strings.HasPrefix(item, prefix) {
+			return true
+		}
+	}
+
+	return false
 }
 
 func TestTriggerExecutesTriggeredAction(t *testing.T) {

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini