agent-first-data 0.35.0 → 0.35.1

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 (2) hide show
  1. package/dist/format.js +88 -33
  2. package/package.json +3 -3
package/dist/format.js CHANGED
@@ -869,7 +869,7 @@ function applyRedactionPolicyWithContext(value, redactionPolicy, context) {
869
869
  * when s is a processable URL, or null when it is not (so callers can keep the
870
870
  * original). Only secret spans change; all other bytes are preserved.
871
871
  */
872
- function redactUrlInStr(s, secretNames) {
872
+ function redactUrlInStr(s, secretNames, depth = 0) {
873
873
  // Precondition (spec): a single, whitespace-free, scheme-prefixed URL.
874
874
  // The gate is scheme + no-whitespace only — NOT "parses via `new URL`". Span
875
875
  // location below is purely byte-wise (we never re-serialize the URL), so a
@@ -879,6 +879,9 @@ function redactUrlInStr(s, secretNames) {
879
879
  if (!s.includes("://") || !isSingleUrl(s)) {
880
880
  return null;
881
881
  }
882
+ // Bound recursive redirect inspection, failing closed before stack growth.
883
+ if (depth >= 16)
884
+ return REDACTED_MARKER;
882
885
  const schemeSep = s.indexOf("://");
883
886
  const scheme = s.slice(0, schemeSep);
884
887
  const rest = s.slice(schemeSep + 3);
@@ -891,8 +894,31 @@ function redactUrlInStr(s, secretNames) {
891
894
  break;
892
895
  }
893
896
  }
897
+ // An unencoded '?' or '#' inside a password ends that authority early and
898
+ // leaves the rest of the password in the query or fragment. When a ':'
899
+ // precedes that early end and an '@' follows it before the first '/', the
900
+ // userinfo runs to that '@' — how libpq reads such a connection string.
901
+ const slash = rest.indexOf("/");
902
+ const firstSlash = slash < 0 ? rest.length : slash;
903
+ const at = rest.slice(0, firstSlash).lastIndexOf("@");
904
+ if (rest.slice(0, authEnd).includes(":") && at > authEnd) {
905
+ authEnd = rest.length;
906
+ for (let i = at; i < rest.length; i++) {
907
+ const c = rest[i];
908
+ if (c === "/" || c === "?" || c === "#") {
909
+ authEnd = i;
910
+ break;
911
+ }
912
+ }
913
+ }
894
914
  const authority = rest.slice(0, authEnd);
895
915
  const remainder = rest.slice(authEnd);
916
+ // An unencoded '/' inside a password ends the authority at a "port" that is
917
+ // not a number, with the '@' somewhere after it. Where the password ends
918
+ // cannot be known, so the whole value is replaced.
919
+ if (!authority.includes("@") && hasNonNumericPort(authority) && remainder.includes("@")) {
920
+ return REDACTED_MARKER;
921
+ }
896
922
  const newAuthority = redactUserinfoPassword(authority);
897
923
  // `remainder` is `path[?query][#fragment]`; '#' ends the query, so split the
898
924
  // fragment off first and the query out of what is left.
@@ -901,14 +927,26 @@ function redactUrlInStr(s, secretNames) {
901
927
  const fragment = hash >= 0 ? remainder.slice(hash + 1) : null;
902
928
  const q = beforeFragment.indexOf("?");
903
929
  const newBeforeFragment = q >= 0
904
- ? `${beforeFragment.slice(0, q)}?${redactQuery(beforeFragment.slice(q + 1), secretNames)}`
930
+ ? `${beforeFragment.slice(0, q)}?${redactQuery(beforeFragment.slice(q + 1), secretNames, depth)}`
905
931
  : beforeFragment;
906
932
  // A fragment gets the same treatment as the query: `k=v&k=v` after the '#' is
907
933
  // exactly how an OAuth implicit-flow response hands back a token, so a
908
934
  // secret-named fragment parameter must not survive where the identically
909
935
  // named query parameter would not. A fragment that is not in that shape has
910
936
  // no '=' in its segments and passes through byte-for-byte.
911
- const newFragment = fragment === null ? "" : `#${redactQuery(fragment, secretNames)}`;
937
+ // A hash route (`#/cb?k=v`) carries its parameters after its first '?',
938
+ // while a form reader keeps that '?' inside a value; redact both readings:
939
+ // the fragment as parameters first, so a secret value absorbs its '?',
940
+ // then whatever follows the first '?' that survives.
941
+ let newFragment = "";
942
+ if (fragment !== null) {
943
+ const params = redactQuery(fragment, secretNames, depth);
944
+ const routeQuery = params.indexOf("?");
945
+ newFragment =
946
+ routeQuery >= 0
947
+ ? `#${params.slice(0, routeQuery)}?${redactQuery(params.slice(routeQuery + 1), secretNames, depth)}`
948
+ : `#${params}`;
949
+ }
912
950
  return `${scheme}://${newAuthority}${newBeforeFragment}${newFragment}`;
913
951
  }
914
952
  function redactUrlsInTextWithContext(text, context) {
@@ -1071,7 +1109,7 @@ function redactUserinfoPassword(authority) {
1071
1109
  * Redact the values of secret-named query parameters, preserving raw bytes of
1072
1110
  * every other segment (keys, benign values, encoding, ordering, separators).
1073
1111
  */
1074
- function redactQuery(query, secretNames) {
1112
+ function redactQuery(query, secretNames, depth) {
1075
1113
  return query
1076
1114
  .split("&")
1077
1115
  .map((segment) => {
@@ -1083,19 +1121,33 @@ function redactQuery(query, secretNames) {
1083
1121
  if (isSecretKey(name, secretNames)) {
1084
1122
  return `${rawKey}=${REDACTED_MARKER}`;
1085
1123
  }
1124
+ // A redirect target carries its own parameters: redact a nested URL in
1125
+ // place, or the whole value when it arrived encoded.
1126
+ const rawValue = segment.slice(eq + 1);
1127
+ const value = formDecode(rawValue);
1128
+ const redacted = redactUrlInStr(value, secretNames, depth + 1);
1129
+ if (redacted !== null && redacted !== value) {
1130
+ return value === rawValue ? `${rawKey}=${redacted}` : `${rawKey}=${REDACTED_MARKER}`;
1131
+ }
1086
1132
  return segment;
1087
1133
  })
1088
1134
  .join("&");
1089
1135
  }
1090
- /** Form-decode a query-parameter name: '+' → space, then percent-decode. */
1136
+ /**
1137
+ * True when the authority's text after its last ':' is not a port number. A
1138
+ * bracketed IPv6 literal without a port ends in ']' and has none.
1139
+ */
1140
+ function hasNonNumericPort(authority) {
1141
+ const colon = authority.lastIndexOf(":");
1142
+ if (colon < 0 || authority.endsWith("]"))
1143
+ return false;
1144
+ return !/^[0-9]*$/.test(authority.slice(colon + 1));
1145
+ }
1146
+ /** Form-decode a query-parameter name or value: '+' → space, then percent-decode. */
1091
1147
  function formDecode(rawKey) {
1092
- const withSpaces = rawKey.replace(/\+/g, " ");
1093
- try {
1094
- return decodeURIComponent(withSpaces);
1095
- }
1096
- catch {
1097
- return withSpaces;
1098
- }
1148
+ // URLSearchParams keeps malformed escapes literal and replaces invalid
1149
+ // UTF-8, while still decoding valid escapes elsewhere in the value.
1150
+ return new URLSearchParams(`value=${rawKey.replace(/&/g, "%26")}`).get("value") ?? "";
1099
1151
  }
1100
1152
  /**
1101
1153
  * True when s begins with a URL scheme (ALPHA *(ALPHA / DIGIT / "+" / "-" /
@@ -1182,28 +1234,30 @@ function decimalIntText(value) {
1182
1234
  }
1183
1235
  const MIN_I64 = -9223372036854775808n;
1184
1236
  const MAX_I64 = 9223372036854775807n;
1185
- function signedI64(value) {
1186
- let text = null;
1187
- if (typeof value === "bigint") {
1188
- text = value.toString();
1189
- }
1190
- else if (typeof value === "number" && Number.isSafeInteger(value)) {
1191
- text = String(value);
1192
- }
1193
- else if (isLosslessNumber(value)) {
1237
+ const MAX_U64 = 18446744073709551615n;
1238
+ // An integer literal stays exact; a decoded integral float (`3.0`) counts as
1239
+ // an integer like any other.
1240
+ function integerValue(value) {
1241
+ if (typeof value === "bigint")
1242
+ return value;
1243
+ if (typeof value === "number")
1244
+ return Number.isInteger(value) ? BigInt(value) : null;
1245
+ if (isLosslessNumber(value)) {
1194
1246
  const literal = value.toString();
1195
1247
  if (/^-?\d+$/.test(literal))
1196
- text = literal;
1197
- }
1198
- if (text === null)
1199
- return null;
1200
- try {
1201
- const integer = BigInt(text);
1202
- return integer >= MIN_I64 && integer <= MAX_I64 ? integer : null;
1203
- }
1204
- catch {
1205
- return null;
1248
+ return BigInt(literal);
1249
+ const parsed = Number(literal);
1250
+ return Number.isInteger(parsed) ? BigInt(parsed) : null;
1206
1251
  }
1252
+ return null;
1253
+ }
1254
+ function signedI64(value) {
1255
+ const integer = integerValue(value);
1256
+ return integer !== null && integer >= MIN_I64 && integer <= MAX_I64 ? integer : null;
1257
+ }
1258
+ function unsignedU64(value) {
1259
+ const integer = integerValue(value);
1260
+ return integer !== null && integer >= 0n && integer <= MAX_U64 ? integer : null;
1207
1261
  }
1208
1262
  function formatCents(value, symbol) {
1209
1263
  const sign = value < 0n ? "-" : "";
@@ -1355,8 +1409,9 @@ function tryProcessField(key, value) {
1355
1409
  }
1356
1410
  stripped = stripSuffixCI(key, "_bytes");
1357
1411
  if (stripped !== null) {
1358
- if (isInt(numeric) && numeric >= 0)
1359
- return [stripped, formatBytesHuman(numeric)];
1412
+ const bytes = unsignedU64(value);
1413
+ if (bytes !== null)
1414
+ return [stripped, formatBytesHuman(Number(bytes))];
1360
1415
  return null;
1361
1416
  }
1362
1417
  stripped = stripSuffixCI(key, "_percent");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-first-data",
3
- "version": "0.35.0",
3
+ "version": "0.35.1",
4
4
  "description": "A naming convention that lets AI agents understand your data without being told what it means, plus a CLI and library for reading Markdown structure and safely editing structured JSON, TOML, YAML, dotenv, and INI documents.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -27,11 +27,11 @@
27
27
  "test": "tsx --test src/*.test.ts"
28
28
  },
29
29
  "devDependencies": {
30
- "@types/node": "^26.6.4",
30
+ "@types/node": "^26.6.5",
31
31
  "tsx": "^4.23.15",
32
32
  "typescript": "^7.0.2"
33
33
  },
34
34
  "dependencies": {
35
- "lossless-json": "^4.3.1"
35
+ "lossless-json": "^4.3.3"
36
36
  }
37
37
  }