@bridge4dev/runner 0.68.1 → 0.69.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/dist/policy.js CHANGED
@@ -121,6 +121,50 @@ const SECRET_PATH_PATTERNS = [
121
121
  // for. Matching the distinctive `checkpoints/<hash>.git` shape closes it
122
122
  // wherever the state directory ends up.
123
123
  /(^|\/)checkpoints\/[0-9a-f]{8,}\.git(\/|$)/,
124
+ /**
125
+ * Ticket #445 — the session journals of the agents themselves.
126
+ *
127
+ * Everything an agent ever printed is in these files, and that now includes
128
+ * secrets the PLATFORM handed it: the ten-minute key
129
+ * `devbridge_library_download` prints is masked in the session feed and is
130
+ * plain text here. On a machine where a project may work outside its own
131
+ * folder (ADR 0008), the neighbouring project's agent reads them as ordinary
132
+ * files.
133
+ *
134
+ * **Only the journals, never the directory, and the difference is the whole
135
+ * rule.** Measured on this machine on 2026-09-19: `/root/.claude/projects/`
136
+ * holds 35 project directories, 1120 `.jsonl` files — and, inside the same
137
+ * directories, the `memory/` folders where every agent on the fleet keeps its
138
+ * own notes, `-opt-devbridge` among them. A rule on the directory would take
139
+ * that memory away from every session on every machine after one «Update
140
+ * all», in both directions, because `normalize` resolves symlinks and the
141
+ * account homes point straight here. The runner-directory rule two entries up
142
+ * carries the same warning for the same reason, and there too the exceptions
143
+ * are not optional.
144
+ *
145
+ * `.jsonl` is what draws the line: journals are `.jsonl`, memory is `.md`.
146
+ * Nested transcripts (`<session>/subagents/agent-*.jsonl`, written by the
147
+ * workflow runner) are covered by the same rule.
148
+ */
149
+ //
150
+ // **Directory-shaped, not `.jsonl`-shaped, and that took an independent QA
151
+ // to see.** A rule anchored on the file extension cannot match a DIRECTORY,
152
+ // and `Grep {pattern: 'dbk_', path: '…/projects', output_mode: 'content'}`
153
+ // hands back the contents of every journal under it in one call — the exact
154
+ // shape this file was already bitten by once for the runner directory
155
+ // (QA-114 MAJOR-2, two entries above). So the directory is refused and
156
+ // `<project>/memory` is carved back out by name, the same way the command
157
+ // form below does it.
158
+ /(^|\/)\.claude\/projects(\/|$)(?![^/]+\/memory(\/|$))/,
159
+ // The same journals reached through an account home, for the case
160
+ // `normalize` cannot resolve (a path that is not on this disk).
161
+ /(^|\/)claude-homes\/[^/]+\/projects(\/|$)(?![^/]+\/memory(\/|$))/,
162
+ // Codex writes one file per turn under its own home. Inside the runner
163
+ // directory this is already refused; these two entries hold where that path
164
+ // does not spell `devbridge-runner` — a `DEVBRIDGE_RUNNER_HOME` override, and
165
+ // the host's own `~/.codex` where `history.jsonl` keeps every prompt typed.
166
+ /(^|\/)sessions\/\d{4}\/\d{2}\/\d{2}\/rollout-[^/]*\.jsonl$/,
167
+ /(^|\/)\.codex\/(sessions(\/|$)|history\.jsonl$)/,
124
168
  /(^|\/)(shadow|passwd|sudoers)$/,
125
169
  /(^|\/)\.netrc$/,
126
170
  /(^|\/)\.git-credentials$/,
@@ -702,6 +746,431 @@ function parseCheckout(segment) {
702
746
  }
703
747
  return null;
704
748
  }
749
+ // ─── Flags and config keys that name a PROGRAM git will run (#443, A-1) ──────
750
+ //
751
+ // `gitRunsAProgram` below is the narrow, context-free half of the git rules,
752
+ // exported for the `PreToolUse` guard (#399). Only this half: the guard runs in
753
+ // «Полный доступ», where the project's own git settings — the push ban, the
754
+ // protected branches, `git clean` — are deliberately not enforced, and dragging
755
+ // them in would refuse work that mode exists to allow. «Which program runs on
756
+ // somebody else's machine» is not one of those settings and never was.
757
+ /**
758
+ * Git's main-level options that swallow the next word, so the scan for the
759
+ * subcommand steps over their value instead of reading it as one.
760
+ *
761
+ * `-c` and `-C` also accept the value glued on (`-cfoo=bar`, `-C/tmp`), which
762
+ * the length check below handles; everything long here takes `=` or a word.
763
+ */
764
+ const GIT_MAIN_VALUE_OPTS = new Set([
765
+ '-c',
766
+ '-C',
767
+ '--config-env',
768
+ '--git-dir',
769
+ '--work-tree',
770
+ '--namespace',
771
+ '--super-prefix',
772
+ '--attr-source',
773
+ ]);
774
+ /**
775
+ * The subcommand of a `git …` segment, or null when it cannot be read.
776
+ *
777
+ * Null is not «harmless»: every caller below treats an unreadable subcommand as
778
+ * one that accepts the flag, for the same reason `parsePush` reports the most
779
+ * dangerous reading — refusing what cannot be understood is the only safe
780
+ * direction here.
781
+ */
782
+ function gitSubcommand(tokens, gitAt) {
783
+ let i = gitAt + 1;
784
+ while (i < tokens.length) {
785
+ const token = tokens[i] ?? '';
786
+ if (!token.startsWith('-'))
787
+ return token;
788
+ // `--git-dir=/x`, `-c a=b` glued as `-ca=b`: the value travels with the flag.
789
+ if (token.includes('=') ||
790
+ ((token.startsWith('-c') || token.startsWith('-C')) && token.length > 2)) {
791
+ i += 1;
792
+ continue;
793
+ }
794
+ i += GIT_MAIN_VALUE_OPTS.has(token) ? 2 : 1;
795
+ }
796
+ return null;
797
+ }
798
+ /**
799
+ * Flags whose value is a command line git executes, and the subcommands that
800
+ * accept them — verified against git 2.43.0 on 2026-09-19, by running each one
801
+ * against a local repository and looking for the marker file it left behind.
802
+ *
803
+ * The subcommand list is not decoration, and it is not «which ones are
804
+ * dangerous» either: every one of these words is an ordinary search term. A
805
+ * rule that looked for `--upload-pack` anywhere would refuse
806
+ * `git log -S '--upload-pack'` — which is the command somebody investigating
807
+ * THIS hole would type first. So the flag counts only where git would act on
808
+ * it; where git answers «unknown option», so does this table, by silence.
809
+ *
810
+ * `--exec` is absent from `fetch`, `pull` and `clone` on purpose: git rejects
811
+ * it there («unknown option `exec=X'»). It is absent from `rebase` for the
812
+ * opposite reason — there it is a legitimate local flag, and a blanket ban
813
+ * would take away a card the person can approve today (`git rebase --exec
814
+ * 'pnpm test' main`). `git bisect run` and `git submodule foreach` take a
815
+ * command as an OPERAND, not as a flag, and are equally out of scope here.
816
+ */
817
+ const PROGRAM_FLAGS = [
818
+ {
819
+ flag: '--upload-pack',
820
+ on: new Set(['fetch', 'pull', 'clone', 'ls-remote']),
821
+ why: 'runs the command you name instead of `git-upload-pack` — against a local path that is simply your command, run here',
822
+ },
823
+ {
824
+ flag: '--exec',
825
+ on: new Set(['ls-remote', 'archive', 'push', 'send-pack', 'upload-archive']),
826
+ why: 'is the same switch as `--upload-pack` / `--receive-pack` under another name, and names the program to run',
827
+ },
828
+ {
829
+ flag: '--receive-pack',
830
+ on: new Set(['push', 'send-pack']),
831
+ why: 'runs the command you name on the receiving side instead of `git-receive-pack`',
832
+ },
833
+ {
834
+ flag: '--template',
835
+ on: new Set(['clone', 'init']),
836
+ why: 'seeds the new repository from a directory of hooks, and git runs those hooks at the end of the clone',
837
+ },
838
+ {
839
+ flag: '--open-files-in-pager',
840
+ on: new Set(['grep']),
841
+ why: 'runs the program you name over the files that matched',
842
+ },
843
+ // Raised by the independent QA of this stage: the same family, found by
844
+ // running git rather than by reading the ticket. Neither was a silent path
845
+ // (both already asked under the default trust), but the rule is «a flag that
846
+ // names a program is refused», and a list that stops one word short of true
847
+ // is the kind of list somebody later trusts.
848
+ {
849
+ flag: '--extcmd',
850
+ on: new Set(['difftool', 'mergetool']),
851
+ why: 'runs the program you name in place of the configured diff or merge tool',
852
+ },
853
+ {
854
+ flag: '--tree-filter',
855
+ on: new Set(['filter-branch']),
856
+ why: 'runs the command you name over every commit in the history',
857
+ },
858
+ {
859
+ flag: '--index-filter',
860
+ on: new Set(['filter-branch']),
861
+ why: 'runs the command you name over every commit in the history',
862
+ },
863
+ {
864
+ flag: '--env-filter',
865
+ on: new Set(['filter-branch']),
866
+ why: 'runs the command you name over every commit in the history',
867
+ },
868
+ {
869
+ flag: '--parent-filter',
870
+ on: new Set(['filter-branch']),
871
+ why: 'runs the command you name over every commit in the history',
872
+ },
873
+ {
874
+ flag: '--msg-filter',
875
+ on: new Set(['filter-branch']),
876
+ why: 'runs the command you name over every commit in the history',
877
+ },
878
+ {
879
+ flag: '--commit-filter',
880
+ on: new Set(['filter-branch']),
881
+ why: 'runs the command you name over every commit in the history',
882
+ },
883
+ {
884
+ flag: '--tag-name-filter',
885
+ on: new Set(['filter-branch']),
886
+ why: 'runs the command you name over every tag in the history',
887
+ },
888
+ ];
889
+ /**
890
+ * Config settings whose VALUE git runs as a program.
891
+ *
892
+ * Exact names first, then the families where a name sits in the middle
893
+ * (`filter.<name>.smudge`), then a suffix backstop so a setting added by a
894
+ * later git — `something.command`, `something.helper` — is refused before
895
+ * anybody notices it exists.
896
+ *
897
+ * **Decided on the KEY alone, never on the value**, and that is deliberate:
898
+ * `--config-env=core.sshCommand=VAR` takes its value out of the environment,
899
+ * where nothing here can see it. A rule that had to read the value would be
900
+ * exactly the rule that form defeats.
901
+ *
902
+ * Proven to execute on git 2.43.0 on 2026-09-19 (marker files, local repo):
903
+ * `core.sshCommand`, `core.pager`, `core.hooksPath`, `core.fsmonitor`,
904
+ * `init.templateDir`, `diff.external`, `filter.<n>.smudge`,
905
+ * `remote.<n>.uploadpack`, `protocol.ext.allow` (with an `ext::` URL) and
906
+ * `alias.<n>` with a leading `!`. The rest are the same mechanism reached
907
+ * through a condition this probe did not stage (a credential prompt, a server
908
+ * side, an editor) and are listed for the same reason the table in
909
+ * `error-policy.ts` lists causes it never retries: an absence reads as an
910
+ * oversight.
911
+ */
912
+ const EXECUTABLE_CONFIG_KEYS = new Set([
913
+ 'core.sshcommand',
914
+ 'core.pager',
915
+ 'core.editor',
916
+ 'core.askpass',
917
+ 'core.hookspath',
918
+ 'core.fsmonitor',
919
+ 'core.gitproxy',
920
+ 'init.templatedir',
921
+ 'sequence.editor',
922
+ 'diff.external',
923
+ 'gpg.program',
924
+ 'gpg.openpgp.program',
925
+ 'gpg.x509.program',
926
+ 'gpg.ssh.program',
927
+ 'uploadpack.packobjectshook',
928
+ 'credential.helper',
929
+ 'protocol.allow',
930
+ 'include.path',
931
+ 'instaweb.httpd',
932
+ 'man.viewer',
933
+ 'web.browser',
934
+ ]);
935
+ /** `<head>.<any name>.<tail>` — the families where the middle word is free. */
936
+ const EXECUTABLE_CONFIG_FAMILIES = new Map([
937
+ ['filter', new Set(['clean', 'smudge', 'process'])],
938
+ ['diff', new Set(['command', 'textconv'])],
939
+ ['merge', new Set(['driver'])],
940
+ ['mergetool', new Set(['cmd', 'path'])],
941
+ ['difftool', new Set(['cmd', 'path'])],
942
+ ['guitool', new Set(['cmd'])],
943
+ ['browser', new Set(['cmd', 'path'])],
944
+ ['man', new Set(['cmd', 'path'])],
945
+ ['trailer', new Set(['command', 'cmd'])],
946
+ ['remote', new Set(['uploadpack', 'receivepack', 'proxy'])],
947
+ ['credential', new Set(['helper'])],
948
+ ['submodule', new Set(['update'])],
949
+ ['imap', new Set(['tunnel'])],
950
+ ['sendemail', new Set(['smtpserver'])],
951
+ ['protocol', new Set(['allow'])],
952
+ ['includeif', new Set(['path'])],
953
+ ]);
954
+ /**
955
+ * The shape of a setting that runs something, for keys no list here names yet.
956
+ *
957
+ * `path` is NOT in it, and leaving it out is the whole difference between a
958
+ * backstop and a nuisance: `submodule.<name>.path` is a directory, while
959
+ * `mergetool.<name>.path` is a program — so `path` is listed per family above
960
+ * and never as a shape.
961
+ */
962
+ const EXECUTABLE_CONFIG_SUFFIXES = [
963
+ 'command',
964
+ 'cmd',
965
+ 'program',
966
+ 'helper',
967
+ 'driver',
968
+ 'editor',
969
+ 'pager',
970
+ 'external',
971
+ 'textconv',
972
+ 'askpass',
973
+ 'hook',
974
+ 'hookspath',
975
+ 'tunnel',
976
+ ];
977
+ function configKeyRunsProgram(rawKey) {
978
+ const key = rawKey.toLowerCase();
979
+ if (key === '')
980
+ return false;
981
+ if (EXECUTABLE_CONFIG_KEYS.has(key))
982
+ return true;
983
+ // Setting an alias from the command line is refused whatever its value: an
984
+ // alias beginning with `!` is a shell command, and with `--config-env` the
985
+ // value is in the environment where this cannot look at it.
986
+ if (key.startsWith('alias.'))
987
+ return true;
988
+ // `pager.<git command>` is the pager for that command — a program, always.
989
+ if (key.startsWith('pager.'))
990
+ return true;
991
+ const parts = key.split('.');
992
+ const head = parts[0] ?? '';
993
+ const tail = parts[parts.length - 1] ?? '';
994
+ if (parts.length >= 3 && EXECUTABLE_CONFIG_FAMILIES.get(head)?.has(tail) === true)
995
+ return true;
996
+ return EXECUTABLE_CONFIG_SUFFIXES.includes(tail);
997
+ }
998
+ /** The key out of `-c key=value`, `--config-env=key=VAR`, `--config key=value`. */
999
+ function configKeyFromToken(token, next) {
1000
+ const eq = token.indexOf('=');
1001
+ if (token === '-c' || token === '--config' || token === '--config-env') {
1002
+ return next === undefined ? null : (next.split('=')[0] ?? null);
1003
+ }
1004
+ if (token.startsWith('-c') && !token.startsWith('--') && token.length > 2) {
1005
+ return token.slice(2).split('=')[0] ?? null;
1006
+ }
1007
+ if (eq > 0 && (token.startsWith('--config=') || token.startsWith('--config-env='))) {
1008
+ return token.slice(eq + 1).split('=')[0] ?? null;
1009
+ }
1010
+ return null;
1011
+ }
1012
+ /** `git config <key> <value>` and friends — reads and removals are not writes. */
1013
+ const CONFIG_READ_FLAGS = new Set([
1014
+ '--get',
1015
+ '--get-all',
1016
+ '--get-regexp',
1017
+ '--get-urlmatch',
1018
+ '--get-color',
1019
+ '--get-colorbool',
1020
+ '--list',
1021
+ '-l',
1022
+ '--unset',
1023
+ '--unset-all',
1024
+ '--remove-section',
1025
+ '--rename-section',
1026
+ '--edit',
1027
+ '-e',
1028
+ ]);
1029
+ /**
1030
+ * `git config` options that swallow the next word.
1031
+ *
1032
+ * Independent QA, 2026-09-19: without this the key was read as the first
1033
+ * non-dash operand, so `git config --file .git/config core.pager /tmp/x` put
1034
+ * the PATH in the key's place and the executable key one slot along was never
1035
+ * looked at — the write went through and every later git command in that
1036
+ * folder ran the program. `--type` did the same with no file option at all.
1037
+ */
1038
+ const CONFIG_VALUE_OPTS = new Set([
1039
+ '--file',
1040
+ '-f',
1041
+ '--blob',
1042
+ '--type',
1043
+ '-t',
1044
+ '--default',
1045
+ '--name-only',
1046
+ '--fixed-value',
1047
+ '--value',
1048
+ ]);
1049
+ const RUN_A_PROGRAM = 'Nothing an agent does by the book has to choose which program runs on somebody else’s machine. ' +
1050
+ 'A person who really needs this runs it from their own shell.';
1051
+ /**
1052
+ * Does this segment tell git to run a program of the caller's choosing? (#443)
1053
+ *
1054
+ * The hole this closes was open from 2026-08-30 to this release and survived
1055
+ * runner 0.59 through 0.68: `git fetch --upload-pack='touch /tmp/x' /path` is
1056
+ * on the safe list (it starts `git fetch`), carries no shell metacharacter, and
1057
+ * so ran with no permission card at all under the DEFAULT trust level. The
1058
+ * flag is not a niche one — git executes it even when the fetch itself then
1059
+ * fails with code 128.
1060
+ *
1061
+ * Three things this must not do, each of them a mistake somebody already paid
1062
+ * for on this file:
1063
+ *
1064
+ * - **no regex over the command.** `git -c` forty-four times in a row once
1065
+ * froze the runner for minutes inside a normal turn (see `GIT_PUSH`), and
1066
+ * this runs synchronously on every Bash call of every session on the
1067
+ * machine. Everything below is a single pass over `words()` and string
1068
+ * compares;
1069
+ * - **no searching for the flag as a substring.** See `PROGRAM_FLAGS`;
1070
+ * - **the refusal has to catch the form with no shell metacharacter in it.**
1071
+ * The proof-of-concept in the 2026-08-30 audit used `;`, which the
1072
+ * metacharacter filter already caught, so a test written from it would have
1073
+ * gone green against an unfixed runner.
1074
+ */
1075
+ export function gitRunsAProgram(command) {
1076
+ // Both spellings and every segment, exactly as `evaluateGitPolicy` reads
1077
+ // them — a caller outside this file must not have to know that.
1078
+ for (const variant of [command, dequote(command)]) {
1079
+ for (const segment of commandSegments(variant)) {
1080
+ const found = parseGitProgramFlag(segment);
1081
+ if (found)
1082
+ return found;
1083
+ }
1084
+ }
1085
+ return null;
1086
+ }
1087
+ function parseGitProgramFlag(segment) {
1088
+ const tokens = words(segment);
1089
+ const gitAt = tokens.findIndex((token) => token === 'git' || token.endsWith('/git'));
1090
+ if (gitAt === -1)
1091
+ return null;
1092
+ const sub = gitSubcommand(tokens, gitAt);
1093
+ for (let i = gitAt + 1; i < tokens.length; i += 1) {
1094
+ const token = tokens[i] ?? '';
1095
+ if (!token.startsWith('-'))
1096
+ continue;
1097
+ const name = token.split('=')[0] ?? token;
1098
+ // `--exec-path=<dir>` makes git look for its own subcommands in a
1099
+ // directory of your choosing, so `git --exec-path=/tmp/x fetch` runs
1100
+ // `/tmp/x/git-fetch`. Only the `=` form sets it: verified on 2.43, a bare
1101
+ // `--exec-path` (with or without a word after it) prints the path and
1102
+ // exits, which is a harmless thing to let through.
1103
+ if (token.startsWith('--exec-path=')) {
1104
+ return {
1105
+ decision: 'deny',
1106
+ reason: `\`--exec-path=\` is not allowed — it points git at a directory of your own programs and runs them in place of its own subcommands. ${RUN_A_PROGRAM}`,
1107
+ };
1108
+ }
1109
+ // `git clone -u <cmd>` is `--upload-pack` spelled short, and it really does
1110
+ // run the command (verified 2.43). On `fetch` the same letter is
1111
+ // `--update-head-ok` and harmless, which is why this is asked only of the
1112
+ // subcommand that means the dangerous one.
1113
+ if (sub === 'clone' && (token === '-u' || (token.startsWith('-u') && token.length > 2))) {
1114
+ return {
1115
+ decision: 'deny',
1116
+ reason: `\`git clone -u\` is \`--upload-pack\` — it runs the command you name instead of \`git-upload-pack\`. ${RUN_A_PROGRAM}`,
1117
+ };
1118
+ }
1119
+ // `git grep -O<pager>` opens the matches in a program of your choosing.
1120
+ if (sub === 'grep' && token.startsWith('-O') && token.length > 2) {
1121
+ return {
1122
+ decision: 'deny',
1123
+ reason: `\`git grep -O\` is \`--open-files-in-pager\` — it runs the program you name over the files that matched. ${RUN_A_PROGRAM}`,
1124
+ };
1125
+ }
1126
+ for (const rule of PROGRAM_FLAGS) {
1127
+ if (name !== rule.flag)
1128
+ continue;
1129
+ if (sub !== null && !rule.on.has(sub))
1130
+ continue;
1131
+ return {
1132
+ decision: 'deny',
1133
+ reason: `\`${rule.flag}\` is not allowed — it ${rule.why}. ${RUN_A_PROGRAM}`,
1134
+ };
1135
+ }
1136
+ const key = configKeyFromToken(token, tokens[i + 1]);
1137
+ if (key !== null && configKeyRunsProgram(key)) {
1138
+ return {
1139
+ decision: 'deny',
1140
+ reason: `setting \`${key}\` on the command line is not allowed — git runs the value of that setting as a program. ${RUN_A_PROGRAM}`,
1141
+ };
1142
+ }
1143
+ }
1144
+ // `git config <executable key> <value>` is the same hole one step removed:
1145
+ // `-c` runs the program once, this writes it into `.git/config` so every
1146
+ // later git command in the folder runs it. Reads and removals are untouched
1147
+ // — only a write with a value is refused.
1148
+ if (sub === 'config') {
1149
+ const rest = tokens.slice(tokens.indexOf('config', gitAt) + 1);
1150
+ if (!rest.some((token) => CONFIG_READ_FLAGS.has(token.split('=')[0] ?? token))) {
1151
+ // The value of a value-taking option is NOT an operand, and reading it as
1152
+ // one is what let the key hide one slot further along.
1153
+ const operands = [];
1154
+ for (let i = 0; i < rest.length; i += 1) {
1155
+ const token = rest[i] ?? '';
1156
+ if (!token.startsWith('-')) {
1157
+ operands.push(token);
1158
+ continue;
1159
+ }
1160
+ if (!token.includes('=') && CONFIG_VALUE_OPTS.has(token))
1161
+ i += 1;
1162
+ }
1163
+ const key = operands[0];
1164
+ if (key !== undefined && operands.length >= 2 && configKeyRunsProgram(key)) {
1165
+ return {
1166
+ decision: 'deny',
1167
+ reason: `\`git config ${key} …\` is not allowed — git runs the value of that setting as a program, and writing it into the config runs it for every later command in this folder. ${RUN_A_PROGRAM}`,
1168
+ };
1169
+ }
1170
+ }
1171
+ }
1172
+ return null;
1173
+ }
705
1174
  /**
706
1175
  * The project's git rules, applied to one Bash command.
707
1176
  *
@@ -723,6 +1192,18 @@ export function evaluateGitPolicy(command, ctx) {
723
1192
  const policy = resolveGitPolicy(ctx);
724
1193
  let asked = null;
725
1194
  for (const segment of commandSegments(command)) {
1195
+ /**
1196
+ * #443 — first in the loop, and with no switch above it.
1197
+ *
1198
+ * The same standing as the `--receive-pack` rule further down and for the
1199
+ * same sentence: nothing an agent legitimately does needs to choose which
1200
+ * program runs on somebody else's machine. It is not `allowDestructiveGit`
1201
+ * and it is not a project setting, because a project that trusts its agent
1202
+ * with `git clean` has said nothing about arbitrary code.
1203
+ */
1204
+ const runsProgram = parseGitProgramFlag(segment);
1205
+ if (runsProgram)
1206
+ return runsProgram;
726
1207
  if (!policy.allowDestructiveGit) {
727
1208
  /**
728
1209
  * The two git commands that destroy work nobody has committed (16).
@@ -960,6 +1441,25 @@ const SECRET_COMMAND_PATTERNS = [
960
1441
  // secret table and not the other is theatre, and `git --git-dir=…` /
961
1442
  // `cat …/checkpoints/<hash>.git/…` walks past a Read-tool guard entirely.
962
1443
  /checkpoints\/[0-9a-f]{8,}\.git/,
1444
+ /**
1445
+ * Ticket #445 in command form — and the exception in it is NOT optional.
1446
+ *
1447
+ * Written against the literal command text, so it has to answer the shapes
1448
+ * the shell has not expanded yet: one journal named outright, a glob over
1449
+ * the whole folder, and `grep -r dbk_ ~/.claude/projects`, which names no
1450
+ * file at all and would read every one of them.
1451
+ *
1452
+ * Hence the directory is refused and `<project>/memory/…` is carved back out
1453
+ * — `cat` and `grep` over an agent's own notes are ordinary work, done many
1454
+ * times a session, and the cost of getting this backwards is the whole fleet
1455
+ * losing its memory at once. The accepted cost of the other side: listing the
1456
+ * project directory itself (`ls ~/.claude/projects/-opt-devbridge/`) is
1457
+ * refused too, the same trade as `devbridge-runner doctor` above.
1458
+ */
1459
+ /\.claude\/projects(?!\/[^/\s"']+\/memory\b)/,
1460
+ /claude-homes\/[^/\s"']+\/projects(?!\/[^/\s"']+\/memory\b)/,
1461
+ /sessions\/\d{4}\/\d{2}\/\d{2}\/rollout-/,
1462
+ /(^|[\s"'/=~])\.codex\/(sessions\b|history\.jsonl)/,
963
1463
  /devbridge-runner(?=\s|$|['"])/,
964
1464
  /\/etc\/(shadow|passwd|sudoers)/,
965
1465
  /\.git-credentials/,
@@ -967,8 +1467,21 @@ const SECRET_COMMAND_PATTERNS = [
967
1467
  // Session 13 — same list as SECRET_PATH_PATTERNS, in command form.
968
1468
  /\.config\/(gh|glab-cli|hub|gcloud)\b/,
969
1469
  ];
970
- function commandMentionsSecretPath(command) {
971
- return SECRET_COMMAND_PATTERNS.some((re) => re.test(command));
1470
+ /**
1471
+ * Does this shell command name a path the agent must not touch?
1472
+ *
1473
+ * Exported for the `PreToolUse` guard (#399), which is the one check that
1474
+ * survives `bypassPermissions` — where `evaluateToolUse` below is never called
1475
+ * at all. One list, one function, two callers: a second copy of these patterns
1476
+ * would drift, and the drift would be silent.
1477
+ *
1478
+ * Both spellings are tested here rather than at the call site, so a caller
1479
+ * cannot forget the one that matters: `cat "/root/.cla""ude/…"` collapses only
1480
+ * under `dequote`, and forgetting it is how quote-splitting got past a rule
1481
+ * once already (QA-96 F8/F9).
1482
+ */
1483
+ export function commandMentionsSecretPath(command) {
1484
+ return SECRET_COMMAND_PATTERNS.some((re) => re.test(command) || re.test(dequote(command)));
972
1485
  }
973
1486
  /**
974
1487
  * Does this command name the compose project it claims to own?
@@ -1070,9 +1583,9 @@ export function evaluateRecipeCommand(command, ctx = {}) {
1070
1583
  }
1071
1584
  return { allowed: false, reason };
1072
1585
  }
1073
- if (commandMentionsSecretPath(variant)) {
1074
- return { allowed: false, reason: 'the command touches protected secret paths' };
1075
- }
1586
+ }
1587
+ if (commandMentionsSecretPath(command)) {
1588
+ return { allowed: false, reason: 'the command touches protected secret paths' };
1076
1589
  }
1077
1590
  return { allowed: true };
1078
1591
  }
@@ -1234,9 +1747,11 @@ export function evaluateToolUse(toolName, input, ctx) {
1234
1747
  if (re.test(variant))
1235
1748
  return { decision: 'deny', reason };
1236
1749
  }
1237
- if (commandMentionsSecretPath(variant)) {
1238
- return { decision: 'deny', reason: 'command touches protected secret paths' };
1239
- }
1750
+ }
1751
+ // Both spellings are tested inside the function now that the `PreToolUse`
1752
+ // guard calls it too (#399) — one caller must not be able to forget one.
1753
+ if (commandMentionsSecretPath(command)) {
1754
+ return { decision: 'deny', reason: 'command touches protected secret paths' };
1240
1755
  }
1241
1756
  // The project said the agent does not commit. Checked BEFORE the trust
1242
1757
  // modes, exactly like the deny list above it: under AUTO the Bash branch
@@ -1633,8 +1633,15 @@ export type RunnerFrame = {
1633
1633
  baseBranch?: string;
1634
1634
  baseSha?: string;
1635
1635
  errorMessage?: string;
1636
- /** Why it ended, when the reason is worth naming rather than an error. */
1637
- endReason?: 'TIME_BUDGET' | 'COST_BUDGET';
1636
+ /**
1637
+ * Why it ended, when the reason is worth naming rather than an error.
1638
+ *
1639
+ * `MODEL_REFUSAL` since 0.69.0 (#435, first item): the provider's safety
1640
+ * filter refused the answer and no fallback model took the turn. The API
1641
+ * reads this through a Zod enum with `.catch(undefined)`, so an older API
1642
+ * simply hears no reason rather than dropping the whole frame.
1643
+ */
1644
+ endReason?: 'TIME_BUDGET' | 'COST_BUDGET' | 'MODEL_REFUSAL';
1638
1645
  /** Agent-active milliseconds, cumulative across processes (session 7). */
1639
1646
  activeMs?: number;
1640
1647
  /**