From 4d17020f494081706facebb74eca3afe875fae6a Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 14 Oct 2026 15:37:26 -0300 Subject: [PATCH 1/4] fix(continuous-learning-v2): redact cloud keys, DSNs and PEM blocks in observations The observation scrubber in observe.sh only redacted a value placed directly after a bare keyword (token=, secret:, ...). These reached observations.jsonl in plain text: - AWS_SECRET_ACCESS_KEY=... / aws_secret_access_key = ... / SECRET_KEY= - scheme://user:password@host DSN passwords - PEM private key blocks - ghp_/gho_/ghu_/ghs_/ghr_ and github_pat_ tokens, AKIA/ASIA key ids Both identical _SECRET_RE copies (parse-error fallback and main path) now accept an optional _access_key/_key suffix on the keyword, and a new _SECRET_VALUE_RE redacts the value-shaped secrets above. Every branch is anchored on a literal prefix with bounded classes, keeping the linear-time constraint from #2278. The existing redaction test gains canaries for each case on both the input and output paths, plus a negative case that ordinary code, credential-free URLs and SSH remotes are left untouched. registryDigest in skill-triggers@1.json is refreshed, which validate-context-profiles requires after any skills/ change. Signed-off-by: Wen --- .../continuous-learning-v2/hooks/observe.sh | 26 +++++++++++-- tests/hooks/observe-secret-redaction.test.js | 39 +++++++++++++++++-- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/skills/continuous-learning-v2/hooks/observe.sh b/skills/continuous-learning-v2/hooks/observe.sh index ff0d3235f..a502281fd 100755 --- a/skills/continuous-learning-v2/hooks/observe.sh +++ b/skills/continuous-learning-v2/hooks/observe.sh @@ -272,11 +272,20 @@ import json, sys, os, re # schemes (instead of a generic [A-Za-z]+\s+ that overlapped the value class) # prevent the catastrophic backtracking that pegged python at 100% CPU (#2278). _SECRET_RE = re.compile( - r"(?i)(api[_-]?key|token|secret|password|authorization|credentials?|auth)" + r"(?i)((?:api[_-]?key|token|secret|password|authorization|credentials?|auth)(?:_access_key|_key)?)" r"""(["'"'"'\s:=\\]{1,8})""" r"((?:bearer|basic|token|bot)\s+)?" r"([A-Za-z0-9_\-/.+=]{8,256})" ) +# Value-shaped secrets that carry no keyword: PEM private key blocks (to END or +# end of the truncated text), GitHub tokens, AWS key IDs, and DSN passwords +# (scheme://user:PASSWORD@host keeps scheme and user). Each branch is anchored +# on a literal prefix and uses bounded or non-overlapping classes (#2278). +_SECRET_VALUE_RE = re.compile( + r"-----BEGIN[A-Z ]{0,40}PRIVATE KEY-----[\s\S]*?(?:-----END[A-Z ]{0,40}PRIVATE KEY-----|\Z)" + r"|\b(?:gh[pousr]_[A-Za-z0-9]{20,255}|github_pat_[A-Za-z0-9_]{20,255}|(?:AKIA|ASIA)[A-Z0-9]{16})\b" + r"""|(?<=://)([^\s:/@"\\]{1,256}:)[^\s@/"\\]{1,256}(?=@)""" +) import signal def _clv2_bail(*_): @@ -290,6 +299,7 @@ except Exception: raw = sys.stdin.read()[:2000] raw = _SECRET_RE.sub(lambda m: m.group(1) + m.group(2) + (m.group(3) or "") + "[REDACTED]", raw) +raw = _SECRET_VALUE_RE.sub(lambda m: (m.group(1) or "") + "[REDACTED]", raw) print(json.dumps({"timestamp": os.environ["TIMESTAMP"], "event": "parse_error", "raw": raw})) ' >> "$OBSERVATIONS_FILE" exit 0 @@ -343,16 +353,26 @@ observation = { # schemes (instead of a generic [A-Za-z]+\s+ that overlapped the value class) # prevent the catastrophic backtracking that pegged python at 100% CPU (#2278). _SECRET_RE = re.compile( - r"(?i)(api[_-]?key|token|secret|password|authorization|credentials?|auth)" + r"(?i)((?:api[_-]?key|token|secret|password|authorization|credentials?|auth)(?:_access_key|_key)?)" r"""(["'"'"'\s:=\\]{1,8})""" r"((?:bearer|basic|token|bot)\s+)?" r"([A-Za-z0-9_\-/.+=]{8,256})" ) +# Value-shaped secrets that carry no keyword: PEM private key blocks (to END or +# end of the truncated text), GitHub tokens, AWS key IDs, and DSN passwords +# (scheme://user:PASSWORD@host keeps scheme and user). Each branch is anchored +# on a literal prefix and uses bounded or non-overlapping classes (#2278). +_SECRET_VALUE_RE = re.compile( + r"-----BEGIN[A-Z ]{0,40}PRIVATE KEY-----[\s\S]*?(?:-----END[A-Z ]{0,40}PRIVATE KEY-----|\Z)" + r"|\b(?:gh[pousr]_[A-Za-z0-9]{20,255}|github_pat_[A-Za-z0-9_]{20,255}|(?:AKIA|ASIA)[A-Z0-9]{16})\b" + r"""|(?<=://)([^\s:/@"\\]{1,256}:)[^\s@/"\\]{1,256}(?=@)""" +) def scrub(val): if val is None: return None - return _SECRET_RE.sub(lambda m: m.group(1) + m.group(2) + (m.group(3) or "") + "[REDACTED]", str(val)) + val = _SECRET_RE.sub(lambda m: m.group(1) + m.group(2) + (m.group(3) or "") + "[REDACTED]", str(val)) + return _SECRET_VALUE_RE.sub(lambda m: (m.group(1) or "") + "[REDACTED]", val) if parsed["input"]: observation["input"] = scrub(parsed["input"]) diff --git a/tests/hooks/observe-secret-redaction.test.js b/tests/hooks/observe-secret-redaction.test.js index 1ab6dcff0..881ad08ec 100644 --- a/tests/hooks/observe-secret-redaction.test.js +++ b/tests/hooks/observe-secret-redaction.test.js @@ -48,25 +48,56 @@ if (process.platform === 'win32') { failed += 1; } else { // Synthetic canary data proves that captured credential-shaped text is masked. - for (const [label, content] of [ + const canary = 'CANARY_TOKEN_abcdefghijk'; + const pemCanary = 'MIIEcanaryPEMbodyAAAA'; + for (const [label, content, secret = canary] of [ ['double-quoted source', 'const apiKey = "CANARY_TOKEN_abcdefghijk";'], ['single-quoted source', "const password = 'CANARY_TOKEN_abcdefghijk';"], ['JSON credential', '{"token":"CANARY_TOKEN_abcdefghijk"}'], - ['nested escaped source', 'const auth = \\"CANARY_TOKEN_abcdefghijk\\";'] + ['nested escaped source', 'const auth = \\"CANARY_TOKEN_abcdefghijk\\";'], + ['compound env var name', 'AWS_SECRET_ACCESS_KEY=CANARY/K7MDENG/bPxRfiCYEXAMPLEKEY', + 'CANARY/K7MDENG/bPxRfiCYEXAMPLEKEY'], + ['lowercase compound name', 'aws_secret_access_key = CANARY/K7MDENG/bPxRfiCYEXAMPLEKEY', + 'CANARY/K7MDENG/bPxRfiCYEXAMPLEKEY'], + ['DSN password', 'DATABASE_URL=postgres://app:CANARYdsnPassw0rd@db.internal:5432/app', + 'CANARYdsnPassw0rd'], + ['PEM private key block', `-----BEGIN RSA ${'PRIVATE'} KEY-----\n${pemCanary}\n-----END RSA PRIVATE KEY-----`, + pemCanary], + ['truncated PEM private key block', `-----BEGIN OPENSSH ${'PRIVATE'} KEY-----\n${pemCanary}`, pemCanary], + ['GitHub classic token', `GH_PAT=${'ghp_'}CANARY${'0'.repeat(30)}`, `CANARY${'0'.repeat(30)}`], + ['GitHub fine-grained token', `use ${'github_pat_'}CANARY_${'1'.repeat(40)}`, `CANARY_${'1'.repeat(40)}`], + ['AWS access key id', `key id ${'AKIA'}CANARY0EXAMPLE00`, 'CANARY0EXAMPLE00'] ]) { test(`redacts ${label} before persisting tool input`, () => { const records = capture('pre', { tool_name: 'Write', tool_input: { file_path: '/tmp/example.js', content } }); - assert.ok(!records.includes('CANARY_TOKEN_abcdefghijk'), 'seeded credential persisted'); + assert.ok(!records.includes(secret), 'seeded credential persisted'); assert.ok(records.includes('[REDACTED]')); }); test(`redacts ${label} before persisting structured tool output`, () => { const records = capture('post', { tool_name: 'Read', tool_input: { file_path: '/tmp/example.js' }, tool_response: { content } }); - assert.ok(!records.includes('CANARY_TOKEN_abcdefghijk'), 'seeded credential persisted'); + assert.ok(!records.includes(secret), 'seeded credential persisted'); assert.ok(records.includes('[REDACTED]')); }); } + + // Ordinary code, URLs without credentials and SSH remotes stay readable. + const benign = [ + 'const tokenizer = new Tokenizer(config);', + 'max_tokens_per_request = 4096', + 'token_count = countTokens(text)', + 'password_hash = hashlib.sha256(pw)', + 'fetch(http://localhost:8080/api/v1)', + 'git clone git@github.com:org/repo.git', + 'https://user@example.com/path' + ]; + test('keeps ordinary code and credential-free URLs unredacted', () => { + const records = capture('post', { tool_name: 'Read', + tool_input: { file_path: '/tmp/example.js' }, tool_response: { content: benign.join('\n') } }); + for (const line of benign) assert.ok(records.includes(line), `benign text altered: ${line}`); + assert.ok(!records.includes('[REDACTED]')); + }); } console.log(`Passed: ${passed}`); console.log(`Failed: ${failed}`); From 64f28b6096c8862e1a88189acdf529fd200ece26 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 14 Oct 2026 16:57:58 -0300 Subject: [PATCH 2/4] fix(continuous-learning-v2): redact tokens after JSON escapes and truncated DSNs Dict tool I/O is JSON-encoded before scrubbing, so a newline before a bare GitHub token or AWS key id became a literal \n and the leading \b never matched. Replace it with a lookbehind that also accepts a preceding JSON escape (\b \f \n \r \t or \uXXXX). Capture truncation (5000 chars, 2000 in the parse-error fallback) can cut a DSN right before its '@', which defeated the (?=@) password lookahead. Let the password run to end of string, except a bare trailing host:port. Applied to both matcher copies. Regression tests cover structured output with tokens after escaped whitespace and DSNs cut before '@' on the string, structured input/output and fallback paths. Refresh registryDigest. Signed-off-by: Wen --- .../continuous-learning-v2/hooks/observe.sh | 14 +++-- tests/hooks/observe-secret-redaction.test.js | 56 ++++++++++++++++++- 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/skills/continuous-learning-v2/hooks/observe.sh b/skills/continuous-learning-v2/hooks/observe.sh index a502281fd..2e38a9b47 100755 --- a/skills/continuous-learning-v2/hooks/observe.sh +++ b/skills/continuous-learning-v2/hooks/observe.sh @@ -281,10 +281,13 @@ _SECRET_RE = re.compile( # end of the truncated text), GitHub tokens, AWS key IDs, and DSN passwords # (scheme://user:PASSWORD@host keeps scheme and user). Each branch is anchored # on a literal prefix and uses bounded or non-overlapping classes (#2278). +# Tokens may follow a JSON escape (\n, \t, \uXXXX) since dict I/O is encoded +# before scrubbing; a DSN cut by truncation before its "@" ends at \Z, +# except a bare trailing host:port such as http://localhost:3000 (#3568). _SECRET_VALUE_RE = re.compile( r"-----BEGIN[A-Z ]{0,40}PRIVATE KEY-----[\s\S]*?(?:-----END[A-Z ]{0,40}PRIVATE KEY-----|\Z)" - r"|\b(?:gh[pousr]_[A-Za-z0-9]{20,255}|github_pat_[A-Za-z0-9_]{20,255}|(?:AKIA|ASIA)[A-Z0-9]{16})\b" - r"""|(?<=://)([^\s:/@"\\]{1,256}:)[^\s@/"\\]{1,256}(?=@)""" + r"|(?:(? { + const records = capture('post', { tool_name: 'Read', + tool_input: { file_path: '/tmp/example.js' }, tool_response: { content } }); + assert.ok(!records.includes(secret), 'seeded credential persisted'); + assert.ok(records.includes('[REDACTED]')); + }); + } + + // Capture truncation (5000 chars, 2000 in the parse-error fallback) can cut a + // DSN right before its '@'; the password must still be redacted (#3568). + const cutSecret = 'CANARYcutPassw0rd'; + const dsnCutAt = (offset) => `${'a'.repeat(offset)} postgres://app:${cutSecret}@db.internal/app`; + const dsnPad = (atIndex) => atIndex - ' postgres://app:'.length - cutSecret.length; + for (const [label, phase, input, expectedEvent] of [ + ['string tool output', 'post', { tool_name: 'Bash', tool_input: { command: 'env' }, + tool_response: dsnCutAt(dsnPad(5000)) }], + // json.dumps prefixes '{"content": "' (13 chars) before the value. + ['structured tool output', 'post', { tool_name: 'Read', tool_input: { file_path: '/tmp/x' }, + tool_response: { content: dsnCutAt(dsnPad(5000 - 13)) } }], + ['structured tool input', 'pre', { tool_name: 'Write', + tool_input: { content: dsnCutAt(dsnPad(5000 - 13)) } }], + // A top-level array is valid JSON but not a hook payload: parse-error + // fallback, which persists the first 2000 raw chars ('["' prefix). + ['parse-error fallback', 'post', JSON.stringify([dsnCutAt(dsnPad(2000 - 2))]), 'parse_error'] + ]) { + test(`redacts DSN password truncated before '@' in ${label}`, () => { + const records = capture(phase, input, expectedEvent); + assert.ok(!records.includes(cutSecret), 'truncated DSN password persisted'); + assert.ok(records.includes('postgres://app:[REDACTED]')); + }); + } + // Ordinary code, URLs without credentials and SSH remotes stay readable. const benign = [ 'const tokenizer = new Tokenizer(config);', @@ -98,6 +142,12 @@ if (process.platform === 'win32') { for (const line of benign) assert.ok(records.includes(line), `benign text altered: ${line}`); assert.ok(!records.includes('[REDACTED]')); }); + test('keeps a trailing host:port in string output unredacted', () => { + const records = capture('post', { tool_name: 'Bash', tool_input: { command: 'npm run dev' }, + tool_response: 'listening on http://localhost:3000' }); + assert.ok(records.includes('http://localhost:3000')); + assert.ok(!records.includes('[REDACTED]')); + }); } console.log(`Passed: ${passed}`); console.log(`Failed: ${failed}`); From 7d4473432818e78baf0063382b7ca2f5fd9695fa Mon Sep 17 00:00:00 2001 From: Wen Date: Sat, 17 Oct 2026 09:43:42 -0300 Subject: [PATCH 3/4] fix(continuous-learning-v2): redact numeric DSN passwords cut at the truncation boundary --- skills/continuous-learning-v2/hooks/observe.sh | 14 ++++++++------ tests/hooks/observe-secret-redaction.test.js | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/skills/continuous-learning-v2/hooks/observe.sh b/skills/continuous-learning-v2/hooks/observe.sh index 2e38a9b47..aecb29a07 100755 --- a/skills/continuous-learning-v2/hooks/observe.sh +++ b/skills/continuous-learning-v2/hooks/observe.sh @@ -283,11 +283,12 @@ _SECRET_RE = re.compile( # on a literal prefix and uses bounded or non-overlapping classes (#2278). # Tokens may follow a JSON escape (\n, \t, \uXXXX) since dict I/O is encoded # before scrubbing; a DSN cut by truncation before its "@" ends at \Z, -# except a bare trailing host:port such as http://localhost:3000 (#3568). +# except a bare trailing host:port after http(s):// such as http://localhost:3000; +# any other scheme (postgres://app:1234) is redacted even when the password is numeric (#3568). _SECRET_VALUE_RE = re.compile( r"-----BEGIN[A-Z ]{0,40}PRIVATE KEY-----[\s\S]*?(?:-----END[A-Z ]{0,40}PRIVATE KEY-----|\Z)" r"|(?:(?> "$OBSERVATIONS_FILE" exit 0 @@ -367,18 +368,19 @@ _SECRET_RE = re.compile( # on a literal prefix and uses bounded or non-overlapping classes (#2278). # Tokens may follow a JSON escape (\n, \t, \uXXXX) since dict I/O is encoded # before scrubbing; a DSN cut by truncation before its "@" ends at \Z, -# except a bare trailing host:port such as http://localhost:3000 (#3568). +# except a bare trailing host:port after http(s):// such as http://localhost:3000; +# any other scheme (postgres://app:1234) is redacted even when the password is numeric (#3568). _SECRET_VALUE_RE = re.compile( r"-----BEGIN[A-Z ]{0,40}PRIVATE KEY-----[\s\S]*?(?:-----END[A-Z ]{0,40}PRIVATE KEY-----|\Z)" r"|(?:(? 'a'.repeat(end - ' postgres://app:1234'.length) + ' postgres://app:1234'; + for (const [label, phase, input, expectedEvent] of [ + ['string tool output', 'post', { tool_name: 'Bash', tool_input: { command: 'env' }, + tool_response: numPad(5000) }], + ['parse-error fallback', 'post', JSON.stringify([numPad(2000 - 2)]), 'parse_error'] + ]) { + test(`redacts numeric DSN password cut at the truncation boundary in ${label}`, () => { + const records = capture(phase, input, expectedEvent); + assert.ok(!records.includes('app:1234'), 'numeric DSN password persisted'); + assert.ok(records.includes('postgres://app:[REDACTED]')); + }); + } + // Ordinary code, URLs without credentials and SSH remotes stay readable. const benign = [ 'const tokenizer = new Tokenizer(config);', From 47a5245f1b6ad67227eed7b4461ba0338037595e Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 27 Oct 2026 16:52:45 -0300 Subject: [PATCH 4/4] chore(context-packs): refresh skill-triggers registry digest --- manifests/context-packs/skill-triggers@1.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifests/context-packs/skill-triggers@1.json b/manifests/context-packs/skill-triggers@1.json index 34be199f4..cc58c1aea 100644 --- a/manifests/context-packs/skill-triggers@1.json +++ b/manifests/context-packs/skill-triggers@1.json @@ -10,7 +10,7 @@ "id": "hand-seeded", "source": "manual-curation-pending-regeneration" }, - "registryDigest": "846ac8826dd91e81cd838c2b55d7be536af427eb453efa127501f891e5778ff8", + "registryDigest": "96aa7d23cc26d7393a64670158c638c3157c9f2c7c0444903d1c80fb86559342", "schemaVersion": 1, "triggers": { "skill:agent-runtime-gateway-smoke-test": [