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 +2 -2
- package/dist/cli.js +12 -0
- package/dist/format.js +106 -41
- package/package.json +4 -4
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
|
-
|
|
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
|
-
|
|
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)
|
|
1040
|
-
// whitespace, is redacted wholesale rather than
|
|
1041
|
-
// connection string like user:pass@host/db
|
|
1042
|
-
// surgical span logic above,
|
|
1043
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
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
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
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
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
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
|
-
|
|
1350
|
-
|
|
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.
|
|
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 (!/[\
|
|
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.
|
|
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.
|
|
31
|
-
"tsx": "^4.23.
|
|
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.
|
|
35
|
+
"lossless-json": "^4.3.3"
|
|
36
36
|
}
|
|
37
37
|
}
|