@yagni-app/code-staging 1.1.0-staging.1327.1 → 1.1.0-staging.1328.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.
@@ -4,9 +4,10 @@
4
4
  *
5
5
  * Pure: no I/O, no network, no model. Loads at startup and classifies
6
6
  * synchronously. The curated default set auto-allows read-only commands
7
- * (ls, cat, rg, git status/log/diff), forbids destructive ones (rm -rf,
8
- * git reset --hard, git push --force, pipe-to-shell), and prompts for the
9
- * ambiguous middle band (npm install, git commit, curl, …).
7
+ * (ls, cat, rg, git status/log/diff), forbids destructive ones (recursive rm,
8
+ * git reset --hard, git push --force, pipe-to-shell, bare-interpreter
9
+ * pipes), and prompts for the ambiguous middle band (non-recursive rm -f,
10
+ * inline-code interpreter pipes, npm install, git commit, curl, …).
10
11
  *
11
12
  * The `prompt` band is what the Guardian arbitrates — see guardian.ts.
12
13
  *
@@ -126,8 +127,10 @@ export declare function tokenize(command: string): string[];
126
127
  * each is classified independently; the strictest decision wins (forbidden >
127
128
  * prompt > allow). Commands with shell constructs (substitution, redirects,
128
129
  * background &) have a floor of `prompt`, and their substitution inner text
129
- * is danger-scanned against the forbidden rules. Pipe-to-shell is always
130
- * forbidden.
130
+ * is danger-scanned against the forbidden rules. Pipes into shells and
131
+ * network relays, or into a bare interpreter (stdin executed as the program),
132
+ * are always forbidden; an interpreter carrying inline code (-c/-e) falls to
133
+ * the prompt band — the code rides the command string the Guardian can read.
131
134
  */
132
135
  export declare function classifyCommand(command: string, policy: ExecPolicy): ExecClassification;
133
136
  /** Curated default rules — the shipped safety floor. */
@@ -4,9 +4,10 @@
4
4
  *
5
5
  * Pure: no I/O, no network, no model. Loads at startup and classifies
6
6
  * synchronously. The curated default set auto-allows read-only commands
7
- * (ls, cat, rg, git status/log/diff), forbids destructive ones (rm -rf,
8
- * git reset --hard, git push --force, pipe-to-shell), and prompts for the
9
- * ambiguous middle band (npm install, git commit, curl, …).
7
+ * (ls, cat, rg, git status/log/diff), forbids destructive ones (recursive rm,
8
+ * git reset --hard, git push --force, pipe-to-shell, bare-interpreter
9
+ * pipes), and prompts for the ambiguous middle band (non-recursive rm -f,
10
+ * inline-code interpreter pipes, npm install, git commit, curl, …).
10
11
  *
11
12
  * The `prompt` band is what the Guardian arbitrates — see guardian.ts.
12
13
  *
@@ -391,11 +392,28 @@ export function extractSubstitutions(command) {
391
392
  }
392
393
  return found;
393
394
  }
394
- /** Interpreters that, when piped into, indicate code execution — always forbidden. */
395
+ /**
396
+ * Words that, when piped into, are always forbidden: shells (execute stdin)
397
+ * and network relays (remote-shell class). Interpreters are separate — see
398
+ * INTERPRETER_INLINE_FLAGS — because with an inline-code flag (-c/-e) the
399
+ * piped data is just input to a script the Guardian can read on the command
400
+ * line; without one, stdin IS the program (download-and-execute).
401
+ */
395
402
  const PIPE_TO_SHELL = new Set([
396
- "sh", "bash", "zsh", "fish", "nc", "ncat", "socat",
403
+ "sh", "bash", "zsh", "fish", "dash", "ksh", "nc", "ncat", "socat",
404
+ ]);
405
+ /** Interpreters that execute piped stdin as a program unless given inline code. */
406
+ const PIPE_INTERPRETERS = new Set([
397
407
  "python", "python3", "perl", "ruby", "node",
398
408
  ]);
409
+ /** Per-interpreter flags that mean "the code is on the command line". */
410
+ const INTERPRETER_INLINE_FLAGS = {
411
+ python: new Set(["-c"]),
412
+ python3: new Set(["-c"]),
413
+ perl: new Set(["-e"]),
414
+ ruby: new Set(["-e"]),
415
+ node: new Set(["-e", "-p"]),
416
+ };
399
417
  /** Wrapper words that forward to another command (`sudo rm …` runs rm). */
400
418
  const WRAPPER_WORDS = new Set(["sudo", "env", "command", "builtin", "exec", "nohup", "time", "nice"]);
401
419
  /** Shell reserved words that can precede a command inside control flow. */
@@ -710,23 +728,83 @@ function classifySegmentTokens(rawTokens, policy, opts) {
710
728
  // No rule matched → prompt (fail toward review, not toward allow)
711
729
  return { decision: "prompt", justification: `no policy rule matched for "${tokens[0]}"` };
712
730
  }
713
- /** Check if any segment pipes into a known shell/network interpreter. */
731
+ /**
732
+ * Check if any segment pipes into a known shell/network interpreter, or into
733
+ * a bare interpreter (no inline-code flag — stdin is executed as the program).
734
+ * Interpreters carrying an inline-code flag WITH its code argument
735
+ * (`… | python3 -c '…'`) are NOT caught here: they fall through to normal
736
+ * segment classification (prompt band), because the code rides the command
737
+ * string the Guardian can read. The pipe target is resolved through the SAME
738
+ * stripLeadingTokens walk used for command words, so wrapper spellings
739
+ * (`… | env sh`, `… | env -i python3`, `… | /usr/bin/env python3 -c '…'`)
740
+ * resolve identically — one resolver, no drift between the two paths.
741
+ */
714
742
  function isPipeToShell(command) {
715
743
  const parsed = shellParse(command);
716
744
  for (let i = 0; i < parsed.length - 1; i++) {
717
745
  const t = parsed[i];
718
- if (typeof t === "object" && t.op === "pipe") {
719
- const next = parsed[i + 1];
720
- if (typeof next !== "string")
721
- continue;
722
- const word = basenameToken(next.startsWith("\\") ? next.slice(1) : next);
746
+ if (typeof t !== "object" || t.op !== "pipe")
747
+ continue;
748
+ // Collect the pipe target's leading plain tokens (up to the next op),
749
+ // then resolve the program word through the shared wrapper strip.
750
+ const target = [];
751
+ let j = i + 1;
752
+ while (j < parsed.length && typeof parsed[j] === "string") {
753
+ target.push(parsed[j]);
754
+ j++;
755
+ }
756
+ const { tokens: resolved, stripped: targetStripped } = stripLeadingTokens(target);
757
+ if (resolved.length === 0)
758
+ continue;
759
+ // Candidate program words: the resolved word, plus — when wrappers were
760
+ // stripped — every non-flag token after it. stripLeadingTokens stops at
761
+ // the first non-flag token after a wrapper, which for flags that TAKE a
762
+ // value (`env -u FOO python3`) makes the value the resolved word and can
763
+ // let a bare interpreter slip past. Checking every candidate is a
764
+ // deliberate conservative tradeoff: it can only ADD forbidden verdicts,
765
+ // never remove them, so a benign target like `… | env grep python3`
766
+ // (grep is the program, python3 an argument) is hard-forbidden instead
767
+ // of prompted — a false positive we accept to keep bare interpreters
768
+ // from escaping the floor through wrapper-flag spellings.
769
+ const candidates = [basenameToken(resolved[0])];
770
+ if (targetStripped) {
771
+ for (let k = 1; k < resolved.length; k++) {
772
+ if (!resolved[k].startsWith("-"))
773
+ candidates.push(basenameToken(resolved[k]));
774
+ }
775
+ }
776
+ for (const word of candidates) {
723
777
  if (PIPE_TO_SHELL.has(word))
724
778
  return true;
725
- // `… | env sh` / `… | /usr/bin/env sh`
726
- if (word === "env") {
727
- const after = parsed[i + 2];
728
- if (typeof after === "string" && PIPE_TO_SHELL.has(basenameToken(after)))
779
+ }
780
+ if (candidates.some((word) => PIPE_INTERPRETERS.has(word))) {
781
+ if (!hasInlineCodeArg(resolved))
782
+ return true;
783
+ }
784
+ }
785
+ return false;
786
+ }
787
+ /**
788
+ * Does a resolved pipe-target token list contain an interpreter carrying an
789
+ * inline-code flag WITH its code argument? An inline flag only counts when
790
+ * its argument follows in the same segment: argumentless `… | python3 -c`
791
+ * still leaves stdin as the program, so it stays forbidden. ANY interpreter
792
+ * in the segment satisfying flag+arg is enough — multi-interpreter segments
793
+ * (`… | python3 -c - python3 -c '…'`) downgrade to prompt when any word has
794
+ * its code on the command line, so the scan never early-returns on the first
795
+ * flag-without-arg.
796
+ */
797
+ function hasInlineCodeArg(resolved) {
798
+ for (let k = 0; k < resolved.length; k++) {
799
+ const w = basenameToken(resolved[k]);
800
+ if (!PIPE_INTERPRETERS.has(w))
801
+ continue;
802
+ const inlineFlags = INTERPRETER_INLINE_FLAGS[w] ?? new Set();
803
+ for (let m = k + 1; m < resolved.length; m++) {
804
+ if (inlineFlags.has(resolved[m])) {
805
+ if (m + 1 < resolved.length && !resolved[m + 1].startsWith("-"))
729
806
  return true;
807
+ break;
730
808
  }
731
809
  }
732
810
  }
@@ -814,8 +892,10 @@ function dangerScanSubstitutions(command, policy, depth) {
814
892
  * each is classified independently; the strictest decision wins (forbidden >
815
893
  * prompt > allow). Commands with shell constructs (substitution, redirects,
816
894
  * background &) have a floor of `prompt`, and their substitution inner text
817
- * is danger-scanned against the forbidden rules. Pipe-to-shell is always
818
- * forbidden.
895
+ * is danger-scanned against the forbidden rules. Pipes into shells and
896
+ * network relays, or into a bare interpreter (stdin executed as the program),
897
+ * are always forbidden; an interpreter carrying inline code (-c/-e) falls to
898
+ * the prompt band — the code rides the command string the Guardian can read.
819
899
  */
820
900
  export function classifyCommand(command, policy) {
821
901
  // Pipe-to-shell is always forbidden regardless of other rules.
@@ -858,11 +938,16 @@ export const DEFAULT_EXEC_POLICY = {
858
938
  // --- forbidden: position-independent dangerous-flag rules (checked first;
859
939
  // GNU getopt permutes flags, so `rm x -rf` and `git push origin
860
940
  // --force` carry the flag after positional args) ---
941
+ // Recursive deletion only: any -r/-R-bearing flag bundle. Combined
942
+ // bundles where r is not first (-fr, -fR) are listed explicitly because
943
+ // the trailing-star globs only anchor at the leading char. Exotic bundles
944
+ // (-fir) miss this rule and land on the -f prompt rule below — fail-closed
945
+ // toward Guardian review, never auto-allowed.
861
946
  {
862
947
  pattern: ["rm"],
863
- flagsAnywhere: ["-r*", "-f*", "--recursive*", "--force*"],
948
+ flagsAnywhere: ["-r*", "-R*", "-fr", "-fR", "--recursive*"],
864
949
  decision: "forbidden",
865
- justification: "recursive/forced deletion is destructive and irreversible",
950
+ justification: "recursive deletion is destructive and irreversible",
866
951
  },
867
952
  {
868
953
  // Exact --force/-f only: --force-with-lease is the guarded variant and
@@ -900,9 +985,9 @@ export const DEFAULT_EXEC_POLICY = {
900
985
  },
901
986
  // --- forbidden: destructive commands (positional) ---
902
987
  {
903
- pattern: ["rm", ["-rf", "-fr", "-r", "-f", "--recursive", "--force"]],
988
+ pattern: ["rm", ["-rf", "-fr", "-r", "-R", "--recursive"]],
904
989
  decision: "forbidden",
905
- justification: "recursive/forced deletion is destructive and irreversible",
990
+ justification: "recursive deletion is destructive and irreversible",
906
991
  },
907
992
  { pattern: ["git", "reset", "--hard"], decision: "forbidden", justification: "hard reset discards uncommitted changes irreversibly" },
908
993
  { pattern: ["git", "checkout", "--"], decision: "forbidden", justification: "discards working tree changes" },
@@ -1049,6 +1134,14 @@ export const DEFAULT_EXEC_POLICY = {
1049
1134
  { pattern: ["printenv"], decision: "allow", justification: "print environment variables (read-only)" },
1050
1135
  { pattern: ["npm", ["view", "info"]], decision: "allow", justification: "read package metadata from registry" },
1051
1136
  // --- prompt: potentially destructive but context-dependent ---
1137
+ // Non-recursive forced deletion — Guardian-reviewable, single file.
1138
+ // Must precede the generic rm prompt rule; ordered before it in this list.
1139
+ {
1140
+ pattern: ["rm"],
1141
+ flagsAnywhere: ["-f*", "--force*"],
1142
+ decision: "prompt",
1143
+ justification: "forced deletion of a single file — review the target",
1144
+ },
1052
1145
  { pattern: ["rm"], decision: "prompt", justification: "file deletion — review the target" },
1053
1146
  { pattern: ["git", "commit"], decision: "prompt", justification: "creates a commit — confirm intent" },
1054
1147
  { pattern: ["git", "push"], decision: "prompt", justification: "pushes to remote — confirm intent" },
@@ -1079,6 +1172,19 @@ export const DEFAULT_EXEC_POLICY = {
1079
1172
  { pattern: ["mkdir"], decision: "prompt", justification: "creates directories" },
1080
1173
  { pattern: ["touch"], decision: "prompt", justification: "creates or updates file timestamps" },
1081
1174
  { pattern: ["tar"], decision: "prompt", justification: "archive operation" },
1175
+ // Interpreters with inline code — the code is on the command line where
1176
+ // the Guardian can read it. Bare interpreters (program from stdin or a
1177
+ // file) never reach these: the pipe-to-interpreter check forbids the piped
1178
+ // form, and the file form is covered by the prompt rules below. Ordered
1179
+ // AFTER the `node --version`/`node -v` allow rules so version checks stay
1180
+ // auto-allowed.
1181
+ { pattern: ["python"], flagsAnywhere: ["-c"], decision: "prompt", justification: "python runs inline code — review the script" },
1182
+ { pattern: ["python3"], flagsAnywhere: ["-c"], decision: "prompt", justification: "python runs inline code — review the script" },
1183
+ { pattern: ["node", ["-e", "-p"]], decision: "prompt", justification: "node runs inline code — review the script" },
1184
+ { pattern: ["perl", "-e"], decision: "prompt", justification: "perl runs inline code — review the script" },
1185
+ { pattern: ["ruby", "-e"], decision: "prompt", justification: "ruby runs inline code — review the script" },
1186
+ { pattern: ["python", "-m"], decision: "prompt", justification: "python runs a module — review the module and args" },
1187
+ { pattern: ["python3", "-m"], decision: "prompt", justification: "python runs a module — review the module and args" },
1082
1188
  { pattern: ["zip"], decision: "prompt", justification: "archive operation" },
1083
1189
  { pattern: ["unzip"], decision: "prompt", justification: "archive operation" },
1084
1190
  { pattern: ["kill"], decision: "prompt", justification: "sends a signal to a process" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.1.0-staging.1327.1",
3
+ "version": "1.1.0-staging.1328.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -58,5 +58,5 @@
58
58
  "turndown": "^7.2.4",
59
59
  "typebox": "^1.3.15"
60
60
  },
61
- "yagniSourceSha": "82e201e640d0f201b2e2a28a14b4259921086436"
61
+ "yagniSourceSha": "34b64c98d42aa85f408a4f85de7ef103722038f7"
62
62
  }