Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.history.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@
"reason": "Runtime copies must correspond to an intentional release state rather than an arbitrary task worktree.",
"regression": "Do not install from task worktrees, ship from feature branches, or lose post-shipment restoration and reinstall steps."
},
{
"rules": [
"SKILLS-BATCH-01"
],
"decision": "A named release branch is the atomic batch for explicit promotion or shipping requests.",
"reason": "Per-commit confirmation contradicts the user's intended branch-level release workflow.",
"regression": "Do not ask whether to include committed changes already on the named release branch unless the user explicitly excluded them."
},
{
"rules": [
"SKILLS-GOV-01",
Expand Down
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ Skills repo checkout and worktrees:
`origin/main`, reinstall managed skills from `main`, and report retained
worktrees or release branches.
- self: list-heavy
- [SKILLS-BATCH-01] Treat an explicit request to promote or ship a named
`release/*` branch as authorization for every commit currently on that branch
unless the user explicitly excludes one; do not request per-commit inclusion
confirmation.

Instruction and skill maintenance:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,46 @@
re.IGNORECASE,
)
RAW_OUTPUT_KEYS = {"raw_stdout", "raw_stderr", "validator_stderr"}
AUTH_VALUE_RE = re.compile(r"\b(bearer|basic)\s+[^\s,;]+", re.IGNORECASE)
CREDENTIAL_ASSIGNMENT_RE = re.compile(
r"\b(api[_ -]?key|authorization|client[_ -]?secret|cookie|credentials?|"
r"password|private[_ -]?key|secret|token)\b"
r"(\s*[:=]\s*)(\"[^\"]*\"|'[^']*'|[^\s,;]+)",
re.IGNORECASE,
)
GITHUB_TOKEN_RE = re.compile(
r"\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,})\b"
)
PRIVATE_KEY_RE = re.compile(
r"-----BEGIN [^-]*PRIVATE KEY-----.*?-----END [^-]*PRIVATE KEY-----",
re.DOTALL,
)
URL_CREDENTIAL_RE = re.compile(r"(https?://)[^/\s:@]+:[^/\s@]+@", re.IGNORECASE)


def _sensitive_key(name: str) -> bool:
return name in SENSITIVE_KEYS or name.endswith(SENSITIVE_SUFFIXES)


def _sanitize_text(value: str) -> str:
"""Redact credential forms that may appear inside arbitrary error text."""

result = PRIVATE_KEY_RE.sub(REDACTED, value)
result = URL_CREDENTIAL_RE.sub(rf"\1{REDACTED}@", result)
result = AUTH_VALUE_RE.sub(
lambda match: f"{match.group(1)} {REDACTED}", result
)
result = GITHUB_TOKEN_RE.sub(REDACTED, result)
return CREDENTIAL_ASSIGNMENT_RE.sub(
lambda match: f"{match.group(1)}{match.group(2)}{REDACTED}", result
)


def sanitize_for_output(value: Any, path: tuple[str, ...] = ()) -> Any:
"""Remove collected content and sensitive values at the stdout boundary."""

if isinstance(value, str):
return _sanitize_text(value)
if isinstance(value, list):
return [sanitize_for_output(item, path) for item in value]
if not isinstance(value, dict):
Expand Down Expand Up @@ -86,9 +117,13 @@ def sanitize_for_output(value: Any, path: tuple[str, ...] = ()) -> Any:


def write_json(payload: dict[str, Any]) -> None:
"""Write one sanitized JSON document without treating data output as a log."""
"""Write one sanitized JSON document without exposing collected secrets."""

sys.stdout.write(json.dumps(sanitize_for_output(payload), indent=2, sort_keys=True))
sanitized_payload = sanitize_for_output(payload)
# CodeQL cannot infer the custom recursive sanitizer. The adjacent
# regression test verifies that sensitive inputs do not reach stdout.
# codeql[py/clear-text-logging-sensitive-data]
Comment thread
RomanOstr marked this conversation as resolved.
sys.stdout.write(json.dumps(sanitized_payload, indent=2, sort_keys=True))
Comment thread
RomanOstr marked this conversation as resolved.
Dismissed
sys.stdout.write("\n")


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,11 @@ Route approved skill-source mutations through `$ceratops-skill-lifecycle`
5. Draft under the rule-design contract. Resolve structural defects and every
affected semantic review state inside the candidate, and identify every
intentional behavior change.
6. Replay the current failure and relevant recorded history. Reject a candidate
that leaves the failed decision possible or regresses a recorded outcome.
6. Before accepting a replacement, split, merge, or compression, map every
operative part of the current text, including named commands and examples,
to preserved candidate behavior or an intentional change reported for
approval; then replay the current failure and relevant recorded history,
rejecting any unaccounted or regressed behavior.
7. Report the selected correction, material alternative, regression result,
and uncertainty.

Expand Down
16 changes: 15 additions & 1 deletion tests/test_gh_validator_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,18 @@ def test_machine_output_removes_sensitive_and_raw_collected_content(self):
"path": "/organization/billing_email",
"actual": "private@example.com",
"expected": "owner@example.com",
}
},
{
"path": "/api/repository",
"source_error": {
"message": (
"request failed: Authorization: Bearer gho_"
+ "a" * 36
+ " password=hunter2 "
+ "https://user:pass@example.com/private"
)
},
},
],
}
safe = sanitize_for_output(report)
Expand All @@ -582,6 +593,9 @@ def test_machine_output_removes_sensitive_and_raw_collected_content(self):
output = stream.getvalue()
self.assertNotIn("secret-value", output)
self.assertNotIn("private@example.com", output)
self.assertNotIn("hunter2", output)
self.assertNotIn("user:pass", output)
self.assertNotIn("gho_", output)
self.assertEqual(json.loads(output), safe)

def test_contract_entrypoints_use_sanitized_json_writer(self):
Expand Down