@sagentlab/navarch-runtime 0.1.145 → 0.1.146
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/bin/worktree-guard-hook.cjs +225 -16
- package/package.json +1 -1
|
@@ -431,7 +431,11 @@ const LITERAL_PATTERN_COMMANDS = new Set([...CHAINABLE_COMMANDS, "sed", "awk", "
|
|
|
431
431
|
*/
|
|
432
432
|
const PROGRAM_COMMANDS = new Set(["bun", "deno", "node", "python", "python3", "tsx"]);
|
|
433
433
|
|
|
434
|
-
const
|
|
434
|
+
const PROGRAM_SHELL_WORDS_RE = /system|subprocess|popen|exec|spawn|child_process|shell|pty|command|\.run\b/i;
|
|
435
|
+
// A backtick also counts where the shell could expand it. A quoted heredoc
|
|
436
|
+
// body is literal, so there a backtick is only a JavaScript template literal
|
|
437
|
+
// (isProgramFileBody uses PROGRAM_SHELL_WORDS_RE alone).
|
|
438
|
+
const PROGRAM_SHELL_RE = new RegExp(`${PROGRAM_SHELL_WORDS_RE.source}|\``, "i");
|
|
435
439
|
|
|
436
440
|
/**
|
|
437
441
|
* git and gh run aliases, hooks and extensions through a shell, so only the
|
|
@@ -442,6 +446,12 @@ const MESSAGE_SUBCOMMAND_RE =
|
|
|
442
446
|
/^\s*(?:git\s+(?:commit|tag|notes|merge)|gh\s+(?:pr|issue|release)\s+(?:create|edit|comment|review|close))\b/;
|
|
443
447
|
const MESSAGE_FLAG_RE = /\s(?:-m|--message|-t|--title|-b|--body|--notes)(?:=|\s+)$/;
|
|
444
448
|
const MESSAGE_FILE_RE = /\s(?:--body-file|-F|--file)(?:=|\s)/;
|
|
449
|
+
// git accepts unique long-option prefixes, so `--ed` also opens the editor.
|
|
450
|
+
const EDITOR_FLAG_RE = /\s(?:-e|--ed(?:i(?:t(?:or)?)?)?)(?:[\s=]|$)/;
|
|
451
|
+
// Names a git or husky hook location, where written text runs on a later git command.
|
|
452
|
+
const HOOK_PATH_RE = /\.git\/|\.husky|hooks/i;
|
|
453
|
+
// A git `-c` override or an alias can make a git/gh message command run text.
|
|
454
|
+
const CONFIG_OVERRIDE_RE = /\s-c\s|alias/;
|
|
445
455
|
|
|
446
456
|
const ASSIGNMENTS_RE = /^\s*!?\s*(?:[A-Za-z_]\w*=\S*\s+)*/;
|
|
447
457
|
const LEADING_REDIRECTS_RE = /^(?:\s*\d*(?:<<?|>>?|&>>?)&?\s*[^\s;|&<>()]+)*\s*/;
|
|
@@ -468,7 +478,7 @@ function classifySegment(command, start) {
|
|
|
468
478
|
const word = commandWordOf(rest);
|
|
469
479
|
if (CHAINABLE_COMMANDS.has(word)) return "text";
|
|
470
480
|
if (PROGRAM_COMMANDS.has(word)) return "active";
|
|
471
|
-
if (MESSAGE_SUBCOMMAND_RE.test(rest) &&
|
|
481
|
+
if (MESSAGE_SUBCOMMAND_RE.test(rest) && !CONFIG_OVERRIDE_RE.test(command)) return "active";
|
|
472
482
|
return "unsafe";
|
|
473
483
|
}
|
|
474
484
|
|
|
@@ -488,7 +498,7 @@ function segmentText(command, start) {
|
|
|
488
498
|
* (`--body "$(cat <<'EOF' ... )"`).
|
|
489
499
|
*/
|
|
490
500
|
function exemptionContextIsSafe(command, segments) {
|
|
491
|
-
if (
|
|
501
|
+
if (HOOK_PATH_RE.test(command)) return false;
|
|
492
502
|
const classes = segments.map((segment) => ({ ...segment, kind: classifySegment(command, segment.at) }));
|
|
493
503
|
if (classes.some(({ kind }) => kind === "unsafe")) return false;
|
|
494
504
|
const active = classes.filter(({ kind }) => kind === "active");
|
|
@@ -497,8 +507,7 @@ function exemptionContextIsSafe(command, segments) {
|
|
|
497
507
|
const activeText = segmentText(command, active[0].at);
|
|
498
508
|
if (activeText.replace(ASSIGNMENTS_RE, "") !== activeText.trimStart()) return false;
|
|
499
509
|
const isMessage = MESSAGE_SUBCOMMAND_RE.test(activeText);
|
|
500
|
-
|
|
501
|
-
if (isMessage && /\s(?:-e|--ed(?:i(?:t(?:or)?)?)?)(?:[\s=]|$)/.test(activeText)) return false;
|
|
510
|
+
if (isMessage && EDITOR_FLAG_RE.test(activeText)) return false;
|
|
502
511
|
return classes.every(({ at, kind, nested }) => {
|
|
503
512
|
if (kind === "empty" || kind === "active") return true;
|
|
504
513
|
if (!isMessage || !nested) return false;
|
|
@@ -513,18 +522,21 @@ function isBareRootText(token) {
|
|
|
513
522
|
}
|
|
514
523
|
|
|
515
524
|
/**
|
|
516
|
-
* Heredoc bodies as [{start, end, word, segment, quoted}]
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
*
|
|
525
|
+
* Heredoc bodies as [{start, end, word, segment, quoted, at, nested}] (`at`
|
|
526
|
+
* is where the body's simple command starts), every simple command outside
|
|
527
|
+
* them as [{at, nested}] (nested inside `$(`, a backtick or a subshell),
|
|
528
|
+
* every single-quoted shell word outside double quotes and heredoc bodies as
|
|
529
|
+
* [{open, close, segmentStart}], and `ok`. A small lexer tracks single and
|
|
530
|
+
* double quotes, `$(`, backticks, subshells and shell comments to find each
|
|
531
|
+
* `<<` operator and its body. Any construct it cannot follow (`$'`, arithmetic `((`, process
|
|
521
532
|
* substitution `<(`/`>(`, a delimiter with `$` or an unbalanced quote, an
|
|
522
533
|
* unterminated quote, substitution or heredoc, an unbalanced `)`) returns
|
|
523
534
|
* `ok: false`, so nothing is exempted on a misread.
|
|
524
535
|
*/
|
|
525
536
|
function scanHeredocs(command) {
|
|
526
|
-
const failed = { ok: false, bodies: [], segments: [] };
|
|
537
|
+
const failed = { ok: false, bodies: [], segments: [], singleQuoted: [] };
|
|
527
538
|
const bodies = [];
|
|
539
|
+
const singleQuoted = [];
|
|
528
540
|
const segments = [{ at: 0, nested: false }];
|
|
529
541
|
const stack = [];
|
|
530
542
|
let pending = [];
|
|
@@ -562,6 +574,7 @@ function scanHeredocs(command) {
|
|
|
562
574
|
if (ch === "'") {
|
|
563
575
|
const close = command.indexOf("'", i + 1);
|
|
564
576
|
if (close === -1) return failed;
|
|
577
|
+
singleQuoted.push({ open: i, close, segmentStart });
|
|
565
578
|
i = close + 1;
|
|
566
579
|
continue;
|
|
567
580
|
}
|
|
@@ -627,6 +640,8 @@ function scanHeredocs(command) {
|
|
|
627
640
|
quoted: /['"\\]/.test(word[0]),
|
|
628
641
|
word: commandWordOf(command.slice(segmentStart, i)),
|
|
629
642
|
segment: command.slice(segmentStart, lineEnd === -1 ? n : lineEnd),
|
|
643
|
+
at: segmentStart,
|
|
644
|
+
nested: stack.some((frame) => frame !== "dq"),
|
|
630
645
|
});
|
|
631
646
|
i = j + word[0].length;
|
|
632
647
|
continue;
|
|
@@ -650,7 +665,15 @@ function scanHeredocs(command) {
|
|
|
650
665
|
if (newline === -1) break;
|
|
651
666
|
}
|
|
652
667
|
if (end === -1) return failed;
|
|
653
|
-
bodies.push({
|
|
668
|
+
bodies.push({
|
|
669
|
+
start,
|
|
670
|
+
end,
|
|
671
|
+
word: doc.word,
|
|
672
|
+
segment: doc.segment,
|
|
673
|
+
quoted: doc.quoted,
|
|
674
|
+
at: doc.at,
|
|
675
|
+
nested: doc.nested,
|
|
676
|
+
});
|
|
654
677
|
}
|
|
655
678
|
if (i > n) i = n;
|
|
656
679
|
pending = [];
|
|
@@ -660,7 +683,7 @@ function scanHeredocs(command) {
|
|
|
660
683
|
i++;
|
|
661
684
|
}
|
|
662
685
|
if (stack.length > 0 || pending.length > 0) return failed;
|
|
663
|
-
return { ok: true, bodies, segments };
|
|
686
|
+
return { ok: true, bodies, segments, singleQuoted };
|
|
664
687
|
}
|
|
665
688
|
|
|
666
689
|
/**
|
|
@@ -674,6 +697,178 @@ function readsAsText(segment, text, isMessage) {
|
|
|
674
697
|
return MESSAGE_SUBCOMMAND_RE.test(segment.replace(ASSIGNMENTS_RE, "")) && isMessage;
|
|
675
698
|
}
|
|
676
699
|
|
|
700
|
+
/** Reserved words that may precede a command word (`do jq ...`, `then gh ...`). */
|
|
701
|
+
const SHELL_KEYWORDS_RE = /^\s*(?:(?:do|then|else|elif|if|while|until|time|!|\{)\s+)*/;
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* True when bare root text sits inside a single-quoted jq program: a filter
|
|
705
|
+
* argument of `jq`, or the `--jq`/`-q` value of `gh`
|
|
706
|
+
* (`--jq '"\(.url|split("/")|last)"'`, `--jq '.a // "none"'`). The shell
|
|
707
|
+
* passes single-quoted text through unchanged, and jq cannot open a path or
|
|
708
|
+
* start a process from a filter, so `/`, `//` or `/*` there is jq syntax. The
|
|
709
|
+
* command word must be a bare `jq` or `gh` (not `./jq`), and the word must
|
|
710
|
+
* hold more than the token, so `jq . '/'` stays screened. Only bare root text
|
|
711
|
+
* qualifies: `jq 'include "/home/x"; .'` and every unquoted path argument
|
|
712
|
+
* stay candidates.
|
|
713
|
+
*/
|
|
714
|
+
function isJqProgramText(command, start, token, singleQuoted, segments) {
|
|
715
|
+
const span = singleQuoted.find(({ open, close }) => start > open && start < close);
|
|
716
|
+
if (!span) return false;
|
|
717
|
+
if (command.slice(span.open + 1, span.close).trim() === token) return false;
|
|
718
|
+
const prefix = command.slice(span.segmentStart, span.open).replace(SHELL_KEYWORDS_RE, "");
|
|
719
|
+
const rest = prefix.replace(ASSIGNMENTS_RE, "");
|
|
720
|
+
const isJq = /^jq\s/.test(rest) || (/^gh\s/.test(rest) && /\s(?:--jq|-q)(?:=|\s+)$/.test(prefix));
|
|
721
|
+
return isJq && outputStaysText(command, span.segmentStart, segments, singleQuoted);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* True when the output of the simple command at `at` cannot become a shell
|
|
726
|
+
* argument: the command is top level (not inside `$(`, a backtick or a
|
|
727
|
+
* subshell), and when it pipes onward or writes a file, every later simple
|
|
728
|
+
* command is empty or a chainable text command (`gh api x --jq '...' | head`).
|
|
729
|
+
* This keeps `jq -n '"/"' | xargs rm -rf`, `rm -rf "$(jq -n '"/"')"` and
|
|
730
|
+
* `jq -n '"/"' > run.sh; sh run.sh` screened.
|
|
731
|
+
*/
|
|
732
|
+
function outputStaysText(command, at, segments, singleQuoted) {
|
|
733
|
+
if (isNestedSegment(at, segments)) return false;
|
|
734
|
+
if (!otherOutputStaysText(command, at, segments, singleQuoted)) return false;
|
|
735
|
+
if (!pipesAfter(command, at, segments) && !writesOutput(command, at, segments, singleQuoted)) {
|
|
736
|
+
return true;
|
|
737
|
+
}
|
|
738
|
+
return segments.every(
|
|
739
|
+
(segment) => segment.at <= at || ["empty", "text"].includes(classifySegment(command, segment.at)),
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* True unless another simple command redirects output to a file that some
|
|
745
|
+
* other command could then run. A redirect outside the command at `at` can
|
|
746
|
+
* still capture its output: `exec >run.sh; jq ...; sh run.sh`,
|
|
747
|
+
* `{ jq ...; } > run.sh; sh run.sh` or `for ...; do jq ...; done > run.sh`.
|
|
748
|
+
* When any command writes output, every command except the one at `at` must
|
|
749
|
+
* be empty or a chainable text command. Pass `text` with heredoc bodies
|
|
750
|
+
* blanked, so a `>` in body prose does not count.
|
|
751
|
+
*/
|
|
752
|
+
function otherOutputStaysText(text, at, segments, singleQuoted) {
|
|
753
|
+
const writes = segments.some(
|
|
754
|
+
(segment) => segment.at !== at && writesOutput(text, segment.at, segments, singleQuoted),
|
|
755
|
+
);
|
|
756
|
+
if (!writes) return true;
|
|
757
|
+
return segments.every(
|
|
758
|
+
(segment) => segment.at === at || ["empty", "text"].includes(classifySegment(text, segment.at)),
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* True when the simple command at `at` may redirect output to a file
|
|
764
|
+
* (`jq ... > run.sh`), where a later command could run it. The command runs to
|
|
765
|
+
* the next quote-aware segment start. Single-quoted words (a jq `>`
|
|
766
|
+
* comparison) and `2>/dev/null` or `2>&1` do not count; a `>` anywhere else,
|
|
767
|
+
* including inside double quotes, does.
|
|
768
|
+
*/
|
|
769
|
+
function writesOutput(command, at, segments, singleQuoted) {
|
|
770
|
+
const next = segments.reduce(
|
|
771
|
+
(end, segment) => (segment.at > at && segment.at < end ? segment.at : end),
|
|
772
|
+
command.length,
|
|
773
|
+
);
|
|
774
|
+
let text = command.slice(at, next);
|
|
775
|
+
for (const { open, close } of singleQuoted) {
|
|
776
|
+
if (open >= at && close < next) {
|
|
777
|
+
text = text.slice(0, open - at) + " ".repeat(close - open + 1) + text.slice(close - at + 1);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
return />/.test(text.replace(/\d*>\s*\/dev\/null\b|\d*>&\d/g, ""));
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** True unless `at` starts a top-level simple command that scanHeredocs found. */
|
|
784
|
+
function isNestedSegment(at, segments) {
|
|
785
|
+
const own = segments.find((segment) => segment.at === at);
|
|
786
|
+
return !own || own.nested;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* True when any simple command after `at` starts at a pipe (`|` or `|&`, not
|
|
791
|
+
* `||`). The scanner's segment starts are quote-aware, so a `|` inside quotes
|
|
792
|
+
* never counts. This is broader than the pipeline of `at` on purpose: a pipe
|
|
793
|
+
* later in the command makes the caller check more, never less.
|
|
794
|
+
*/
|
|
795
|
+
function pipesAfter(command, at, segments) {
|
|
796
|
+
return segments.some(
|
|
797
|
+
({ at: next }) =>
|
|
798
|
+
next > at && command[next - 1] === "|" && command[next - 2] !== "|" && command[next] !== "|",
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** Command text with every heredoc body blanked out. */
|
|
803
|
+
function textOutsideBodies(command, bodies) {
|
|
804
|
+
let text = command;
|
|
805
|
+
for (const { start, end } of bodies) {
|
|
806
|
+
text = text.slice(0, start) + " ".repeat(end - start) + text.slice(end);
|
|
807
|
+
}
|
|
808
|
+
return text;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* True when a quoted heredoc body can reach no command except its own reader,
|
|
813
|
+
* so the rest of the command does not matter: `cat <<'EOF'` prints it, and
|
|
814
|
+
* `gh pr create --body-file - <<'EOF'` or `git commit -F - <<'EOF'` sends it as
|
|
815
|
+
* a message (`git push && gh pr create --body-file - <<'EOF'`). The reader
|
|
816
|
+
* must be top level, without a leading assignment, a `>` redirect or a pipe
|
|
817
|
+
* onward, and no other command may redirect output while a non-text command
|
|
818
|
+
* runs (otherOutputStaysText). A message reader also gets no editor flag, and no `-c` or alias
|
|
819
|
+
* text may appear outside the bodies.
|
|
820
|
+
*/
|
|
821
|
+
function isSealedBody(command, body, outside, segments, singleQuoted) {
|
|
822
|
+
if (body.nested || isNestedSegment(body.at, segments) || pipesAfter(command, body.at, segments)) {
|
|
823
|
+
return false;
|
|
824
|
+
}
|
|
825
|
+
if (!otherOutputStaysText(outside, body.at, segments, singleQuoted)) return false;
|
|
826
|
+
// body.segment runs from the reader to the end of its line, so this also
|
|
827
|
+
// rejects a `>` that hides behind a quoted separator (`cat 'a;b' > f`).
|
|
828
|
+
if (body.segment.includes(">")) return false;
|
|
829
|
+
const text = body.segment.trimStart();
|
|
830
|
+
if (text.replace(ASSIGNMENTS_RE, "") !== text) return false;
|
|
831
|
+
if (/^cat(?:\s|$)/.test(text)) return true;
|
|
832
|
+
if (!MESSAGE_SUBCOMMAND_RE.test(text) || !MESSAGE_FILE_RE.test(text)) return false;
|
|
833
|
+
return !EDITOR_FLAG_RE.test(text) && !CONFIG_OVERRIDE_RE.test(outside);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* True when a quoted heredoc body is a non-shell program written to a file and
|
|
838
|
+
* run only as that program: `cat > ./x.cjs <<'EOF' ... EOF` then
|
|
839
|
+
* `node ./x.cjs; rm ./x.cjs`. This is the same text `node - <<'EOF'` would
|
|
840
|
+
* run, so the same rule applies: the body must not name a way to start a
|
|
841
|
+
* process (PROGRAM_SHELL_WORDS_RE; a backtick in a quoted body is a template
|
|
842
|
+
* literal, not a shell substitution). The writer must be exactly `cat > FILE`
|
|
843
|
+
* with one `>` and a plain file name, at top level, without a pipe onward.
|
|
844
|
+
* Every other command must be top level and either empty, a chainable text
|
|
845
|
+
* command, `rm`, or a PROGRAM_COMMANDS interpreter, without leading
|
|
846
|
+
* assignments, whose first argument is FILE. Anything else, such as `sh FILE`,
|
|
847
|
+
* `chmod +x FILE`, `python3 -c ...` or `mv`, could run the text another way.
|
|
848
|
+
*/
|
|
849
|
+
function isProgramFileBody(command, body, segments) {
|
|
850
|
+
if (body.nested || PROGRAM_SHELL_WORDS_RE.test(command.slice(body.start, body.end))) return false;
|
|
851
|
+
if (isNestedSegment(body.at, segments)) return false;
|
|
852
|
+
// body.segment runs to the end of the line. The writer may be followed only
|
|
853
|
+
// by `;` or `&&`; the file-name class holds no quote, so no quoted
|
|
854
|
+
// separator can hide a pipe or a second redirect.
|
|
855
|
+
const writer = body.segment.replace(/\s*<<-?\s*\S+/, " ");
|
|
856
|
+
const redirect = /^\s*cat\s*>\s*([\w./-]+)\s*(?:$|;|&&)/.exec(writer);
|
|
857
|
+
if (!redirect || /<</.test(writer)) return false;
|
|
858
|
+
const file = redirect[1].replace(/^(?:\.\/)+/, "");
|
|
859
|
+
if (!file || file.endsWith("/")) return false;
|
|
860
|
+
const escaped = file.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
861
|
+
const runsFile = new RegExp(`^(?:${[...PROGRAM_COMMANDS].join("|")})\\s+(?:\\./)*${escaped}(?:\\s|$)`);
|
|
862
|
+
return segments.every(({ at, nested }) => {
|
|
863
|
+
if (at === body.at) return true;
|
|
864
|
+
if (nested) return false;
|
|
865
|
+
const kind = classifySegment(command, at);
|
|
866
|
+
if (kind === "empty" || kind === "text") return true;
|
|
867
|
+
const text = segmentText(command, at).trimStart();
|
|
868
|
+
return /^rm(?:\s|$)/.test(text) || runsFile.test(text);
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
|
|
677
872
|
/**
|
|
678
873
|
* True when bare root text sits in a heredoc body or quoted argument that a
|
|
679
874
|
* text command, a non-shell program, or a git/gh message reads: a
|
|
@@ -682,13 +877,27 @@ function readsAsText(segment, text, isMessage) {
|
|
|
682
877
|
* quoted argument must hold more than the token itself, so `ls "/"` stays
|
|
683
878
|
* screened. An unquoted-delimiter heredoc qualifies only without `$(` or
|
|
684
879
|
* backtick expansion in its body. No exemption applies unless the rest of the
|
|
685
|
-
* command cannot run that text (exemptionContextIsSafe)
|
|
880
|
+
* command cannot run that text (exemptionContextIsSafe), except for a
|
|
881
|
+
* single-quoted jq program whose output stays text (isJqProgramText), and a
|
|
882
|
+
* quoted heredoc body that reaches only its reader (isSealedBody) or runs only
|
|
883
|
+
* as a program file (isProgramFileBody). The two heredoc rules look for hook
|
|
884
|
+
* locations (HOOK_PATH_RE) only outside the bodies, so body prose that
|
|
885
|
+
* mentions `hooks` stays exempt. The jq rule needs no hook check, because jq
|
|
886
|
+
* output never reaches a hook file when it reaches no other command.
|
|
686
887
|
*/
|
|
687
888
|
function isProgramTextRootToken(command, start, token, heredocs) {
|
|
688
889
|
if (!isBareRootText(token)) return false;
|
|
689
|
-
const { ok, bodies, segments } = heredocs();
|
|
690
|
-
if (!ok
|
|
890
|
+
const { ok, bodies, segments, singleQuoted } = heredocs();
|
|
891
|
+
if (!ok) return false;
|
|
892
|
+
if (isJqProgramText(command, start, token, singleQuoted, segments)) return true;
|
|
691
893
|
const body = bodies.find((doc) => start >= doc.start && start < doc.end);
|
|
894
|
+
const outside = body && body.quoted ? textOutsideBodies(command, bodies) : "";
|
|
895
|
+
if (outside && !HOOK_PATH_RE.test(outside)) {
|
|
896
|
+
if (isSealedBody(command, body, outside, segments, singleQuoted) || isProgramFileBody(command, body, segments)) {
|
|
897
|
+
return true;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
if (!exemptionContextIsSafe(command, segments)) return false;
|
|
692
901
|
if (body) {
|
|
693
902
|
const text = command.slice(body.start, body.end);
|
|
694
903
|
if (!body.quoted && /`|\$\(/.test(text)) return false;
|
package/package.json
CHANGED