Background
The Mural skill's recent OAuth security hardening landed three coupled
abstractions that together form the redaction contract:
LOGGER (a module-level logging.getLogger(name)) — single observable
log surface.
_emit() / _emit_debug_traceback() — single output sink that runs
every formatted string through _redact() before reaching LOGGER or
stderr.
MuralAPIError — typed error class with a controlled __str__ (status,
code, message, request_id) that never carries raw header dicts or response
bodies into exception chains.
References:
.github/skills/mural/mural/scripts/mural.py L298 (LOGGER), L312
(MuralAPIError), L1540 (_emit), L1547 (_emit_debug_traceback)
.github/skills/mural/mural/SECURITY.md Bucket B2 / B4 Information
Disclosure rows
Without these three abstractions, redaction is unenforceable — a
contributor can always reach print(..., file=sys.stderr) directly, and a
freshly-raised exception will carry the raw upstream body into the traceback.
This is precisely the structural pattern that allowed G-INF-1
(client_secret not in the keyset) to remain undetected for as long as it
did, until the central sink and source-contract tests were introduced.
Audit findings
.github/skills/jira/jira/scripts/jira.py (472 LOC)
| Site |
Gap |
L37 class ScriptError(Exception) |
Generic; no status/code/request_id; no controlled __str__ |
L97 def request(...) |
No LOGGER, no debug logging, no central sink |
| L120, L124-128, L132-134 |
Raw URL + raw upstream body embedded in exception text |
L143-159 _extract_error_message |
Returns upstream body verbatim |
L204 _print_selected_fields, L218 _print_result |
Raw print() of upstream JSON |
L323 handle_comment reads sys.stdin.read() bare |
Raw payload could land in error traceback |
.github/skills/gitlab/gitlab/scripts/gitlab.py (439 LOC)
| Site |
Gap |
L34 gitlab_url, gitlab_token, api_url module globals |
Mutable global secret state |
L40 die() helper |
Raw print(..., file=sys.stderr) + SystemExit; no error class at all |
L122 def request(...) |
No LOGGER, no central sink |
| L154-159, L161, L168, L172 |
Raw upstream body / URL into stderr or stdout |
L387-394 cmd_job_log second urlopen site |
Bypasses request() entirely; raw print(error.read().decode(), file=sys.stderr) |
L218 print_fields() |
Raw print() of arbitrary upstream field values |
L427-437 main() |
Bare print(..., file=sys.stderr) for KeyboardInterrupt + BrokenPipeError |
Why this is an architectural prerequisite, not a refactor
Adding _REDACT_KEYS / _REDACT_PATTERNS / _redact() to either skill
without first introducing LOGGER + _emit() + a typed error class
results in a redaction net with holes wherever a print() or a raw exception
chain still exists. Specifically:
- The redaction call must live somewhere —
_emit() is that somewhere.
Otherwise, every site that wants to log must remember to call _redact(),
which is exactly the failure mode source-contract tests exist to prevent.
- Raised exceptions are formatted by Python at the top of the stack — if the
error message contains an unfiltered upstream body, the traceback printed
on --no-debug paths leaks it. A typed error class with a controlled
__str__ is the choke point.
- GitLab's
cmd_job_log second urlopen site (L387-394) is the cleanest
evidence: even after redaction is added to request(), this site would
continue to leak. It must be refactored to route through request() (or
through a shared _http_get_raw() helper that itself uses _emit() for
error paths).
This issue must merge before the Jira and GitLab redaction-port issues
land.
Required changes
Common to both skills
LOGGER = logging.getLogger("jira") / logging.getLogger("gitlab").
_emit(level, message, *args) — formats, redacts, routes to LOGGER.log
and (for user-facing errors) to sys.stderr.
_emit_debug_traceback(exc) — gated on JIRA_DEBUG / GITLAB_DEBUG;
calls traceback.format_exception and pipes the result through
_redact() (depends on the redaction-port issues for the actual
_redact function, but the call site lands here).
- Typed error class with controlled
__str__:
JiraAPIError(ScriptError) — fields: status, code, message,
request_id. Replaces raw ScriptError(f"HTTP ... details ...") at
L124-128 / L132-134.
GitLabAPIError(Exception) — same field set. Replaces every die()
call site (gitlab has no error class at all today).
- Refactor every existing
print(...) / print(..., file=sys.stderr) /
die(...) to route through _emit() or raise <error class>(...).
GitLab-specific
- Refactor
cmd_job_log L387-394: collapse the second urlopen site
into the central request() helper. Add a source-contract test asserting
exactly one urlopen call site in the file.
- Refactor
main() L427-437 (KeyboardInterrupt, BrokenPipeError) to
route through _emit().
- Move
gitlab_token off the module global into a small immutable config
object mirroring Jira's JiraClient dataclass.
Acceptance criteria
Dependencies
- Blocks the Jira redaction-port issue.
- Blocks the GitLab redaction-port issue.
- Blocks the per-skill CI gating issue.
References
.github/skills/mural/mural/scripts/mural.py L298, L312, L1540, L1547
.github/skills/mural/mural/tests/test_redaction.py source-contract section
docs/security/security-model.md OA-1..OA-17
Background
The Mural skill's recent OAuth security hardening landed three coupled
abstractions that together form the redaction contract:
LOGGER(a module-levellogging.getLogger(name)) — single observablelog surface.
_emit()/_emit_debug_traceback()— single output sink that runsevery formatted string through
_redact()before reachingLOGGERorstderr.MuralAPIError— typed error class with a controlled__str__(status,code, message, request_id) that never carries raw header dicts or response
bodies into exception chains.
References:
.github/skills/mural/mural/scripts/mural.pyL298 (LOGGER), L312(
MuralAPIError), L1540 (_emit), L1547 (_emit_debug_traceback).github/skills/mural/mural/SECURITY.mdBucket B2 / B4 InformationDisclosure rows
Without these three abstractions, redaction is unenforceable — a
contributor can always reach
print(..., file=sys.stderr)directly, and afreshly-raised exception will carry the raw upstream body into the traceback.
This is precisely the structural pattern that allowed G-INF-1
(
client_secretnot in the keyset) to remain undetected for as long as itdid, until the central sink and source-contract tests were introduced.
Audit findings
.github/skills/jira/jira/scripts/jira.py(472 LOC)class ScriptError(Exception)status/code/request_id; no controlled__str__def request(...)LOGGER, no debug logging, no central sink_extract_error_message_print_selected_fields, L218_print_resultprint()of upstream JSONhandle_commentreadssys.stdin.read()bare.github/skills/gitlab/gitlab/scripts/gitlab.py(439 LOC)gitlab_url,gitlab_token,api_urlmodule globalsdie()helperprint(..., file=sys.stderr)+SystemExit; no error class at alldef request(...)LOGGER, no central sinkcmd_job_logsecondurlopensiterequest()entirely; rawprint(error.read().decode(), file=sys.stderr)print_fields()print()of arbitrary upstream field valuesmain()print(..., file=sys.stderr)for KeyboardInterrupt + BrokenPipeErrorWhy this is an architectural prerequisite, not a refactor
Adding
_REDACT_KEYS/_REDACT_PATTERNS/_redact()to either skillwithout first introducing
LOGGER+_emit()+ a typed error classresults in a redaction net with holes wherever a
print()or a raw exceptionchain still exists. Specifically:
_emit()is that somewhere.Otherwise, every site that wants to log must remember to call
_redact(),which is exactly the failure mode source-contract tests exist to prevent.
error message contains an unfiltered upstream body, the traceback printed
on
--no-debugpaths leaks it. A typed error class with a controlled__str__is the choke point.cmd_job_logsecondurlopensite (L387-394) is the cleanestevidence: even after redaction is added to
request(), this site wouldcontinue to leak. It must be refactored to route through
request()(orthrough a shared
_http_get_raw()helper that itself uses_emit()forerror paths).
This issue must merge before the Jira and GitLab redaction-port issues
land.
Required changes
Common to both skills
LOGGER = logging.getLogger("jira")/logging.getLogger("gitlab")._emit(level, message, *args)— formats, redacts, routes toLOGGER.logand (for user-facing errors) to
sys.stderr._emit_debug_traceback(exc)— gated onJIRA_DEBUG/GITLAB_DEBUG;calls
traceback.format_exceptionand pipes the result through_redact()(depends on the redaction-port issues for the actual_redactfunction, but the call site lands here).__str__:JiraAPIError(ScriptError)— fields:status,code,message,request_id. Replaces rawScriptError(f"HTTP ... details ...")atL124-128 / L132-134.
GitLabAPIError(Exception)— same field set. Replaces everydie()call site (gitlab has no error class at all today).
print(...)/print(..., file=sys.stderr)/die(...)to route through_emit()orraise <error class>(...).GitLab-specific
cmd_job_logL387-394: collapse the secondurlopensiteinto the central
request()helper. Add a source-contract test assertingexactly one
urlopencall site in the file.main()L427-437 (KeyboardInterrupt,BrokenPipeError) toroute through
_emit().gitlab_tokenoff the module global into a small immutable configobject mirroring Jira's
JiraClientdataclass.Acceptance criteria
LOGGER,_emit,_emit_debug_traceback, and atyped API error class.
print(..., file=sys.stderr)outside_emit().LOGGER.exception(calls (mirror.github/skills/mural/mural/tests/test_redaction.pytest_logger_no_bare_exception_calls).urlopencall site.die()helper remaining; all error paths use theGitLabErrorhierarchy, and API transport failures useGitLabAPIError.gitlab_tokenis no longer a module-level mutable global.JiraClient.__repr__does not contain the rawauth_headervalue.
__str__excludes raw upstream body(negative test).
Dependencies
References
.github/skills/mural/mural/scripts/mural.pyL298, L312, L1540, L1547.github/skills/mural/mural/tests/test_redaction.pysource-contract sectiondocs/security/security-model.mdOA-1..OA-17