approval-md 0.4.3 → 0.4.6

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 (52) hide show
  1. package/SPEC.md +13 -2
  2. package/dist/src/channels/telegram.d.ts +105 -31
  3. package/dist/src/channels/telegram.js +157 -81
  4. package/dist/src/channels/telegram.js.map +1 -1
  5. package/dist/src/cli/channel-telegram.d.ts +100 -35
  6. package/dist/src/cli/channel-telegram.js +216 -119
  7. package/dist/src/cli/channel-telegram.js.map +1 -1
  8. package/dist/src/cli/doctor.js +33 -13
  9. package/dist/src/cli/doctor.js.map +1 -1
  10. package/dist/src/cli/help.js +3 -3
  11. package/dist/src/cli/hook-exec-code.d.ts +150 -0
  12. package/dist/src/cli/hook-exec-code.js +357 -0
  13. package/dist/src/cli/hook-exec-code.js.map +1 -0
  14. package/dist/src/cli/hook.d.ts +125 -1
  15. package/dist/src/cli/hook.js +2177 -107
  16. package/dist/src/cli/hook.js.map +1 -1
  17. package/dist/src/cli/wordmark.d.ts +2 -2
  18. package/dist/src/cli/wordmark.js +2 -2
  19. package/dist/src/core/command-class.d.ts +368 -1
  20. package/dist/src/core/command-class.js +2830 -93
  21. package/dist/src/core/command-class.js.map +1 -1
  22. package/dist/src/core/date-argv.d.ts +83 -0
  23. package/dist/src/core/date-argv.js +212 -0
  24. package/dist/src/core/date-argv.js.map +1 -0
  25. package/dist/src/core/exec-code.d.ts +77 -0
  26. package/dist/src/core/exec-code.js +60 -0
  27. package/dist/src/core/exec-code.js.map +1 -0
  28. package/dist/src/core/git-push-argv.d.ts +111 -0
  29. package/dist/src/core/git-push-argv.js +375 -0
  30. package/dist/src/core/git-push-argv.js.map +1 -0
  31. package/dist/src/core/harness-version.d.ts +71 -30
  32. package/dist/src/core/harness-version.js +101 -34
  33. package/dist/src/core/harness-version.js.map +1 -1
  34. package/dist/src/core/policy-load.d.ts +8 -0
  35. package/dist/src/core/policy-load.js.map +1 -1
  36. package/dist/src/core/read-scope.js +1 -0
  37. package/dist/src/core/read-scope.js.map +1 -1
  38. package/dist/src/core/sysinfo-argv.d.ts +48 -0
  39. package/dist/src/core/sysinfo-argv.js +340 -0
  40. package/dist/src/core/sysinfo-argv.js.map +1 -0
  41. package/dist/src/daemon/git-evidence.d.ts +2 -2
  42. package/dist/src/daemon/git-evidence.js +1 -1
  43. package/docs/README-extended.md +3 -2
  44. package/docs/cli-reference.md +20 -7
  45. package/package.json +2 -2
  46. package/schema/event.schema.json +1 -1
  47. package/schema/fixtures/policy/invalid/exec-code-custody-boolean.json +11 -0
  48. package/schema/fixtures/policy/invalid/exec-code-execute-code-value.json +11 -0
  49. package/schema/fixtures/policy/invalid/exec-code-unknown-key.json +12 -0
  50. package/schema/fixtures/policy/valid/exec-code-both.json +17 -0
  51. package/schema/fixtures/policy/valid/exec-code-custody.json +11 -0
  52. package/schema/policy.schema.json +23 -0
@@ -10,13 +10,13 @@
10
10
  * half-blocks, no box-drawing, no emoji, because those are the characters that
11
11
  * break in Terminal.app's default font and in every CI log. And it DEGRADES —
12
12
  * with colour off it collapses to a single line, so a pipe, a `NO_COLOR`
13
- * terminal or an ASCII locale gets `approval.md v0.4.3` and no decoration at
13
+ * terminal or an ASCII locale gets `approval.md v0.4.6` and no decoration at
14
14
  * all. The banner is ornament; the name and the version are the information,
15
15
  * and only the information survives the degradation.
16
16
  */
17
17
  import { type Style } from "./style.js";
18
18
  /** Kept in step with package.json by a test rather than by memory. */
19
- export declare const VERSION = "0.4.3";
19
+ export declare const VERSION = "0.4.6";
20
20
  export declare const TAGLINE = "human approval for agent actions";
21
21
  /** The one-line form, and the only form a pipe ever sees. */
22
22
  export declare function plainWordmark(): string;
@@ -10,13 +10,13 @@
10
10
  * half-blocks, no box-drawing, no emoji, because those are the characters that
11
11
  * break in Terminal.app's default font and in every CI log. And it DEGRADES —
12
12
  * with colour off it collapses to a single line, so a pipe, a `NO_COLOR`
13
- * terminal or an ASCII locale gets `approval.md v0.4.3` and no decoration at
13
+ * terminal or an ASCII locale gets `approval.md v0.4.6` and no decoration at
14
14
  * all. The banner is ornament; the name and the version are the information,
15
15
  * and only the information survives the degradation.
16
16
  */
17
17
  import { style as processStyle } from "./style.js";
18
18
  /** Kept in step with package.json by a test rather than by memory. */
19
- export const VERSION = "0.4.3";
19
+ export const VERSION = "0.4.6";
20
20
  export const TAGLINE = "human approval for agent actions";
21
21
  /**
22
22
  * The six-line form. Written as a raw array so no editor reflows it: every
@@ -82,7 +82,32 @@ export interface ClassifiedSegment {
82
82
  * profile is the caller's, and a profile a caller wrote can allow anything.
83
83
  */
84
84
  sandbox?: SandboxWrapper;
85
+ /**
86
+ * The program an `exec.code` segment runs, present only on such a segment
87
+ * (APRV-510). Text only, like everything this file returns: the hook hashes
88
+ * the inline source, or reads and hashes the file, and binds the hash and the
89
+ * length, never the text (SPEC.md §11.1 invariant 3).
90
+ */
91
+ execCode?: ExecCodeSource;
92
+ }
93
+ /**
94
+ * What an `exec.code` segment runs, as the classifier read it (APRV-510).
95
+ *
96
+ * `source` is the inline program (`python3 -c <source>`, `node -e <source>`,
97
+ * `deno eval <source>`) or the file path as written (`python3 <source>`,
98
+ * `bun <source>`). `argv` is the segment's argv from the binary on, verbatim,
99
+ * and `sourceIndex` names the word of `argv` that carries `source`, so a
100
+ * caller can bind the argv with that one word replaced by its hash.
101
+ */
102
+ export interface ExecCodeSource {
103
+ interpreter: string;
104
+ mode: ExecCodeMode;
105
+ source: string;
106
+ argv: string[];
107
+ sourceIndex: number;
85
108
  }
109
+ /** How an `exec.code` segment carries its program (APRV-510). */
110
+ export type ExecCodeMode = "inline" | "file";
86
111
  /**
87
112
  * Who wrote the profile a wrapped command runs under (APRV-193).
88
113
  *
@@ -222,6 +247,35 @@ interface ProtectedEntry {
222
247
  * skipped validation.
223
248
  */
224
249
  export declare function parseProtectedEntry(entry: ProtectedPathEntry): ProtectedEntry | null;
250
+ /**
251
+ * The words bash's brace expansion makes of `word`, or `null` when there would
252
+ * be more than {@link BRACE_EXPANSION_CAP}, or making them would take more than
253
+ * {@link BRACE_EXPANSION_STEPS} expansions (APRV-511 refuter M2, recheck S(new)).
254
+ *
255
+ * A group is `{` … `}` holding a top-level comma (`{a,b}`, `{,s}`) or a
256
+ * sequence (`{1..3}`, `{a..c}`, `{1..9..2}`); a `{` right after `$` opens a
257
+ * parameter expansion and is never a group, and anything else stays literal,
258
+ * as it does in bash. Groups nest and multiply. The lexer has already removed
259
+ * quoting, so a quoted brace pair reads as a group too, which can only make an
260
+ * answer stricter.
261
+ *
262
+ * The expansion is iterative, never recursive: a work list holds the words
263
+ * still carrying a group, and both bounds are tested BEFORE a word is
264
+ * expanded further, so no input reaches the stack limit or runs unbounded.
265
+ * Every caller reads `null` as the stricter class.
266
+ */
267
+ export declare function braceExpansions(word: string): string[] | null;
268
+ /**
269
+ * The words a path word may stand for once bash has expanded its brace groups,
270
+ * plus, for any word that still carries a stray `{` or `}` outside a parameter
271
+ * expansion, the same word with the stray braces removed (APRV-511 review).
272
+ * Bash keeps a stray brace literally (`{~/.hermes/.op.env` names a directory
273
+ * called `{~`), but a word whose only distance from an organ or a credential is
274
+ * a stray brace is judged as that organ: the stricter of the two readings.
275
+ * `null` when the expansion exceeds {@link BRACE_EXPANSION_CAP}, unless every
276
+ * group is a sequence or list of plain numbers ({@link numericRepresentative}).
277
+ */
278
+ export declare function braceCandidates(word: string): string[] | null;
225
279
  /**
226
280
  * Which protected class does this path name, if any? (APRV-198.)
227
281
  *
@@ -384,6 +438,58 @@ export declare const NON_SECRET_ENV_NAMES: readonly string[];
384
438
  * environment rather than of command text, and it must ask it the same way.
385
439
  */
386
440
  export declare function isSecretEnvName(name: string): boolean;
441
+ /**
442
+ * Does this git argv only WRITE the paths it names? (APRV-522.) `true` only
443
+ * when the verb is read unambiguously, is one of
444
+ * {@link GIT_PATH_WRITE_OPTIONS}, and every option before `--` is on that
445
+ * verb's allow-list.
446
+ *
447
+ * The verb is found the way APRV-520's {@link gitGlobalRefusal} reads the
448
+ * globals: zero-value globals are skipped, and ANY other flag before the verb
449
+ * (a global that takes a value, `-C dir`, `-c k=v`, `--git-dir x`, a joined
450
+ * `--work-tree=`, or one this table does not know) leaves the verb unknown, so
451
+ * the argv is a READER. `classifySegment` refuses those globals anyway; this
452
+ * does not rely on that ordering.
453
+ *
454
+ * The options are a closed list, never a deny-list: git accepts any unique
455
+ * prefix of a long option (`--pat` is `--patch`), several verbs print the
456
+ * file's hunks (`git add -p`, `git checkout -p`, `git restore --patch`), and
457
+ * `git checkout-index --prefix=/tmp/` writes a copy elsewhere. So an option
458
+ * not listed for the verb (an abbreviation, a short letter bundled with one
459
+ * not listed, `-fp`, an unknown flag) makes the argv a reader, and the
460
+ * credential rule judges what it names.
461
+ */
462
+ /**
463
+ * Where git's verb sits in its argv, read the way {@link gitWritesNamedPathsOnly}
464
+ * reads it (APRV-522 fix round 2): only zero-value globals may come before it,
465
+ * and any other flag leaves the verb unknown (`null`). The hook's credential
466
+ * pass uses it so that, under a directory a `CDPATH` lookup may have chosen,
467
+ * the verb word is not judged as a path: git never opens its verb.
468
+ */
469
+ export declare function gitVerbIndex(args: readonly string[]): number | null;
470
+ /**
471
+ * The words of a segment the credential rule reads as paths (APRV-522), for
472
+ * the pure classifier and the hook's disk pass alike: one judgement, so the two
473
+ * cannot disagree about which words a reader opens.
474
+ *
475
+ * Empty for a binary that only WRITES what it names
476
+ * ({@link credentialWritesOnly}). Otherwise:
477
+ *
478
+ * - every positional (the words that are not flags, a flag's separate value
479
+ * included);
480
+ * - the value of every `--name=value` flag and of every short flag carrying
481
+ * its value attached (`-fFILE`), because a reader's file operand may arrive
482
+ * inside its own option (M6);
483
+ * - the file half of an upload spelling, `@FILE` and `name=@FILE;type=…`, and
484
+ * `name=<FILE`, which curl reads the same way;
485
+ * - for git, the path half of a `<rev>:<path>` or `:<path>` word, pathspec
486
+ * magic (`:(top)`, `:/`) stripped, because show and cat-file print the blob
487
+ * such a word names.
488
+ *
489
+ * Over-reading a word costs an approval prompt when it happens to spell a
490
+ * credential path; under-reading one costs the property.
491
+ */
492
+ export declare function credentialReadWords(basename: string, args: readonly string[]): string[];
387
493
  interface LexWord {
388
494
  /** Literal text, quotes removed and backslash escapes applied. */
389
495
  text: string;
@@ -391,6 +497,36 @@ interface LexWord {
391
497
  quoted: boolean;
392
498
  /** Inner text of each `$(…)` in this word, for recursive classification. */
393
499
  substitutions: string[];
500
+ /**
501
+ * An UNQUOTED variable or `$(…)` is part of the word, so the shell may split
502
+ * its value into several words (APRV-511 fix round 3, S(open)).
503
+ */
504
+ splits: boolean;
505
+ /**
506
+ * An UNQUOTED `{`, `*`, `?` or `[` is part of the word, so brace expansion or
507
+ * a glob may make it several words or change its head (fix round 3).
508
+ */
509
+ expands: boolean;
510
+ /**
511
+ * Is `text` exactly the bytes the shell would hand the command for this
512
+ * word? (APRV-510.) `false` when the shell would still change it: a `$`
513
+ * outside single quotes (a parameter expansion), an unquoted glob or brace
514
+ * character, a leading unquoted `~`, or a backslash inside double quotes
515
+ * before a character the shell keeps it for (this lexer drops every such
516
+ * backslash, the shell drops only five). Read by the `exec.code` shape alone,
517
+ * which hashes a word and so needs the word's bytes to be the program's
518
+ * bytes; every other reader keeps reading `text` exactly as before.
519
+ */
520
+ exact: boolean;
521
+ /**
522
+ * Is this word an assignment to bash (APRV-523)? bash decides that on the
523
+ * word as WRITTEN, before quote removal: the name, an optional subscript, the
524
+ * `+` and the `=` must carry no quoting or escaping character, and the name
525
+ * no `$`, backtick or brace. {@link ASSIGNMENT} applied to the source
526
+ * spelling, not to `text`: a word whose name is quoted or escaped has the
527
+ * same `text` as an assignment and is a command word, the program bash runs.
528
+ */
529
+ assignment: boolean;
394
530
  }
395
531
  interface LexRedirect {
396
532
  /** `>` and `>>` write; `<` reads. */
@@ -401,9 +537,48 @@ interface LexSegment {
401
537
  text: string;
402
538
  words: LexWord[];
403
539
  redirects: LexRedirect[];
540
+ /**
541
+ * The `$(…)` found in the bodies of unquoted heredocs consumed on this
542
+ * segment's line (APRV-504, folded into APRV-508). The shell expands them
543
+ * when the body is read, so they are classified like any other.
544
+ */
545
+ bodySubstitutions: string[];
404
546
  /** Set when the segment contains a construct whose effect cannot be read. */
405
547
  opaque: string | null;
548
+ /**
549
+ * Did this segment open a heredoc (`<<EOF`, `<<-EOF`)? (APRV-510.) The body
550
+ * is skipped and never classified, and an unquoted body still expands
551
+ * (APRV-504), so the `exec.code` shape declines a segment carrying one.
552
+ */
553
+ heredoc: boolean;
554
+ /**
555
+ * Does this segment's stdin come from somewhere the text names as input:
556
+ * the previous segment of a pipeline (`a | b`, `a |& b`), or a descriptor
557
+ * duplicated onto it (`<&3`)? (APRV-510, security review round 2.) A program
558
+ * can read its real source from stdin (`echo '<src>' | python3 -c
559
+ * 'exec(sys.stdin.read())'`), outside the hash, so the `exec.code` shape
560
+ * declines a segment fed this way.
561
+ */
562
+ stdinFed: boolean;
563
+ /**
564
+ * Is this segment inside a subshell `( … )`? (APRV-510, refuter rS2.) A
565
+ * redirection or heredoc written after the closing `)` feeds the stdin of
566
+ * every command inside it (`(python3 -c '…') < evil.py`), and lands on a
567
+ * segment of its own, so `stdinFed`, `heredoc` and `redirects` never reach
568
+ * the program's segment. The `exec.code` shape declines any segment inside a
569
+ * subshell at all, the stricter form of "a subshell carrying a redirection".
570
+ * A depth that goes negative (an unmatched `)`, as in a `case` pattern)
571
+ * counts as inside.
572
+ */
573
+ subshell: boolean;
406
574
  }
575
+ /**
576
+ * What a `$(…)` leaves in a word's text (APRV-511 refuter M1): an unexpanded
577
+ * variable, which is exactly what the shell's substitution is to a classifier
578
+ * that does not run it. The name cannot collide with a real expansion a
579
+ * command would carry, and it is braced so no brace-expansion reading applies.
580
+ */
581
+ export declare const SUBSTITUTION_SENTINEL = "${__APPROVAL_SUBST__}";
407
582
  /**
408
583
  * Facts about the machine the command will run on, resolved by the CALLER
409
584
  * (APRV-267).
@@ -465,6 +640,32 @@ export interface ClassifierContext {
465
640
  * here, forgetting it cannot loosen anything.
466
641
  */
467
642
  unwrapShell?: boolean;
643
+ /**
644
+ * A variable git could inherit that the command line's own shell sets
645
+ * (APRV-521, {@link gitEnvironmentWrite}). {@link classifyCommand} fills it
646
+ * from its own segments and hands it down to a substitution and an
647
+ * unwrapped script, which run in a shell that inherits it; every `git`
648
+ * segment under it is `unclassified`. A caller that sets it only refuses
649
+ * more.
650
+ */
651
+ shellEnvWrite?: string;
652
+ /**
653
+ * May an opaque PROGRAM be classified `exec.code` rather than refused
654
+ * (APRV-510)?
655
+ *
656
+ * `false` by default, and the default is today's answer exactly: `python3 -c`,
657
+ * `node -e`, `perl -e`, `ruby -e` and `deno eval` stay `opaque`, `bun -e`,
658
+ * `python3 <file>` and `bun <file>.ts` stay `unclassified`. `src/cli/hook.ts`
659
+ * passes `true` only when the loaded policy declares
660
+ * `exec.code.custody: attested`, the operator's statement that the agent's
661
+ * code cannot reach credentials it should not hold. Like `scratchRoots`, a
662
+ * caller that forgets it gets the stricter answer: a refusal.
663
+ *
664
+ * It opens a SHAPE and nothing wider ({@link execCodeShape}): `bash -c`,
665
+ * `sh -c`, `eval`, a pipe into an interpreter and `python3 -` (stdin) are
666
+ * refused whatever this says.
667
+ */
668
+ execCode?: boolean;
468
669
  }
469
670
  /** Everything a refinement needs: the binary and the words that followed it. */
470
671
  interface RuleContext {
@@ -490,6 +691,12 @@ interface RuleContext {
490
691
  * `PYTHONPATH`, `PYTHONSTARTUP`, `HOME`).
491
692
  */
492
693
  assigned: boolean;
694
+ /**
695
+ * Those assignments, verbatim (APRV-517). `git push` reads them: a `GIT_*`
696
+ * variable or `HOME` decides which configuration the push runs under, and
697
+ * configuration can carry a deleting refspec.
698
+ */
699
+ assignments: readonly string[];
493
700
  /** What the caller knows about the machine (APRV-267). Never read from here. */
494
701
  context: ClassifierContext;
495
702
  }
@@ -552,6 +759,27 @@ export interface CommandRule {
552
759
  * than being refined into some other refusal.
553
760
  */
554
761
  skillScript?: boolean;
762
+ /**
763
+ * Match only an argv this predicate accepts (APRV-508).
764
+ *
765
+ * The general form of `probe` and `skillScript`: the row is a SHAPE, and an
766
+ * argv outside it does not match the row at all, so the segment keeps the
767
+ * answer it had before the row existed (`unclassified`, for a binary no other
768
+ * row names). Before the predicate is asked, a shape row is declined by a
769
+ * substituted word (its text is a hole) and by a binary spelled with a path
770
+ * outside the system directories ({@link isSystemBinWord}: `./date` is
771
+ * whatever program sits in the working directory). `assignments` are the
772
+ * `NAME=value` words in front of the binary, verbatim.
773
+ */
774
+ shape?: (args: readonly string[], assignments: readonly string[]) => boolean;
775
+ /**
776
+ * Match only an argv in the `exec.code` shape, and only when the caller
777
+ * opened the class (APRV-510, {@link ClassifierContext.execCode},
778
+ * {@link execCodeShape}). The `probe`, `skillScript` and `shape` device again:
779
+ * an argv outside the shape does not match the row at all, so it keeps the
780
+ * answer it has today.
781
+ */
782
+ execCode?: boolean;
555
783
  }
556
784
  /**
557
785
  * The suffix that marks a Muse model as training on what it is shown.
@@ -602,6 +830,57 @@ export declare function isSkillScriptPath(word: string): boolean;
602
830
  * `classifySegment`, which runs after the table and can only tighten.
603
831
  */
604
832
  export declare function skillScriptArg(bin: string, args: readonly string[]): string | null;
833
+ /**
834
+ * The class an opaque program takes once a policy opens it: an inline program
835
+ * or an interpreter run of a file the gate cannot read as a skill script.
836
+ * Under `exec.*` (SPEC.md §7) beside `exec.local`, and deliberately not under
837
+ * it: `exec.local` is code in the workspace the runtime may name, this is code
838
+ * whose effect nothing here reads. A record of it says the program EXISTED
839
+ * (its hash and length), never what it did: every tool call the program then
840
+ * makes through the harness is its own hook event, and a shell command it
841
+ * spawns is not seen at all.
842
+ */
843
+ export declare const EXEC_CODE_CLASS = "exec.code";
844
+ /** The table row that matches the `exec.code` shape. */
845
+ export declare const EXEC_CODE_ROW = "exec-code";
846
+ /** The rule id of an inline program (`python3 -c`, `node -e`, ...). */
847
+ export declare const EXEC_CODE_INLINE_RULE = "exec-code-inline";
848
+ /** The rule id of an interpreter running a file (`python3 x.py`, `bun x.ts`). */
849
+ export declare const EXEC_CODE_FILE_RULE = "exec-code-file";
850
+ /** Where an `exec.code` argv carries its program (an index into the args). */
851
+ export interface ExecCodeShape {
852
+ mode: ExecCodeMode;
853
+ /** Index into the args (the words after the binary) of the source word. */
854
+ index: number;
855
+ }
856
+ /** A word as {@link execCodeShape} needs it: its text and whether the shell would change it. */
857
+ interface ExecShapeWord {
858
+ exact: boolean;
859
+ substitutions: readonly string[];
860
+ }
861
+ /**
862
+ * The `exec.code` shape of an interpreter argv, or `null` (APRV-510).
863
+ *
864
+ * An ALLOWLIST, like {@link skillScriptArg}, and the same flags are inert here:
865
+ *
866
+ * - inline: `[inert flags…] <inline flag> <program>`, and NOTHING after the
867
+ * program, for every interpreter. A word after it is handed to the program
868
+ * (`sys.argv`, `process.argv`, `ARGV`), and a one-line loader
869
+ * (`python3 -c 'import sys; exec(sys.argv[1])' '<real program>'`) would put
870
+ * the real program there, outside the hash and inside the argv the payload
871
+ * carries verbatim (security review of APRV-510, finding 3a). `deno` is
872
+ * exactly `deno eval <program>`;
873
+ * - file (python, python3 and bun only): `[inert flags…] <path> [args…]`,
874
+ * where the path is not a skill script (those are `exec.local`, APRV-502)
875
+ * and, for `bun`, carries a script extension.
876
+ *
877
+ * Anything else is not the shape: a value-taking flag, `--flag=value`, `-m`,
878
+ * a lone `-` (stdin), an inline flag with no program, two programs, a cluster
879
+ * hiding the inline flag (`-Ic`), and, when `words` is given, a source word the
880
+ * shell would still change (`LexWord.exact`) or one built from a substitution:
881
+ * the hash binds the program's bytes, so the word must BE them.
882
+ */
883
+ export declare function execCodeShape(bin: string, args: readonly string[], words?: readonly ExecShapeWord[] | null): ExecCodeShape | null;
605
884
  /**
606
885
  * The table.
607
886
  *
@@ -641,7 +920,16 @@ export declare const COMMAND_RULES: readonly CommandRule[];
641
920
  * - a substitution in any of the three words, whose effect happens before the
642
921
  * shell even starts;
643
922
  * - a nested shell inside the script, refused by {@link ClassifierContext} when
644
- * the recursion runs (the inner classification unwraps nothing).
923
+ * the recursion runs (the inner classification unwraps nothing);
924
+ * - a shell word that is not the system's binary ({@link isSystemBinWord}): a
925
+ * relative path, or an absolute one outside the system-bin directories, is
926
+ * whatever program sits there, which its last path segment does not name
927
+ * (APRV-523 fix round, review-b S1);
928
+ * - a shell word that is assignment-shaped, in its source spelling
929
+ * ({@link LexWord.assignment}) or its dequoted text ({@link ASSIGNMENT}).
930
+ * bash runs a word whose name is quoted or escaped as a program named by the
931
+ * whole word, and {@link classifySegment} refuses that segment; unwrapping
932
+ * it here would answer before that check runs.
645
933
  *
646
934
  * The BINDING does not move. `cli/hook.ts` binds the outer command and argv
647
935
  * exactly as APRV-362 built them; what this changes is only which text is
@@ -649,6 +937,59 @@ export declare const COMMAND_RULES: readonly CommandRule[];
649
937
  * bound elsewhere and unchanged.
650
938
  */
651
939
  export declare function loginShellScript(segment: LexSegment): string | null;
940
+ /**
941
+ * How the shell may still change one argv word before the program sees it
942
+ * (APRV-511 fix round 3, S(open)): `splits`, an unquoted variable or `$(…)` is
943
+ * in it; `expands`, an unquoted brace or glob character is. A caller that
944
+ * cannot say passes neither, and only the text is read.
945
+ */
946
+ export interface ArgExpansion {
947
+ splits: boolean;
948
+ expands: boolean;
949
+ }
950
+ /**
951
+ * The first word of a `cp`, `mv`, `ln` or `install` argv that makes its write
952
+ * targets unknowable (APRV-511 fix round 3, S(open)), or `null`. GNU permutes
953
+ * options, so every word before `--` is in an option slot.
954
+ */
955
+ export declare function copyArgvUnknown(args: readonly string[], flags?: readonly ArgExpansion[]): string | null;
956
+ /**
957
+ * The destination directories a `cp`, `mv`, `ln` or `install` argv names by
958
+ * flag (APRV-511 refuter S3, recheck S(open)): `-t DIR`, `-tDIR`, `-t` anywhere
959
+ * in a short cluster (`-vtDIR`, `-rt DIR`, `-ftDIR`, `-stDIR`), and the long
960
+ * option by any unique prefix GNU accepts (`--target-directory=DIR`,
961
+ * `--target-dir=DIR`, `--t DIR`). A cluster is read from its first `t` even
962
+ * where an earlier letter would have taken the rest as its own value: one more
963
+ * target judged can only make the answer stricter. Words after `--` are
964
+ * operands. An absent value is not returned (the operand scan judges the rest).
965
+ */
966
+ export declare function copyTargetDirectories(args: readonly string[]): string[];
967
+ /**
968
+ * Where `curl` and `wget` write (APRV-511 refuter S1 and review): `files`,
969
+ * paths written by name (`curl -o F`, `-sSLoF`, `--output F`, `--outp=F`,
970
+ * `-D`, `-c`, `--trace`, `--stderr`; `wget -O F`, `--output-doc=F`, `-o`/`-a`
971
+ * logs), and `directories`, directories written with a name the server
972
+ * chooses (`curl -O` into `--output-dir` or `.`; `wget` without `-O` into
973
+ * `-P`/`--directory-p…` or `.`). `unknown` names the first option that makes
974
+ * the outputs unknowable: one this table does not know, an ambiguous prefix,
975
+ * or a config source (`curl -K`/`--config`, `wget -e`/`--execute`/`--config`).
976
+ * `-` is standard output and writes nothing. Any other binary writes nothing.
977
+ *
978
+ * Fix round 4 (M(L5f-1)): `sources` are the files an upload or request-body
979
+ * option READS ({@link uploadSourcePaths}), in every spelling: separated
980
+ * (`-T F`, `--data @F`), joined by `=` (`--upload-file=F`, `--data=@F`) and
981
+ * glued (`-TF`, `-sd@F`, `-Ff=@F`). `sourceUnknown` is the first option word
982
+ * whose EMBEDDED value names a file this text cannot read (a variable, a
983
+ * substitution, a glob or a brace in it). A separated value is a word of its
984
+ * own and is judged as the positional it also is.
985
+ */
986
+ export declare function fetchOutputs(bin: string, args: readonly string[], flags?: readonly ArgExpansion[]): {
987
+ files: string[];
988
+ directories: string[];
989
+ unknown: string | null;
990
+ sources: string[];
991
+ sourceUnknown: string | null;
992
+ };
652
993
  /**
653
994
  * The rules whose commands RUN CODE THIS RUNTIME DID NOT AUTHOR (APRV-193).
654
995
  *
@@ -796,9 +1137,35 @@ export interface CommandSegmentShape {
796
1137
  text: string;
797
1138
  bin: string | null;
798
1139
  args: string[];
1140
+ /** What the shell may still do to each of `args` (APRV-511 fix round 3). */
1141
+ argFlags: ArgExpansion[];
799
1142
  writes: string[];
800
1143
  /** Paths read through `<` (APRV-445 recheck 3, SF3c). */
801
1144
  reads: string[];
1145
+ /** Is this segment's stdin the previous segment's output? (APRV-522.) */
1146
+ stdinFed: boolean;
1147
+ /**
1148
+ * The inner text of every `$(…)` this segment expands: in its words, its
1149
+ * redirection targets and an unquoted heredoc's body (APRV-522), so the
1150
+ * hook's disk pass judges the reads a substitution makes.
1151
+ */
1152
+ substitutions: string[];
1153
+ /**
1154
+ * The `NAME=value` words before the binary (APRV-522 fix round 1, M-B): a
1155
+ * prefix such as `CDPATH=` or `HOME=` steers where a `cd` lands.
1156
+ */
1157
+ assignments: string[];
1158
+ /**
1159
+ * The script of a login-shell wrapper, exactly as {@link loginShellScript}
1160
+ * reads it for `classifyCommand` (APRV-522 fix round 1, M-A), or `null`.
1161
+ */
1162
+ script: string | null;
1163
+ /**
1164
+ * A known shell handed an inline-script flag in a shape
1165
+ * {@link loginShellScript} does not unwrap (APRV-522 fix round 1, M-A): what
1166
+ * its script reads cannot be judged, so the hook's disk pass fails closed.
1167
+ */
1168
+ opaqueShell: boolean;
802
1169
  }
803
1170
  export declare function commandSegmentShapes(command: string): CommandSegmentShape[] | null;
804
1171
  export declare function commandSegmentWords(command: string): CommandSegmentWords[] | null;