diff --git a/private/domain.te b/private/domain.te
index 326e62ae1ca1fd6547ca76a04217d751805d929d..dda8f21e9413cc0b052c91f0e20df7c3bfba6213 100644
--- a/private/domain.te
+++ b/private/domain.te
@@ -10,7 +10,8 @@ allow domain crash_dump:process sigchld;
 # heap profiling, as initialization will fail if it does not have the
 # necessary SELinux permissions.
 get_prop(domain, heapprofd_prop);
-userdebug_or_eng(`can_profile_heap({
+# Allow heap profiling on debug builds.
+userdebug_or_eng(`can_profile_heap_userdebug_or_eng({
   domain
   -bpfloader
   -init
diff --git a/private/ephemeral_app.te b/private/ephemeral_app.te
index 3500c0f7f3f363c6a4cbadecac1cb79f521f5d9f..9a6a300a0cd138adbc0e8288ddb2820a88c34249 100644
--- a/private/ephemeral_app.te
+++ b/private/ephemeral_app.te
@@ -49,6 +49,10 @@ allow ephemeral_app traced:fd use;
 allow ephemeral_app traced_tmpfs:file { read write getattr map };
 unix_socket_connect(ephemeral_app, traced_producer, traced)
 
+# Allow heap profiling if the app opts in by being marked
+# profileable/debuggable.
+can_profile_heap(ephemeral_app)
+
 # allow ephemeral apps to use UDP sockets provided by the system server but not
 # modify them other than to connect
 allow ephemeral_app system_server:udp_socket {
diff --git a/private/heapprofd.te b/private/heapprofd.te
index 5a179907dace93219d412830ad548a0d8968f3dd..7f8d8d62b89afa2d2fd94d3981b2dfe4ca58db02 100644
--- a/private/heapprofd.te
+++ b/private/heapprofd.te
@@ -1,33 +1,47 @@
-# Android Heap Profiler Daemon go/heapprofd
+# Android heap profiling daemon. go/heapprofd.
+#
+# On user builds, this daemon is responsible for receiving the initial
+# profiling configuration, finding matching target processes (if profiling by
+# process name), and sending the activation signal to them (+ setting system
+# properties for new processes to start profiling from startup). When profiling
+# is triggered in a process, it spawns a private heapprofd subprocess (in its
+# own SELinux domain), which will exclusively handle profiling of its parent.
+#
+# On debug builds, this central daemon performs profiling for all target
+# processes (which talk directly to this daemon).
 type heapprofd_exec, exec_type, file_type, system_file_type;
 
 init_daemon_domain(heapprofd)
 
 set_prop(heapprofd, heapprofd_prop);
 
+# Necessary for /proc/[pid]/cmdline access & sending signals.
+typeattribute heapprofd mlstrustedsubject;
+
+# Allow sending signals to processes. This excludes SIGKILL, SIGSTOP and
+# SIGCHLD, which are controlled by separate permissions.
+allow heapprofd self:capability kill;
+
+# When scanning /proc/[pid]/cmdline to find matching processes for by-name
+# profiling, only whitelisted domains will be allowed by SELinux. Avoid
+# spamming logs with denials for entries that we can not access.
+dontaudit heapprofd domain:dir { search open };
+
+# Write trace data to the Perfetto traced daemon. This requires connecting to
+# its producer socket and obtaining a (per-process) tmpfs fd.
+allow heapprofd traced:fd use;
+allow heapprofd traced_tmpfs:file { read write getattr map };
+unix_socket_connect(heapprofd, traced_producer, traced)
+
+# When handling profiling for all processes, heapprofd needs to read
+# executables/libraries/etc to do stack unwinding.
 userdebug_or_eng(`
-  # TODO(fmayer): We will also need this on user to read /proc/<pid>/cmdline
-  # and send signals.
-  typeattribute heapprofd mlstrustedsubject;
-  # Allow to send signal to processes.
-  # This excludes SIGKILL, SIGSTOP and SIGCHLD,
-  # which are controlled by separate permissions.
-  allow heapprofd self:capability kill;
-
-  # Executables and libraries.
-  # These are needed to read the ELF binary data needed for unwinding.
   r_dir_file(heapprofd, system_file_type)
   r_dir_file(heapprofd, apk_data_file)
   r_dir_file(heapprofd, dalvikcache_data_file)
   r_dir_file(heapprofd, vendor_file_type)
 ')
 
-# Write trace data to the Perfetto traced damon. This requires connecting to its
-# producer socket and obtaining a (per-process) tmpfs fd.
-allow heapprofd traced:fd use;
-allow heapprofd traced_tmpfs:file { read write getattr map };
-unix_socket_connect(heapprofd, traced_producer, traced)
-
 never_profile_heap(`{
   bpfloader
   init
diff --git a/private/isolated_app.te b/private/isolated_app.te
index 3443dc439d4c656c59675c86ca91e24a485ec3ef..017f46b06241c109f85406f92136310323c3d43e 100644
--- a/private/isolated_app.te
+++ b/private/isolated_app.te
@@ -60,6 +60,10 @@ allow isolated_app traced:fd use;
 allow isolated_app traced_tmpfs:file { read write getattr map };
 unix_socket_connect(isolated_app, traced_producer, traced)
 
+# Allow heap profiling if the main app has been marked as profileable or
+# debuggable.
+can_profile_heap(isolated_app)
+
 #####
 ##### Neverallow
 #####
diff --git a/private/priv_app.te b/private/priv_app.te
index b6828f063258c76b009c3db91e15c426e6dd10d6..9232bd0f947b5cb10c197a40f2c3f0915de9ad53 100644
--- a/private/priv_app.te
+++ b/private/priv_app.te
@@ -144,6 +144,10 @@ allow priv_app traced:fd use;
 allow priv_app traced_tmpfs:file { read write getattr map };
 unix_socket_connect(priv_app, traced_producer, traced)
 
+# Allow heap profiling if the app opts in by being marked
+# profileable/debuggable.
+can_profile_heap(priv_app)
+
 # suppress denials for non-API accesses.
 dontaudit priv_app exec_type:file getattr;
 dontaudit priv_app device:dir read;
diff --git a/private/untrusted_app_all.te b/private/untrusted_app_all.te
index ba707516b547408797fd12140fc1d42995dcdca1..a4af4e70825742e201df2f0ef2aa61a49311251c 100644
--- a/private/untrusted_app_all.te
+++ b/private/untrusted_app_all.te
@@ -123,6 +123,10 @@ allow untrusted_app_all traced:fd use;
 allow untrusted_app_all traced_tmpfs:file { read write getattr map };
 unix_socket_connect(untrusted_app_all, traced_producer, traced)
 
+# Allow heap profiling if the app opts in by being marked
+# profileable/debuggable.
+can_profile_heap(untrusted_app_all)
+
 # allow untrusted apps to use UDP sockets provided by the system server but not
 # modify them other than to connect
 allow untrusted_app_all system_server:udp_socket {
diff --git a/public/te_macros b/public/te_macros
index 149d5ac22d8b6ffbdb50f1eb93512986ceb9d499..ca6070b616bb31203dc9dee861b87f5c0d113d98 100644
--- a/public/te_macros
+++ b/public/te_macros
@@ -647,31 +647,66 @@ define(`hal_attribute_hwservice', `
 
 ###################################
 # can_profile_heap(domain)
-# never_profile_heap(domain)
-#
-# Opt in our out of heap profiling.
-# This will allow a heap profiling daemon to read this
-# process' address space in order to support unwinding.
+# Allow processes within the domain to have their heap profiled by heapprofd.
 #
+# Note that profiling is performed differently between debug and user builds.
+# This macro covers both user and debug builds, but see
+# can_profile_heap_userdebug_or_eng for a variant that can be used when
+# allowing profiling for a domain only on debug builds, without granting
+# the exec permission. The exec permission is necessary for user builds, but
+# only a nice-to-have for development and testing purposes on debug builds.
 define(`can_profile_heap', `
+  # Allow central daemon to send signal for client initialization.
+  allow heapprofd $1:process signal;
+
+  # Allow executing a private heapprofd process to handle profiling on
+  # user builds (also debug builds for testing & development purposes).
+  allow $1 heapprofd_exec:file rx_file_perms;
+
+  # Allow directory & file read to the central heapprofd daemon, as it scans
+  # /proc/[pid]/cmdline for by-process-name profiling configs.
+  # Note that this excludes /proc/[pid]/mem, as it requires ptrace capabilities.
+  allow heapprofd $1:file r_file_perms;
+  allow heapprofd $1:dir r_dir_perms;
+
+  # On debug builds, central daemon can handle profiling of all processes
+  # directly.
+  userdebug_or_eng(`
+    # Allow connecting to the daemon.
+    unix_socket_connect($1, heapprofd, heapprofd)
+    # Allow daemon to use the passed fds.
+    allow heapprofd $1:fd use;
+  ')
+')
+
+###################################
+# can_profile_heap_userdebug_or_eng(domain)
+# Allow processes within the domain to have their heap profiled by heapprofd on
+# debug builds only.
+#
+# Only necessary when can_profile_heap cannot be applied, see its description
+# for rationale.
+define(`can_profile_heap_userdebug_or_eng', `
   userdebug_or_eng(`
-    # RT signal for client initialization.
+    # Allow central daemon to send signal for client initialization.
     allow heapprofd $1:process signal;
-    # Connect to heapprofd service.
+    # Allow connecting to the daemon.
     unix_socket_connect($1, heapprofd, heapprofd)
-    # To receive file descriptor.
+    # Allow daemon to use the passed fds.
     allow heapprofd $1:fd use;
 
     # To read from the received file descriptors.
     # /proc/[pid]/maps and /proc/[pid]/mem have the same SELinux label as the
     # process they relate to.
     allow heapprofd $1:file r_file_perms;
-    # This is needed to search the /proc/[pid] directory.
+    # Allow searching the /proc/[pid] directory for cmdline.
     allow heapprofd $1:dir r_dir_perms;
-    allow heapprofd $1:process signal;
   ')
 ')
 
+###################################
+# never_profile_heap(domain)
+# Opt out of heap profiling by heapprofd.
 define(`never_profile_heap', `
   neverallow heapprofd $1:file read;
   neverallow heapprofd $1:process signal;