@bridge4dev/runner 0.68.1 → 0.69.0
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/adapters/claude.js +243 -10
- package/dist/adapters/codex.js +166 -3
- package/dist/adapters/error-policy.d.ts +17 -0
- package/dist/adapters/error-policy.js +74 -3
- package/dist/adapters/types.d.ts +12 -0
- package/dist/policy.d.ts +39 -0
- package/dist/policy.js +523 -8
- package/dist/protocol.d.ts +9 -2
- package/dist/regex-guard.js +84 -3
- package/dist/supervisor.d.ts +15 -0
- package/dist/supervisor.js +52 -6
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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
|
-
|
|
971
|
-
|
|
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
|
-
|
|
1074
|
-
|
|
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
|
-
|
|
1238
|
-
|
|
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
|
package/dist/protocol.d.ts
CHANGED
|
@@ -1633,8 +1633,15 @@ export type RunnerFrame = {
|
|
|
1633
1633
|
baseBranch?: string;
|
|
1634
1634
|
baseSha?: string;
|
|
1635
1635
|
errorMessage?: string;
|
|
1636
|
-
/**
|
|
1637
|
-
|
|
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
|
/**
|