@getmarrow/mcp 3.9.108 → 3.9.110

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +26 -1
  2. package/dist/cli.js +44 -3
  3. package/dist/cli.js.map +1 -1
  4. package/dist/control-notice.d.ts +13 -0
  5. package/dist/control-notice.d.ts.map +1 -0
  6. package/dist/control-notice.js +76 -0
  7. package/dist/control-notice.js.map +1 -0
  8. package/dist/control-routes.d.ts +10 -0
  9. package/dist/control-routes.d.ts.map +1 -0
  10. package/dist/control-routes.js +81 -0
  11. package/dist/control-routes.js.map +1 -0
  12. package/dist/env.d.ts +15 -0
  13. package/dist/env.d.ts.map +1 -1
  14. package/dist/env.js +29 -2
  15. package/dist/env.js.map +1 -1
  16. package/dist/hook-context.d.ts.map +1 -1
  17. package/dist/hook-context.js +34 -11
  18. package/dist/hook-context.js.map +1 -1
  19. package/dist/hook-contract.d.ts +2 -2
  20. package/dist/hook-contract.js +1 -1
  21. package/dist/hook-pre-action.d.ts +10 -2
  22. package/dist/hook-pre-action.d.ts.map +1 -1
  23. package/dist/hook-pre-action.js +105 -33
  24. package/dist/hook-pre-action.js.map +1 -1
  25. package/dist/hook-session.d.ts.map +1 -1
  26. package/dist/hook-session.js +12 -7
  27. package/dist/hook-session.js.map +1 -1
  28. package/dist/hook-tool-policy.d.ts +1 -1
  29. package/dist/hook-tool-policy.d.ts.map +1 -1
  30. package/dist/hook.d.ts.map +1 -1
  31. package/dist/hook.js +19 -14
  32. package/dist/hook.js.map +1 -1
  33. package/dist/host-approval.d.ts +9 -0
  34. package/dist/host-approval.d.ts.map +1 -1
  35. package/dist/host-approval.js +22 -0
  36. package/dist/host-approval.js.map +1 -1
  37. package/dist/identity-explain.d.ts +35 -0
  38. package/dist/identity-explain.d.ts.map +1 -0
  39. package/dist/identity-explain.js +60 -0
  40. package/dist/identity-explain.js.map +1 -0
  41. package/dist/ipv4-first.d.ts +7 -0
  42. package/dist/ipv4-first.d.ts.map +1 -0
  43. package/dist/ipv4-first.js +60 -0
  44. package/dist/ipv4-first.js.map +1 -0
  45. package/dist/key-class.d.ts +16 -0
  46. package/dist/key-class.d.ts.map +1 -0
  47. package/dist/key-class.js +37 -0
  48. package/dist/key-class.js.map +1 -0
  49. package/dist/keyless-notice.d.ts +10 -0
  50. package/dist/keyless-notice.d.ts.map +1 -0
  51. package/dist/keyless-notice.js +47 -0
  52. package/dist/keyless-notice.js.map +1 -0
  53. package/dist/normalized-action.d.ts +4 -0
  54. package/dist/normalized-action.d.ts.map +1 -1
  55. package/dist/normalized-action.js +60 -27
  56. package/dist/normalized-action.js.map +1 -1
  57. package/dist/own-server-identity.d.ts +20 -0
  58. package/dist/own-server-identity.d.ts.map +1 -1
  59. package/dist/own-server-identity.js +130 -5
  60. package/dist/own-server-identity.js.map +1 -1
  61. package/dist/owner-bypass.d.ts +9 -0
  62. package/dist/owner-bypass.d.ts.map +1 -0
  63. package/dist/owner-bypass.js +79 -0
  64. package/dist/owner-bypass.js.map +1 -0
  65. package/dist/redact.d.ts +5 -0
  66. package/dist/redact.d.ts.map +1 -1
  67. package/dist/redact.js +61 -9
  68. package/dist/redact.js.map +1 -1
  69. package/dist/seatbelt/hook.d.ts +3 -1
  70. package/dist/seatbelt/hook.d.ts.map +1 -1
  71. package/dist/seatbelt/hook.js +7 -3
  72. package/dist/seatbelt/hook.js.map +1 -1
  73. package/dist/seatbelt/mapper.d.ts.map +1 -1
  74. package/dist/seatbelt/mapper.js +849 -12
  75. package/dist/seatbelt/mapper.js.map +1 -1
  76. package/dist/seatbelt/patch-text.d.ts +49 -0
  77. package/dist/seatbelt/patch-text.d.ts.map +1 -0
  78. package/dist/seatbelt/patch-text.js +376 -0
  79. package/dist/seatbelt/patch-text.js.map +1 -0
  80. package/dist/seatbelt/tables.d.ts +66 -0
  81. package/dist/seatbelt/tables.d.ts.map +1 -1
  82. package/dist/seatbelt/tables.js +467 -3
  83. package/dist/seatbelt/tables.js.map +1 -1
  84. package/dist/seatbelt/win-protected.d.ts +52 -0
  85. package/dist/seatbelt/win-protected.d.ts.map +1 -0
  86. package/dist/seatbelt/win-protected.js +1279 -0
  87. package/dist/seatbelt/win-protected.js.map +1 -0
  88. package/dist/self-protect.d.ts +18 -0
  89. package/dist/self-protect.d.ts.map +1 -0
  90. package/dist/self-protect.js +185 -0
  91. package/dist/self-protect.js.map +1 -0
  92. package/dist/session-id.d.ts +2 -0
  93. package/dist/session-id.d.ts.map +1 -0
  94. package/dist/session-id.js +32 -0
  95. package/dist/session-id.js.map +1 -0
  96. package/dist/session-loop-guard.d.ts +40 -0
  97. package/dist/session-loop-guard.d.ts.map +1 -1
  98. package/dist/session-loop-guard.js +236 -9
  99. package/dist/session-loop-guard.js.map +1 -1
  100. package/dist/session-marker.d.ts +7 -0
  101. package/dist/session-marker.d.ts.map +1 -0
  102. package/dist/session-marker.js +42 -0
  103. package/dist/session-marker.js.map +1 -0
  104. package/dist/win-private-state.d.ts +117 -0
  105. package/dist/win-private-state.d.ts.map +1 -0
  106. package/dist/win-private-state.js +544 -0
  107. package/dist/win-private-state.js.map +1 -0
  108. package/package.json +1 -1
  109. package/server.json +2 -2
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.preferIpv4FirstWithHappyEyeballs = preferIpv4FirstWithHappyEyeballs;
37
+ const dns = __importStar(require("node:dns"));
38
+ const net = __importStar(require("node:net"));
39
+ let applied = false;
40
+ /**
41
+ * N1: the pre-action hook dials the Marrow API with IPv4 tried first and happy eyeballs on, so an IPv6 address that does not
42
+ * route (the resolver lists it first) cannot hold the hook past the host's budget. Transport only: the host, port, path and
43
+ * headers of every request are unchanged. Set once, before the first request; an older Node without either setting skips it.
44
+ */
45
+ function preferIpv4FirstWithHappyEyeballs() {
46
+ if (applied)
47
+ return;
48
+ applied = true;
49
+ try {
50
+ if (typeof dns.setDefaultResultOrder === 'function')
51
+ dns.setDefaultResultOrder('ipv4first');
52
+ }
53
+ catch { /* older Node */ }
54
+ try {
55
+ if (typeof net.setDefaultAutoSelectFamily === 'function')
56
+ net.setDefaultAutoSelectFamily(true);
57
+ }
58
+ catch { /* older Node */ }
59
+ }
60
+ //# sourceMappingURL=ipv4-first.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ipv4-first.js","sourceRoot":"","sources":["../src/ipv4-first.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAUA,4EAKC;AAfD,8CAAgC;AAChC,8CAAgC;AAEhC,IAAI,OAAO,GAAG,KAAK,CAAC;AAEpB;;;;GAIG;AACH,SAAgB,gCAAgC;IAC9C,IAAI,OAAO;QAAE,OAAO;IACpB,OAAO,GAAG,IAAI,CAAC;IACf,IAAI,CAAC;QAAC,IAAI,OAAO,GAAG,CAAC,qBAAqB,KAAK,UAAU;YAAE,GAAG,CAAC,qBAAqB,CAAC,WAAW,CAAC,CAAC;IAAC,CAAC;IAAC,MAAM,CAAC,CAAC,gBAAgB,CAAC,CAAC;IAC/H,IAAI,CAAC;QAAC,IAAI,OAAO,GAAG,CAAC,0BAA0B,KAAK,UAAU;YAAE,GAAG,CAAC,0BAA0B,CAAC,IAAI,CAAC,CAAC;IAAC,CAAC;IAAC,MAAM,CAAC,CAAC,gBAAgB,CAAC,CAAC;AACpI,CAAC"}
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The class (live or test) of a Stripe-style key (`sk_`, `rk_`, `pk_`) inside a span that a redactor is about to
3
+ * replace, so the marker that stands for the span keeps the class: an approval, a hold or a correlation made for an
4
+ * action with a test key can never cover the same action with a live key.
5
+ *
6
+ * Fail closed: the answer is `test` only when every key-shaped token of the span is the exact lowercase ASCII
7
+ * `sk_test_` / `rk_test_` / `pk_test_` shape. A live prefix in any spelling the text can be folded to (case, a
8
+ * `-` for the `_`, NFKC/NFKD-foldable forms, zero-width / format / combining characters, percent or Basic-auth base64
9
+ * encoding), an unknown or malformed prefix, or a test prefix that is not exactly canonical, is `live`. A span with
10
+ * no such token has no class (`null`): its marker stays what it was. The class is read, never copied: callers pick a
11
+ * marker from a closed set of constants.
12
+ */
13
+ export type KeyClass = 'live' | 'test';
14
+ /** The class of the Stripe-style key(s) in `span`, or null when it holds none. Linear in the span. */
15
+ export declare function keyClassOf(span: string): KeyClass | null;
16
+ //# sourceMappingURL=key-class.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"key-class.d.ts","sourceRoot":"","sources":["../src/key-class.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,MAAM,CAAC;AAsBvC,sGAAsG;AACtG,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CASxD"}
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.keyClassOf = keyClassOf;
4
+ /** Format, combining and control characters (zero-width space and joiners, BOM, soft hyphen, bidi marks, accents, NUL). */
5
+ const HIDDEN = /[\p{Cf}\p{Mn}\p{Me}\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu;
6
+ const LIVE_TOKEN = /(?<![a-z0-9])(?:sk|rk|pk)[_-]live/;
7
+ /** `sk_` and more that is neither `test` nor `live`: an unknown or malformed class (`sk_prod_…`, `sk_…`). */
8
+ const UNKNOWN_TOKEN = /(?<![a-z0-9])(?:sk|rk|pk)_(?!test)[a-z0-9_-]{12,}/;
9
+ const TEST_TOKEN = /(?<![a-z0-9])(?:sk|rk|pk)[_-]test/g;
10
+ const CANONICAL_TEST = /(?<![A-Za-z0-9])(?:sk|rk|pk)_test_[A-Za-z0-9_-]{12,}/g;
11
+ const PLAIN_ASCII = /^[\t\n\r\x20-\x7e]*$/;
12
+ const BASE64_SPAN = /^\s*(?:Basic\s+)?([A-Za-z0-9+/]{16,4096}={0,2})\s*$/i;
13
+ const PERCENT = /%([0-9A-Fa-f]{2})/g;
14
+ /** The span with the readings a backend or a client may give it added after it (percent escapes, Basic-auth base64). */
15
+ function withReadings(span) {
16
+ let probe = span;
17
+ if (span.includes('%'))
18
+ probe += `\n${span.replace(PERCENT, (_m, hex) => String.fromCharCode(parseInt(hex, 16)))}`;
19
+ const basic = BASE64_SPAN.exec(span);
20
+ if (basic)
21
+ probe += `\n${Buffer.from(basic[1], 'base64').toString('latin1')}`;
22
+ return probe;
23
+ }
24
+ /** The class of the Stripe-style key(s) in `span`, or null when it holds none. Linear in the span. */
25
+ function keyClassOf(span) {
26
+ const probe = withReadings(span);
27
+ // NFKD, not NFKC: an accent composed onto a letter (`i` + U+0301) must come apart so the mark can be dropped.
28
+ const folded = probe.normalize('NFKD').toLowerCase().replace(HIDDEN, '');
29
+ if (LIVE_TOKEN.test(folded) || UNKNOWN_TOKEN.test(folded))
30
+ return 'live';
31
+ const tests = folded.match(TEST_TOKEN);
32
+ if (!tests)
33
+ return null;
34
+ const canonical = probe.match(CANONICAL_TEST);
35
+ return PLAIN_ASCII.test(probe) && canonical !== null && canonical.length === tests.length ? 'test' : 'live';
36
+ }
37
+ //# sourceMappingURL=key-class.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"key-class.js","sourceRoot":"","sources":["../src/key-class.ts"],"names":[],"mappings":";;AAmCA,gCASC;AA9BD,2HAA2H;AAC3H,MAAM,MAAM,GAAG,oEAAoE,CAAC;AACpF,MAAM,UAAU,GAAG,mCAAmC,CAAC;AACvD,6GAA6G;AAC7G,MAAM,aAAa,GAAG,mDAAmD,CAAC;AAC1E,MAAM,UAAU,GAAG,oCAAoC,CAAC;AACxD,MAAM,cAAc,GAAG,uDAAuD,CAAC;AAC/E,MAAM,WAAW,GAAG,sBAAsB,CAAC;AAC3C,MAAM,WAAW,GAAG,sDAAsD,CAAC;AAC3E,MAAM,OAAO,GAAG,oBAAoB,CAAC;AAErC,wHAAwH;AACxH,SAAS,YAAY,CAAC,IAAY;IAChC,IAAI,KAAK,GAAG,IAAI,CAAC;IACjB,IAAI,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC;QAAE,KAAK,IAAI,KAAK,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC,EAAE,EAAE,GAAW,EAAE,EAAE,CAAC,MAAM,CAAC,YAAY,CAAC,QAAQ,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;IAC3H,MAAM,KAAK,GAAG,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACrC,IAAI,KAAK;QAAE,KAAK,IAAI,KAAK,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;IAC9E,OAAO,KAAK,CAAC;AACf,CAAC;AAED,sGAAsG;AACtG,SAAgB,UAAU,CAAC,IAAY;IACrC,MAAM,KAAK,GAAG,YAAY,CAAC,IAAI,CAAC,CAAC;IACjC,8GAA8G;IAC9G,MAAM,MAAM,GAAG,KAAK,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IACzE,IAAI,UAAU,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,aAAa,CAAC,IAAI,CAAC,MAAM,CAAC;QAAE,OAAO,MAAM,CAAC;IACzE,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;IACvC,IAAI,CAAC,KAAK;QAAE,OAAO,IAAI,CAAC;IACxB,MAAM,SAAS,GAAG,KAAK,CAAC,KAAK,CAAC,cAAc,CAAC,CAAC;IAC9C,OAAO,WAAW,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,SAAS,KAAK,IAAI,IAAI,SAAS,CAAC,MAAM,KAAK,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC;AAC9G,CAAC"}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * MCP-5: a hook that runs with local control on and no API key records nothing, so nothing tells the person that holds and approvals are
3
+ * not being recorded. This puts one user-visible notice per host session through the host's `systemMessage` field: the first keyless hook
4
+ * call of a session (a local marker keyed by a hash of the session id, mode 600, says it was sent) adds it to the hook's own JSON output,
5
+ * or prints it alone when the hook prints nothing. Names only: no variable value is ever read into the text. Claude Code only, the one
6
+ * host whose hook output carries `systemMessage` for every event; other hosts keep their exact output.
7
+ */
8
+ export declare const KEYLESS_NOTICE_TEXT = "Marrow hooks in this session have no API key, so holds and approvals are NOT recorded. Put MARROW_API_KEY in ~/.marrow/env (chmod 600) or start the host with it, then restart the session.";
9
+ export declare function armKeylessNotice(harness: string, sessionId: unknown): boolean;
10
+ //# sourceMappingURL=keyless-notice.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"keyless-notice.d.ts","sourceRoot":"","sources":["../src/keyless-notice.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,gMAAgM,CAAC;AAIjO,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,GAAG,OAAO,CAyB7E"}
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.KEYLESS_NOTICE_TEXT = void 0;
4
+ exports.armKeylessNotice = armKeylessNotice;
5
+ const session_marker_1 = require("./session-marker");
6
+ /**
7
+ * MCP-5: a hook that runs with local control on and no API key records nothing, so nothing tells the person that holds and approvals are
8
+ * not being recorded. This puts one user-visible notice per host session through the host's `systemMessage` field: the first keyless hook
9
+ * call of a session (a local marker keyed by a hash of the session id, mode 600, says it was sent) adds it to the hook's own JSON output,
10
+ * or prints it alone when the hook prints nothing. Names only: no variable value is ever read into the text. Claude Code only, the one
11
+ * host whose hook output carries `systemMessage` for every event; other hosts keep their exact output.
12
+ */
13
+ exports.KEYLESS_NOTICE_TEXT = 'Marrow hooks in this session have no API key, so holds and approvals are NOT recorded. Put MARROW_API_KEY in ~/.marrow/env (chmod 600) or start the host with it, then restart the session.';
14
+ let armed = false;
15
+ function armKeylessNotice(harness, sessionId) {
16
+ if (armed || harness !== 'claude-code' || typeof sessionId !== 'string' || !sessionId)
17
+ return false;
18
+ if (!(0, session_marker_1.claimSessionMarker)('keyless-notice', sessionId))
19
+ return false;
20
+ armed = true;
21
+ const write = process.stdout.write.bind(process.stdout);
22
+ let carried = false;
23
+ process.stdout.write = ((chunk, ...rest) => {
24
+ if (!carried && typeof chunk === 'string' && chunk.trim().startsWith('{')) {
25
+ try {
26
+ const parsed = JSON.parse(chunk);
27
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && !('systemMessage' in parsed)) {
28
+ carried = true;
29
+ return write(JSON.stringify({ ...parsed, systemMessage: exports.KEYLESS_NOTICE_TEXT }), ...rest);
30
+ }
31
+ }
32
+ catch { /* not JSON: written unchanged */ }
33
+ }
34
+ return write(chunk, ...rest);
35
+ });
36
+ process.on('exit', () => {
37
+ if (!carried) {
38
+ carried = true;
39
+ try {
40
+ write(JSON.stringify({ systemMessage: exports.KEYLESS_NOTICE_TEXT }));
41
+ }
42
+ catch { /* the notice is best effort */ }
43
+ }
44
+ });
45
+ return true;
46
+ }
47
+ //# sourceMappingURL=keyless-notice.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"keyless-notice.js","sourceRoot":"","sources":["../src/keyless-notice.ts"],"names":[],"mappings":";;;AAaA,4CAyBC;AAtCD,qDAAsD;AAEtD;;;;;;GAMG;AACU,QAAA,mBAAmB,GAAG,6LAA6L,CAAC;AAEjO,IAAI,KAAK,GAAG,KAAK,CAAC;AAElB,SAAgB,gBAAgB,CAAC,OAAe,EAAE,SAAkB;IAClE,IAAI,KAAK,IAAI,OAAO,KAAK,aAAa,IAAI,OAAO,SAAS,KAAK,QAAQ,IAAI,CAAC,SAAS;QAAE,OAAO,KAAK,CAAC;IACpG,IAAI,CAAC,IAAA,mCAAkB,EAAC,gBAAgB,EAAE,SAAS,CAAC;QAAE,OAAO,KAAK,CAAC;IACnE,KAAK,GAAG,IAAI,CAAC;IACb,MAAM,KAAK,GAAG,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,CAAoD,CAAC;IAC3G,IAAI,OAAO,GAAG,KAAK,CAAC;IACpB,OAAO,CAAC,MAAM,CAAC,KAAK,GAAG,CAAC,CAAC,KAAc,EAAE,GAAG,IAAe,EAAW,EAAE;QACtE,IAAI,CAAC,OAAO,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,IAAI,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;YAC1E,IAAI,CAAC;gBACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,CAAY,CAAC;gBAC5C,IAAI,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,eAAe,IAAI,MAAM,CAAC,EAAE,CAAC;oBACnG,OAAO,GAAG,IAAI,CAAC;oBACf,OAAO,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,GAAI,MAAkC,EAAE,aAAa,EAAE,2BAAmB,EAAE,CAAC,EAAE,GAAG,IAAI,CAAC,CAAC;gBACxH,CAAC;YACH,CAAC;YAAC,MAAM,CAAC,CAAC,iCAAiC,CAAC,CAAC;QAC/C,CAAC;QACD,OAAO,KAAK,CAAC,KAAK,EAAE,GAAG,IAAI,CAAC,CAAC;IAC/B,CAAC,CAAgC,CAAC;IAClC,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,EAAE;QACtB,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,OAAO,GAAG,IAAI,CAAC;YACf,IAAI,CAAC;gBAAC,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,aAAa,EAAE,2BAAmB,EAAE,CAAC,CAAC,CAAC;YAAC,CAAC;YAAC,MAAM,CAAC,CAAC,+BAA+B,CAAC,CAAC;QAClH,CAAC;IACH,CAAC,CAAC,CAAC;IACH,OAAO,IAAI,CAAC;AACd,CAAC"}
@@ -24,6 +24,10 @@
24
24
  * `truncated: true`: Marrow cannot see a replacement inside the hash, and
25
25
  * an approval is never reused for an action that carried a secret (it asks
26
26
  * each time). The verdict, the hold and the prompt do not depend on it.
27
+ * A Stripe-style key (`sk_`, `rk_`, `pk_`) keeps its class in the marker it is replaced by: `[secret-live]` or
28
+ * `[secret-test]` (key-class.ts decides; an unknown, malformed or look-alike prefix is live), so an approval or hold
29
+ * made for an action with a test key never covers the same action with a live key. Any other credential stays
30
+ * `[secret]`. The class markers are three constants; the paths sent for an edit stay `[secret]`.
27
31
  * Credentials are:
28
32
  * values of environment assignments, flags, `NAME=value` arguments and
29
33
  * inline `name: value` / `"name": "value"` literals whose name is a
@@ -1 +1 @@
1
- {"version":3,"file":"normalized-action.d.ts","sourceRoot":"","sources":["../src/normalized-action.ts"],"names":[],"mappings":"AAcA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6EG;AACH,MAAM,MAAM,oBAAoB,GAAG;IACjC,SAAS,EAAE,OAAO,GAAG,MAAM,GAAG,KAAK,GAAG,OAAO,CAAC;IAC9C,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,UAAU,EAAE;QAAE,cAAc,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG;QAAE,YAAY,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG;QAAE,UAAU,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;IAC7J,yGAAyG;IACzG,SAAS,CAAC,EAAE,IAAI,CAAC;CAClB,CAAC;AAMF,yHAAyH;AACzH,eAAO,MAAM,cAAc,QAAS,CAAC;AAWrC,KAAK,SAAS,GAAG;IAAE,SAAS,CAAC,EAAE,OAAO,CAAC;IAAC,UAAU,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AA2C/D,eAAO,MAAM,gBAAgB,QAAoZ,CAAC;AAelb,4GAA4G;AAC5G,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAUnD;AAgDD,KAAK,SAAS,GAAG,YAAY,GAAG,WAAW,GAAG,WAAW,GAAG,OAAO,CAAC;AAEpE;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAW1D;AAy/BD;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,EAAE,KAAK,UAAQ,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,CAWjJ;AAwID;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAyChE;AAuCD,4HAA4H;AAC5H,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,SAAS,GAAG,MAAM,GAAG,IAAI,CAEtE;AA+MD;;;GAGG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAK7D;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,SAAS,EAAE,OAAO,GAAE;IAAE,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAO,GAAG,oBAAoB,CA0CxI"}
1
+ {"version":3,"file":"normalized-action.d.ts","sourceRoot":"","sources":["../src/normalized-action.ts"],"names":[],"mappings":"AAeA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiFG;AACH,MAAM,MAAM,oBAAoB,GAAG;IACjC,SAAS,EAAE,OAAO,GAAG,MAAM,GAAG,KAAK,GAAG,OAAO,CAAC;IAC9C,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,UAAU,EAAE;QAAE,cAAc,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG;QAAE,YAAY,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG;QAAE,UAAU,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;IAC7J,yGAAyG;IACzG,SAAS,CAAC,EAAE,IAAI,CAAC;CAClB,CAAC;AAWF,yHAAyH;AACzH,eAAO,MAAM,cAAc,QAAS,CAAC;AAWrC,KAAK,SAAS,GAAG;IAAE,SAAS,CAAC,EAAE,OAAO,CAAC;IAAC,UAAU,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAqE/D,eAAO,MAAM,gBAAgB,QAAoZ,CAAC;AAelb,4GAA4G;AAC5G,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAUnD;AAgDD,KAAK,SAAS,GAAG,YAAY,GAAG,WAAW,GAAG,WAAW,GAAG,OAAO,CAAC;AAEpE;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAW1D;AAy/BD;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,EAAE,KAAK,UAAQ,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,CAWjJ;AAwID;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAyChE;AAuCD,4HAA4H;AAC5H,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,SAAS,GAAG,MAAM,GAAG,IAAI,CAEtE;AA+MD;;;GAGG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAK7D;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,SAAS,EAAE,OAAO,GAAE;IAAE,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAO,GAAG,oBAAoB,CA0CxI"}
@@ -11,7 +11,13 @@ exports.normalizedHookAction = normalizedHookAction;
11
11
  const node_crypto_1 = require("node:crypto");
12
12
  const node_os_1 = require("node:os");
13
13
  const hook_tool_policy_1 = require("./hook-tool-policy");
14
+ const key_class_1 = require("./key-class");
14
15
  const SECRET = '[secret]';
16
+ /** The marker of a replaced Stripe-style key keeps its class (a closed set: never built from input). */
17
+ const SECRET_LIVE = '[secret-live]';
18
+ const SECRET_TEST = '[secret-test]';
19
+ const SECRET_MARKER = /\[secret(?:-(?:live|test))?\]/;
20
+ const SECRET_MARKER_ALL = /\[secret-(?:live|test)\]/g;
15
21
  const HASH_VERSION = 'marrow-normalized-action-v3';
16
22
  const EDIT_TOOLS = /^(?:edit|write|multiedit|apply_patch|notebookedit|replace|write_file|edit_file|delete_file|search_replace|delete|create_file)$/i;
17
23
  const MAX_DEPTH = 3;
@@ -36,9 +42,35 @@ function newExactness(deadline = Date.now() + DEFAULT_NORMALIZE_MS, exact = fals
36
42
  * A value withheld only because it MAY be a secret (it may also be an id, a commit or a name that decides the
37
43
  * action): replaced and the action marked truncated; the exact pass keeps it as it is.
38
44
  */
39
- function withheld(state, original, replacement = SECRET) {
45
+ function withheld(state, original, replacement) {
40
46
  state.truncated = true; // exact: kept
41
- return state.exact ? original : replacement;
47
+ return state.exact ? original : (replacement ?? secretFor(original));
48
+ }
49
+ /**
50
+ * The marker for a replaced span: `[secret-live]` / `[secret-test]` when the span holds a Stripe-style key of that class
51
+ * (key-class.ts: unknown, malformed and look-alike prefixes are live), else `[secret]`. A span past the scan limit is
52
+ * not read (it is withheld, truncated, and the exact pass keeps it as it is).
53
+ */
54
+ function secretFor(span) {
55
+ if (span.length > exports.MAX_SCAN_CHARS)
56
+ return SECRET;
57
+ const keyClass = (0, key_class_1.keyClassOf)(span);
58
+ return keyClass === 'live' ? SECRET_LIVE : keyClass === 'test' ? SECRET_TEST : SECRET;
59
+ }
60
+ /** The marker for a replaced value of any type (a tool input field): an object or array is read as bounded JSON. */
61
+ function secretOfValue(value) {
62
+ if (typeof value === 'string')
63
+ return secretFor(value);
64
+ try {
65
+ return secretFor((JSON.stringify(value) ?? '').slice(0, exports.MAX_SCAN_CHARS));
66
+ }
67
+ catch {
68
+ return SECRET;
69
+ }
70
+ }
71
+ /** Whether a text holds a replaced-secret marker (any class). */
72
+ function hasSecretMarker(text) {
73
+ return SECRET_MARKER.test(text);
42
74
  }
43
75
  /** Counts text about to be scanned against the shared budget; past it, normalization stops. */
44
76
  function spend(state, chars) {
@@ -94,7 +126,7 @@ function redactKeyShapes(text, state) {
94
126
  if (whole && !/\s/.test(whole) && looksLikeKey(whole)) {
95
127
  if (!exports.KNOWN_KEY_PREFIX.test(whole) && !PEM.test(whole))
96
128
  return withheld(state, text);
97
- return state.exact && PUBLIC_KNOWN_KEY.test(whole) ? text : SECRET;
129
+ return state.exact && PUBLIC_KNOWN_KEY.test(whole) ? text : secretFor(whole);
98
130
  }
99
131
  if (PEM.test(text)) {
100
132
  // The exact pass replaces only a private key block; a certificate or a public key block is kept.
@@ -105,7 +137,7 @@ function redactKeyShapes(text, state) {
105
137
  return part;
106
138
  if (!exports.KNOWN_KEY_PREFIX.test(part))
107
139
  return withheld(state, part);
108
- return state.exact && PUBLIC_KNOWN_KEY.test(part) ? part : SECRET;
140
+ return state.exact && PUBLIC_KNOWN_KEY.test(part) ? part : secretFor(part);
109
141
  }).join('');
110
142
  }
111
143
  // ---------------------------------------------------------------------------
@@ -236,10 +268,10 @@ function cutCredential(value, state) {
236
268
  if (at >= value.length) {
237
269
  if (holdsMoreThanCredential(value))
238
270
  return withheld(state, value);
239
- return SECRET;
271
+ return secretFor(value);
240
272
  }
241
273
  state.truncated = true; // exact: certain (the credential before the field; the rest is scanned in the same pass)
242
- return SECRET + redactText(value.slice(at), state);
274
+ return secretFor(value.slice(0, at)) + redactText(value.slice(at), state);
243
275
  }
244
276
  /** One-value auth header (`Authorization: Bearer X`, `X-Api-Key: K`): the whole value is the credential. */
245
277
  const SINGLE_VALUE_HEADER = /^[^:\s]{1,128}:[ \t]*(?:(?:Bearer|Basic|Token|Bot)[ \t]+)?[^\s;,&]+[ \t]*$/i;
@@ -260,7 +292,7 @@ function redactText(text, state) {
260
292
  // goes to host `deploy`), while libpq, mysql and redis clients take the whole span as the password. It is
261
293
  // hidden either way, but it is not a certain credential: the action is marked truncated and the exact pass
262
294
  // keeps the span, so it never shares a key with `https://deploy:<pw>@host`.
263
- let out = text.replace(URL_CREDENTIALS, (_match, scheme, user, password) => (`${scheme}${user}:${/[#?]/.test(password) ? withheld(state, password) : SECRET}@`));
295
+ let out = text.replace(URL_CREDENTIALS, (_match, scheme, user, password) => (`${scheme}${user}:${/[#?]/.test(password) ? withheld(state, password) : secretFor(password)}@`));
264
296
  // The exact pass keeps a `Token`/`Bot` value that is not key-shaped (`git commit -m "Token rotation"`).
265
297
  out = replaceValuesAfter(out, AUTH_SCHEME, state.exact ? (scheme, value) => /^(?:Token|Bot)$/.test(scheme) && !looksLikeKey(value) : null);
266
298
  // The exact pass keeps a URL `code` value: it may be an OAuth code, but it may as well be a country or product code.
@@ -286,7 +318,7 @@ function replaceValuesAfter(text, pattern, keep) {
286
318
  pattern.lastIndex = end;
287
319
  continue;
288
320
  }
289
- out += text.slice(copied, start) + SECRET;
321
+ out += text.slice(copied, start) + secretFor(text.slice(start, end));
290
322
  copied = end;
291
323
  pattern.lastIndex = end;
292
324
  }
@@ -326,7 +358,7 @@ function redactAssignments(text, state) {
326
358
  continue;
327
359
  const value = text.slice(start, end);
328
360
  if (!value.startsWith('[secret') && !value.startsWith('[data:')) {
329
- const replacement = kind === 'ambiguous' || holdsMoreThanCredential(value) ? withheld(state, value) : SECRET;
361
+ const replacement = kind === 'ambiguous' || holdsMoreThanCredential(value) ? withheld(state, value) : secretFor(value);
330
362
  out += text.slice(copied, start) + replacement;
331
363
  copied = end;
332
364
  }
@@ -349,7 +381,7 @@ function dataMarker(text, feedsSecrets, state, words = null) {
349
381
  }
350
382
  if (feedsSecrets) {
351
383
  state.truncated = true; // exact: kept (hashed above, certain credentials replaced)
352
- return SECRET;
384
+ return secretFor(text);
353
385
  }
354
386
  // Secret-shaped data (`'{"api_key":"S","env":"production"}'`): the secrets are
355
387
  // replaced and the rest is hashed, so data that differs outside a secret gives
@@ -573,7 +605,7 @@ function assignedValue(name, value, state) {
573
605
  if (kind === 'credential' || kind === 'ambiguous') {
574
606
  if (kind === 'ambiguous' || holdsMoreThanCredential(value))
575
607
  return `${name}=${withheld(state, state.exact ? dataMarker(value, false, state) : value)}`;
576
- return `${name}=${SECRET}`;
608
+ return `${name}=${secretFor(value)}`;
577
609
  }
578
610
  return value ? `${name}=${dataMarker(value, false, state)}` : `${name}=`;
579
611
  }
@@ -699,7 +731,7 @@ function redactSegment(words, ctx) {
699
731
  const envStore = storeContext(lowered.slice(0, 4));
700
732
  const stem = programStem(program);
701
733
  const redactAt = (i, ambiguous = false) => {
702
- out[i] = ambiguous || holdsMoreThanCredential(out[i]) ? withheld(state, out[i]) : SECRET;
734
+ out[i] = ambiguous || holdsMoreThanCredential(out[i]) ? withheld(state, out[i]) : secretFor(out[i]);
703
735
  handled.add(i);
704
736
  };
705
737
  // An auth header: a one-value header is the credential; any other (`Cookie: a=1; env=prod`)
@@ -711,7 +743,7 @@ function redactSegment(words, ctx) {
711
743
  if (holdsMoreThanCredential(value))
712
744
  return withheld(state, header);
713
745
  if (!state.exact)
714
- return SECRET;
746
+ return secretFor(value);
715
747
  // The exact pass: the value of a credential header (or a Bearer/Basic value) is certain; any other
716
748
  // one-value header (`X-Idempotency-Key: k`) and the header name are kept.
717
749
  const name = header.slice(0, colon);
@@ -720,7 +752,7 @@ function redactSegment(words, ctx) {
720
752
  // Basic USER:PASSWORD names who acts, as `curl -u` does: only the password is certain.
721
753
  const basic = /^[ \t]*Basic[ \t]+([A-Za-z0-9+/]+={0,2})[ \t]*$/i.exec(value);
722
754
  const decoded = basic ? Buffer.from(basic[1], 'base64').toString('latin1') : '';
723
- return /^[\x20-\x7e]*:/.test(decoded) ? `${name}:Basic ${JSON.stringify(decoded.slice(0, decoded.indexOf(':')))}:${SECRET}` : `${name}:${SECRET}`;
755
+ return /^[\x20-\x7e]*:/.test(decoded) ? `${name}:Basic ${JSON.stringify(decoded.slice(0, decoded.indexOf(':')))}:${secretFor(decoded.slice(decoded.indexOf(':') + 1))}` : `${name}:${secretFor(value)}`;
724
756
  }
725
757
  const redacted = redactText(header, state);
726
758
  return redacted === header ? withheld(state, header) : redacted;
@@ -739,9 +771,9 @@ function redactSegment(words, ctx) {
739
771
  return `${name}=${cutCredential(value, state)}`;
740
772
  };
741
773
  // The exact pass: `curl -u USER:PASSWORD` names who acts; only the password is certain.
742
- const exactUser = (value) => (value.includes(':') ? `${value.slice(0, value.indexOf(':'))}:${SECRET}` : value);
774
+ const exactUser = (value) => (value.includes(':') ? `${value.slice(0, value.indexOf(':'))}:${secretFor(value.slice(value.indexOf(':') + 1))}` : value);
743
775
  // The exact pass: openssl `pass:X` and a bare value are the password; `env:`, `file:`, `fd:` name where it is.
744
- const exactOpensslPass = (value) => (/^(?:env|file|fd):/.test(value) ? value : /^pass:/.test(value) ? `pass:${SECRET}` : SECRET);
776
+ const exactOpensslPass = (value) => (/^(?:env|file|fd):/.test(value) ? value : /^pass:/.test(value) ? `pass:${secretFor(value.slice(5))}` : secretFor(value));
745
777
  // A program named for a secret (set-password.sh): its arguments cannot be told apart (the exact pass keeps them).
746
778
  if (!SECRET_PROGRAMS.has(stem) && classifySecretName(stem) === 'credential') {
747
779
  for (const i of positionals)
@@ -771,7 +803,7 @@ function redactSegment(words, ctx) {
771
803
  };
772
804
  const attachedOrNext = (prefix) => {
773
805
  if (arg.length > prefix.length) {
774
- out[i] = `${prefix}${SECRET}`;
806
+ out[i] = `${prefix}${secretFor(arg.slice(prefix.length))}`;
775
807
  handled.add(i);
776
808
  }
777
809
  else {
@@ -790,12 +822,12 @@ function redactSegment(words, ctx) {
790
822
  if (long) {
791
823
  const name = long[1].replace(/^-+/, '');
792
824
  if (name === 'from-literal' || name === 'from-env-literal') {
793
- const replaced = `${long[1]}=${long[2].replace(/^([^=]*)=.*$/s, `$1=${SECRET}`)}`;
825
+ const replaced = `${long[1]}=${long[2].replace(/^([^=]*)=(.*)$/s, (_m, literal, secret) => `${literal}=${secretFor(secret)}`)}`;
794
826
  out[i] = holdsMoreThanCredential(long[2].replace(/^[^=]*=/, '')) ? withheld(state, arg, replaced) : replaced;
795
827
  handled.add(i);
796
828
  }
797
829
  else if (CURL_FAMILY.has(program) && /^(?:u|user|proxy-user)$/.test(name)) {
798
- out[i] = `${long[1]}=${state.exact ? exactUser(long[2]) : SECRET}`;
830
+ out[i] = `${long[1]}=${state.exact ? exactUser(long[2]) : secretFor(long[2])}`;
799
831
  handled.add(i);
800
832
  }
801
833
  else if (CURL_FAMILY.has(program) && name === 'header' && AUTH_HEADER.test(long[2])) {
@@ -846,7 +878,7 @@ function redactSegment(words, ctx) {
846
878
  // Short flags by program; single-dash long names (-password, -token) by name.
847
879
  if (/^-[A-Za-z]/.test(arg)) {
848
880
  if (MYSQL_FAMILY.has(program) && /^-p.+/.test(arg)) {
849
- out[i] = `-p${SECRET}`;
881
+ out[i] = `-p${secretFor(arg.slice(2))}`;
850
882
  handled.add(i);
851
883
  }
852
884
  else if (ALWAYS_P.has(program) && /^-p/.test(arg))
@@ -889,7 +921,7 @@ function redactSegment(words, ctx) {
889
921
  const [, name, value] = assignment;
890
922
  if (secretContext) {
891
923
  // In a secrets command every value is a secret: withheld whole, and truncated if it held more fields.
892
- out[i] = /[&#]/.test(value) || holdsMoreThanCredential(value) ? withheld(state, arg, `${name}=${SECRET}`) : `${name}=${SECRET}`;
924
+ out[i] = /[&#]/.test(value) || holdsMoreThanCredential(value) ? withheld(state, arg, `${name}=${secretFor(value)}`) : `${name}=${secretFor(value)}`;
893
925
  handled.add(i);
894
926
  }
895
927
  else if (holdsSecret(name)) {
@@ -909,7 +941,7 @@ function redactSegment(words, ctx) {
909
941
  continue;
910
942
  }
911
943
  if (program === 'openssl' && /^(?:pass|env|file|fd):/.test(arg)) {
912
- out[i] = state.exact ? exactOpensslPass(arg) : arg.replace(/:.*/s, `:${SECRET}`);
944
+ out[i] = state.exact ? exactOpensslPass(arg) : arg.replace(/:(.*)$/s, (_m, rest) => `:${secretFor(rest)}`);
913
945
  handled.add(i);
914
946
  continue;
915
947
  }
@@ -1264,7 +1296,7 @@ function redactedInput(value, state, depth = 0) {
1264
1296
  if (state.exact)
1265
1297
  throw new NormalizationLimit();
1266
1298
  state.truncated = true; // exact: raw (canonical input)
1267
- return SECRET;
1299
+ return secretOfValue(value);
1268
1300
  }
1269
1301
  if (typeof value === 'string')
1270
1302
  return redactText(value, state);
@@ -1289,7 +1321,7 @@ function redactedInput(value, state, depth = 0) {
1289
1321
  // name (`credentials: {user, role, password}`): only a value itself is certain.
1290
1322
  if (kind === 'ambiguous')
1291
1323
  state.truncated = true; // exact: kept (below)
1292
- out[key] = state.exact && (kind === 'ambiguous' || (item !== null && typeof item === 'object')) ? redactedInput(item, state, depth + 1) : SECRET;
1324
+ out[key] = state.exact && (kind === 'ambiguous' || (item !== null && typeof item === 'object')) ? redactedInput(item, state, depth + 1) : secretOfValue(item);
1293
1325
  }
1294
1326
  else {
1295
1327
  out[key] = redactedInput(item, state, depth + 1);
@@ -1456,7 +1488,7 @@ function inputPreimage(input, state) {
1456
1488
  if (input === undefined || input === null)
1457
1489
  return null;
1458
1490
  const text = JSON.stringify(sortedValue(redactedInput(input, state)));
1459
- if (text.includes(SECRET))
1491
+ if (hasSecretMarker(text))
1460
1492
  state.truncated = true; // exact: n/a (pass 1 only; exactMaterial is the exact form)
1461
1493
  return `${HASH_VERSION}\ninput\n${text}`;
1462
1494
  }
@@ -1800,7 +1832,7 @@ function normalizedOfKind(event, kind, hostToolName, state) {
1800
1832
  state.freeText = !(0, hook_tool_policy_1.isShellTextTool)(event);
1801
1833
  const { text, programs } = normalizeInner(raw, state, 0);
1802
1834
  // A secret was replaced: the action is never bound or reused (it asks each time).
1803
- if (text.includes(SECRET))
1835
+ if (hasSecretMarker(text))
1804
1836
  state.truncated = true; // exact: n/a (pass 1 only; the exact pass decides each value)
1805
1837
  // The command text itself only for a deploy the service can judge word for word (see deployCommandText).
1806
1838
  // Judged on the host's exact text (a trimmed non-ASCII space must not slip through).
@@ -1841,7 +1873,8 @@ function normalizedOfKind(event, kind, hostToolName, state) {
1841
1873
  if (targets.length > 64)
1842
1874
  state.truncated = true; // exact: n/a (paths are not exact material; the whole input is)
1843
1875
  const paths = targets.slice(0, 64).map((path) => {
1844
- const redacted = redactPathSpans(redactText(withoutUserName(path, state), state));
1876
+ // The paths go to the service's path classifier as they did before the key class: class-free (the class is in the hashes).
1877
+ const redacted = redactPathSpans(redactText(withoutUserName(path, state), state)).replace(SECRET_MARKER_ALL, SECRET);
1845
1878
  if (redacted.length > 512 || redacted.includes(SECRET) || path.length > PATH_SCAN_CHARS)
1846
1879
  state.truncated = true; // exact: n/a (as above)
1847
1880
  return redacted.slice(0, 512);