agent-first-data 0.34.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.
package/README.md CHANGED
@@ -22,11 +22,11 @@ Useful names use TypeScript casing: `render`, `outputOptionsForPolicy`, `redacte
22
22
  ## Behavior Notes
23
23
 
24
24
  - Default redaction replaces every `_secret` or configured secret-name subtree with `***`, including objects and arrays.
25
- - `_url` fields scrub userinfo passwords and secret-named query parameters; surrounding whitespace is trimmed and internal whitespace redacts the whole field.
25
+ - `_url` fields scrub userinfo passwords and secret-named query parameters; surrounding whitespace is trimmed, and internal whitespace or a `://` that is not the start of a clean URL redacts the whole field, where whitespace is the explicit Unicode `White_Space` set the spec enumerates, not the language's native predicate.
26
26
  - `OutputOptions.redaction.urlNames` applies URL treatment to exact legacy field names
27
27
  and recurses through collections. `redactUrlsInText` is explicit and scans
28
28
  only complete scheme URLs, never arbitrary prose secrets.
29
- - YAML keeps original keys and values (structure-preserving, like JSON), sorting keys by UTF-16 code unit order and quoting/escaping unsafe keys and string scalars. Plain strips formatting suffixes, formats values, sorts the same way, and renders nested objects/arrays as canonical JSON.
29
+ - YAML keeps original keys and values (structure-preserving, like JSON), sorting keys by UTF-16 code unit order and quoting/escaping unsafe keys and string scalars. Plain strips formatting suffixes, formats values, sorts the same way, and renders nested objects/arrays as canonical JSON; it quotes a value only when it contains ASCII space, tab, newline, carriage return, form feed, vertical tab, NBSP, `=`, `"`, or `\`, and escapes `\`, `"`, newline, carriage return, tab, form feed, and vertical tab inside the quotes.
30
30
  - Logging records use `kind:"log"` with a nested `log` payload and a separate `level` field, so error-level logs are not terminal protocol errors.
31
31
  - `buildCliError(message, hint?)` returns a strict-ready CLI error with `error.retryable:false` and `trace:{}`.
32
32
  - Do not add a raw version/help pre-parser. Until the TypeScript `CliSpec` compiler lands, applications should keep lifecycle parsing in their own parser rather than claim AFDATA closed-world help compatibility.
package/dist/cli.js CHANGED
@@ -110,8 +110,19 @@ export function parseOutputTo(value) {
110
110
  }
111
111
  throw new Error("invalid --output-to: expected split, stdout, or stderr");
112
112
  }
113
+ const guardedProcessStreams = new WeakSet();
114
+ function guardProcessStream(stream) {
115
+ if (guardedProcessStreams.has(stream))
116
+ return;
117
+ guardedProcessStreams.add(stream);
118
+ stream.on("error", (error) => {
119
+ if (error.code !== "EPIPE")
120
+ throw error;
121
+ });
122
+ }
113
123
  /** Event writer bound to the process stdout stream. */
114
124
  const processStdoutWriter = (line) => {
125
+ guardProcessStream(process.stdout);
115
126
  process.stdout.write(line);
116
127
  };
117
128
  /**
@@ -121,6 +132,7 @@ const processStdoutWriter = (line) => {
121
132
  * `stderr-sink` marker is what no_stderr_policy.test.ts allows through.
122
133
  */
123
134
  const processStderrWriter = (line) => {
135
+ guardProcessStream(process.stderr); // stderr-sink: CliEmitter diagnostic channel
124
136
  process.stderr.write(line); // stderr-sink: CliEmitter diagnostic channel
125
137
  };
126
138
  /**
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) {
@@ -1029,19 +1067,28 @@ function redactUrlFieldValue(s, secretNames) {
1029
1067
  const redacted = redactUrlInStr(s, secretNames);
1030
1068
  if (redacted !== null)
1031
1069
  return redacted;
1032
- const trimmed = s.trim();
1070
+ let start = 0;
1071
+ let end = s.length;
1072
+ while (start < end && isSpanTerminatorWhitespace(s.charCodeAt(start)))
1073
+ start += 1;
1074
+ while (end > start && isSpanTerminatorWhitespace(s.charCodeAt(end - 1)))
1075
+ end -= 1;
1076
+ const trimmed = s.slice(start, end);
1033
1077
  if (trimmed !== s) {
1034
1078
  const trimmedRedacted = redactUrlInStr(trimmed, secretNames);
1035
1079
  if (trimmedRedacted !== null)
1036
1080
  return trimmedRedacted;
1037
1081
  }
1038
1082
  // Fail closed: a _url value we could not parse as a clean scheme-prefixed
1039
- // URL, yet which carries a credential sigil ('@' userinfo) or internal
1040
- // whitespace, is redacted wholesale rather than passed through. A schemeless
1041
- // connection string like user:pass@host/db has no scheme anchor for the
1042
- // surgical span logic above, so blanket redaction is the safe default.
1043
- if (/\s/.test(s) || s.includes("@"))
1083
+ // URL, yet which carries a credential sigil ('@' userinfo), internal
1084
+ // whitespace, or a '://' scheme separator, is redacted wholesale rather than
1085
+ // passed through. A schemeless connection string like user:pass@host/db
1086
+ // has no scheme anchor for the surgical span logic above, and a URL behind
1087
+ // a non-whitespace prefix (a BOM, a zero-width space, a label) has one the
1088
+ // span logic cannot reach, so blanket redaction is the safe default.
1089
+ if ([...s].some(c => isSpanTerminatorWhitespace(c.charCodeAt(0))) || s.includes("@") || s.includes("://")) {
1044
1090
  return REDACTED_MARKER;
1091
+ }
1045
1092
  return s;
1046
1093
  }
1047
1094
  /**
@@ -1062,7 +1109,7 @@ function redactUserinfoPassword(authority) {
1062
1109
  * Redact the values of secret-named query parameters, preserving raw bytes of
1063
1110
  * every other segment (keys, benign values, encoding, ordering, separators).
1064
1111
  */
1065
- function redactQuery(query, secretNames) {
1112
+ function redactQuery(query, secretNames, depth) {
1066
1113
  return query
1067
1114
  .split("&")
1068
1115
  .map((segment) => {
@@ -1074,19 +1121,33 @@ function redactQuery(query, secretNames) {
1074
1121
  if (isSecretKey(name, secretNames)) {
1075
1122
  return `${rawKey}=${REDACTED_MARKER}`;
1076
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
+ }
1077
1132
  return segment;
1078
1133
  })
1079
1134
  .join("&");
1080
1135
  }
1081
- /** 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. */
1082
1147
  function formDecode(rawKey) {
1083
- const withSpaces = rawKey.replace(/\+/g, " ");
1084
- try {
1085
- return decodeURIComponent(withSpaces);
1086
- }
1087
- catch {
1088
- return withSpaces;
1089
- }
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") ?? "";
1090
1151
  }
1091
1152
  /**
1092
1153
  * True when s begins with a URL scheme (ALPHA *(ALPHA / DIGIT / "+" / "-" /
@@ -1173,28 +1234,30 @@ function decimalIntText(value) {
1173
1234
  }
1174
1235
  const MIN_I64 = -9223372036854775808n;
1175
1236
  const MAX_I64 = 9223372036854775807n;
1176
- function signedI64(value) {
1177
- let text = null;
1178
- if (typeof value === "bigint") {
1179
- text = value.toString();
1180
- }
1181
- else if (typeof value === "number" && Number.isSafeInteger(value)) {
1182
- text = String(value);
1183
- }
1184
- 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)) {
1185
1246
  const literal = value.toString();
1186
1247
  if (/^-?\d+$/.test(literal))
1187
- text = literal;
1188
- }
1189
- if (text === null)
1190
- return null;
1191
- try {
1192
- const integer = BigInt(text);
1193
- return integer >= MIN_I64 && integer <= MAX_I64 ? integer : null;
1194
- }
1195
- catch {
1196
- return null;
1248
+ return BigInt(literal);
1249
+ const parsed = Number(literal);
1250
+ return Number.isInteger(parsed) ? BigInt(parsed) : null;
1197
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;
1198
1261
  }
1199
1262
  function formatCents(value, symbol) {
1200
1263
  const sign = value < 0n ? "-" : "";
@@ -1346,8 +1409,9 @@ function tryProcessField(key, value) {
1346
1409
  }
1347
1410
  stripped = stripSuffixCI(key, "_bytes");
1348
1411
  if (stripped !== null) {
1349
- if (isInt(numeric) && numeric >= 0)
1350
- return [stripped, formatBytesHuman(numeric)];
1412
+ const bytes = unsignedU64(value);
1413
+ if (bytes !== null)
1414
+ return [stripped, formatBytesHuman(Number(bytes))];
1351
1415
  return null;
1352
1416
  }
1353
1417
  stripped = stripSuffixCI(key, "_percent");
@@ -1629,7 +1693,8 @@ function isAmbiguousYamlKey(key) {
1629
1693
  if (["true", "false", "null", "~", ".nan", ".inf", "+.inf", "-.inf"].includes(key.toLowerCase())) {
1630
1694
  return true;
1631
1695
  }
1632
- return key.trim() !== "" && Number.isFinite(Number(key));
1696
+ return ["inf", "infinity", "nan"].includes(key.replace(/^[+-]/, "").toLowerCase())
1697
+ || /^[-+]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][-+]?[0-9]+)?$/.test(key);
1633
1698
  }
1634
1699
  function yamlScalar(value) {
1635
1700
  if (typeof value === "string")
@@ -1726,7 +1791,7 @@ function plainScalarRaw(value) {
1726
1791
  function quoteLogfmtValue(value) {
1727
1792
  if (value === "")
1728
1793
  return "";
1729
- if (!/[\s="\\]/.test(value))
1794
+ if (!/[ \t\n\r\f\v\u00a0="\\]/.test(value))
1730
1795
  return value;
1731
1796
  const escaped = value
1732
1797
  .replace(/\\/g, "\\\\")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-first-data",
3
- "version": "0.34.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.2.0",
31
- "tsx": "^4.23.12",
30
+ "@types/node": "^26.6.5",
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
  }