Summary
A sudo denial that carries no error code reaches the terminal with nothing actionable beside it. The plugin prints Alpacon denied this sudo command.—no code, no parenthesis—and neither the hint table nor the unknown-code fallback matches that line, so the user gets the bare denial and no next step.
Where
sudoDenialHint (cmd/exec/run.go:235-247) has two paths, and both anchor on sudoDenialLinePrefix:
const sudoDenialLinePrefix = "Alpacon denied this sudo command ("
- the table scan, via
denialCodePresent(output, h.code) → sudoDenialLinePrefix + code + ")."
- the fallback, via
firstDenialCode(output) → strings.Cut(rest, sudoDenialLinePrefix)
The prefix ends in (, so a line without the code slot matches neither and sudoDenialHint returns "".
When the code is absent
Two branches on the agent side, both reached on a real denial:
alpacon_approval.c:449-451 — g_plugin_printf(SUDO_CONV_ERROR_MSG, "Alpacon denied this sudo command.\n") when error_code_buf is empty
pam_alpamon.c:643-645 — pam_error(pamh, "Alpacon denied this sudo command.") when code[0] == '\0'
error_code_buf ends up empty in two cases: the server sent no error_code, or the sanitizer rejected the one it sent. The sanitizer (alpacon_approval.c:380-404) rejects the code whole rather than truncating it—on any character outside [A-Z0-9_], and on overflow past sizeof(error_code_buf) - 1 on a char[64], with the comment "a truncated code could be spoofed by prefixing a legitimate code with padding". So a server that ships a malformed or over-63-character code lands on this silent path, not on the unknown-code fallback that #344 added for exactly that drift.
Not a regression
Pre-existing: the old table scan missed this line too. TestSudoDenialHint/prefix without the code slot (cmd/exec/sudo_hint_test.go:293) pins the current behavior as deliberate—the fallback must not fire on a line with no code to name.
Raised on #344 as a non-blocking suggestion and left out of scope. It is the sibling of the case that PR's fallback exists to close, and after it, the only remaining path where a real denial reaches the terminal with nothing actionable.
Suggested direction
A generic branch on the codeless literal, after the table scan and the firstDenialCode fallback both miss:
if strings.Contains(output, "Alpacon denied this sudo command.") {
return denialHintLine("sudo was denied, and the server named no reason. Read the denial in the Alpacon console (web).\n")
}
With no category from the server there is genuinely less to say, so the guidance is thin by necessity—the point is that the user is pointed somewhere instead of nowhere.
Worth settling before writing it: this matches on a bare literal rather than on the plugin's exact line shape, so a command's own output containing that sentence would produce the hint. The existing detectors all anchor on the code slot precisely to keep command output from forging a denial signal; the codeless line has no slot to anchor on. sudoDenialHint is only reachable from a RemoteCommandError, which bounds the exposure but does not remove it.
Acceptance
Summary
A sudo denial that carries no error code reaches the terminal with nothing actionable beside it. The plugin prints
Alpacon denied this sudo command.—no code, no parenthesis—and neither the hint table nor the unknown-code fallback matches that line, so the user gets the bare denial and no next step.Where
sudoDenialHint(cmd/exec/run.go:235-247) has two paths, and both anchor onsudoDenialLinePrefix:denialCodePresent(output, h.code)→sudoDenialLinePrefix + code + ")."firstDenialCode(output)→strings.Cut(rest, sudoDenialLinePrefix)The prefix ends in
(, so a line without the code slot matches neither andsudoDenialHintreturns"".When the code is absent
Two branches on the agent side, both reached on a real denial:
alpacon_approval.c:449-451—g_plugin_printf(SUDO_CONV_ERROR_MSG, "Alpacon denied this sudo command.\n")whenerror_code_bufis emptypam_alpamon.c:643-645—pam_error(pamh, "Alpacon denied this sudo command.")whencode[0] == '\0'error_code_bufends up empty in two cases: the server sent noerror_code, or the sanitizer rejected the one it sent. The sanitizer (alpacon_approval.c:380-404) rejects the code whole rather than truncating it—on any character outside[A-Z0-9_], and on overflow pastsizeof(error_code_buf) - 1on achar[64], with the comment "a truncated code could be spoofed by prefixing a legitimate code with padding". So a server that ships a malformed or over-63-character code lands on this silent path, not on the unknown-code fallback that #344 added for exactly that drift.Not a regression
Pre-existing: the old table scan missed this line too.
TestSudoDenialHint/prefix without the code slot(cmd/exec/sudo_hint_test.go:293) pins the current behavior as deliberate—the fallback must not fire on a line with no code to name.Raised on #344 as a non-blocking suggestion and left out of scope. It is the sibling of the case that PR's fallback exists to close, and after it, the only remaining path where a real denial reaches the terminal with nothing actionable.
Suggested direction
A generic branch on the codeless literal, after the table scan and the
firstDenialCodefallback both miss:With no category from the server there is genuinely less to say, so the guidance is thin by necessity—the point is that the user is pointed somewhere instead of nowhere.
Worth settling before writing it: this matches on a bare literal rather than on the plugin's exact line shape, so a command's own output containing that sentence would produce the hint. The existing detectors all anchor on the code slot precisely to keep command output from forging a denial signal; the codeless line has no slot to anchor on.
sudoDenialHintis only reachable from aRemoteCommandError, which bounds the exposure but does not remove it.Acceptance
TestSudoDenialHint/prefix without the code slotupdated to the new expectation, andthe code slot without the prefixstill yields no hint