@cleocode/core 2026.10.3 → 2026.10.4
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/build-provenance.json +2 -2
- package/dist/cloud/nexus-enrol.d.ts +9 -0
- package/dist/cloud/nexus-enrol.d.ts.map +1 -1
- package/dist/cloud/nexus-enrol.js +45 -2
- package/dist/cloud/nexus-enrol.js.map +1 -1
- package/dist/cloud/nexus-first-run.d.ts +142 -0
- package/dist/cloud/nexus-first-run.d.ts.map +1 -0
- package/dist/cloud/nexus-first-run.js +352 -0
- package/dist/cloud/nexus-first-run.js.map +1 -0
- package/dist/cloud/nexus-project-names.d.ts +173 -0
- package/dist/cloud/nexus-project-names.d.ts.map +1 -0
- package/dist/cloud/nexus-project-names.js +314 -0
- package/dist/cloud/nexus-project-names.js.map +1 -0
- package/dist/cloud/nexus-vault-keys.d.ts +33 -2
- package/dist/cloud/nexus-vault-keys.d.ts.map +1 -1
- package/dist/cloud/nexus-vault-keys.js +132 -12
- package/dist/cloud/nexus-vault-keys.js.map +1 -1
- package/dist/dispatch/contracts/derive-output-contract.d.ts +1 -1
- package/dist/dispatch/contracts/derive-output-contract.d.ts.map +1 -1
- package/dist/dispatch/contracts/derive-output-contract.js +2 -1
- package/dist/dispatch/contracts/derive-output-contract.js.map +1 -1
- package/dist/dispatch/contracts/output-contracts.d.ts +2 -1
- package/dist/dispatch/contracts/output-contracts.d.ts.map +1 -1
- package/dist/dispatch/contracts/output-contracts.js +1 -1
- package/dist/dispatch/contracts/output-contracts.js.map +1 -1
- package/dist/dispatch/mvi-projection.d.ts +1 -1
- package/dist/dispatch/mvi-projection.d.ts.map +1 -1
- package/dist/dispatch/mvi-projection.js +1 -1
- package/dist/dispatch/mvi-projection.js.map +1 -1
- package/dist/docs/export-document.js +24462 -24572
- package/dist/docs/export-document.js.map +3 -3
- package/dist/doctor/db-substrate.d.ts +6 -9
- package/dist/doctor/db-substrate.d.ts.map +1 -1
- package/dist/doctor/db-substrate.js +10 -14
- package/dist/doctor/db-substrate.js.map +1 -1
- package/dist/doctor/migrations.d.ts +18 -0
- package/dist/doctor/migrations.d.ts.map +1 -1
- package/dist/doctor/migrations.js +54 -2
- package/dist/doctor/migrations.js.map +1 -1
- package/dist/error-catalog.js +1 -1
- package/dist/error-catalog.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +21 -5
- package/dist/init.js.map +1 -1
- package/dist/llm/conversation.d.ts.map +1 -1
- package/dist/llm/conversation.js +15 -4
- package/dist/llm/conversation.js.map +1 -1
- package/dist/llm/plugin-facade.js +277 -376
- package/dist/llm/plugin-facade.js.map +3 -3
- package/dist/llm/transports/bedrock.d.ts +1 -0
- package/dist/llm/transports/bedrock.d.ts.map +1 -1
- package/dist/llm/transports/bedrock.js +30 -12
- package/dist/llm/transports/bedrock.js.map +1 -1
- package/dist/memory/decision-contradiction.d.ts.map +1 -1
- package/dist/memory/decision-contradiction.js +10 -5
- package/dist/memory/decision-contradiction.js.map +1 -1
- package/dist/memory/dialectic-evaluator.d.ts.map +1 -1
- package/dist/memory/dialectic-evaluator.js +4 -1
- package/dist/memory/dialectic-evaluator.js.map +1 -1
- package/dist/memory/transcript-extractor.d.ts.map +1 -1
- package/dist/memory/transcript-extractor.js +3 -2
- package/dist/memory/transcript-extractor.js.map +1 -1
- package/dist/metrics/token-service.d.ts +16 -0
- package/dist/metrics/token-service.d.ts.map +1 -1
- package/dist/metrics/token-service.js +18 -0
- package/dist/metrics/token-service.js.map +1 -1
- package/dist/nexus/api-contracts.d.ts.map +1 -1
- package/dist/nexus/api-contracts.js +27 -11
- package/dist/nexus/api-contracts.js.map +1 -1
- package/dist/nexus/api-extractors/http-extractor.d.ts +2 -2
- package/dist/nexus/api-extractors/http-extractor.d.ts.map +1 -1
- package/dist/nexus/api-extractors/http-extractor.js +3 -3
- package/dist/nexus/api-extractors/http-extractor.js.map +1 -1
- package/dist/nexus/clusters.js +1 -1
- package/dist/nexus/clusters.js.map +1 -1
- package/dist/nexus/diff.d.ts +1 -1
- package/dist/nexus/diff.d.ts.map +1 -1
- package/dist/nexus/diff.js +3 -2
- package/dist/nexus/diff.js.map +1 -1
- package/dist/nexus/flows.js +1 -1
- package/dist/nexus/flows.js.map +1 -1
- package/dist/nexus/identity.d.ts +8 -0
- package/dist/nexus/identity.d.ts.map +1 -1
- package/dist/nexus/identity.js +17 -1
- package/dist/nexus/identity.js.map +1 -1
- package/dist/nexus/index.d.ts +1 -1
- package/dist/nexus/index.d.ts.map +1 -1
- package/dist/nexus/index.js +2 -2
- package/dist/nexus/index.js.map +1 -1
- package/dist/nexus/nexus-bridge.d.ts.map +1 -1
- package/dist/nexus/nexus-bridge.js +5 -5
- package/dist/nexus/nexus-bridge.js.map +1 -1
- package/dist/nexus/registry.d.ts +22 -0
- package/dist/nexus/registry.d.ts.map +1 -1
- package/dist/nexus/registry.js +76 -1
- package/dist/nexus/registry.js.map +1 -1
- package/dist/paths.js +1 -1
- package/dist/paths.js.map +1 -1
- package/dist/project-scope.js +1 -1
- package/dist/project-scope.js.map +1 -1
- package/dist/project-tombstone.js +1 -1
- package/dist/project-tombstone.js.map +1 -1
- package/dist/release/open.d.ts +3 -6
- package/dist/release/open.d.ts.map +1 -1
- package/dist/release/open.js +59 -23
- package/dist/release/open.js.map +1 -1
- package/dist/release/preflight-skip.d.ts +69 -10
- package/dist/release/preflight-skip.d.ts.map +1 -1
- package/dist/release/preflight-skip.js +195 -47
- package/dist/release/preflight-skip.js.map +1 -1
- package/dist/render/colors.js +1 -19
- package/dist/render/colors.js.map +1 -1
- package/dist/render/tasks/colors.js +1 -1
- package/dist/render/tasks/colors.js.map +1 -1
- package/dist/resources/governor.d.ts +12 -0
- package/dist/resources/governor.d.ts.map +1 -1
- package/dist/resources/governor.js +5 -1
- package/dist/resources/governor.js.map +1 -1
- package/dist/resources/heavy-command-hook-delivery.d.ts +144 -0
- package/dist/resources/heavy-command-hook-delivery.d.ts.map +1 -0
- package/dist/resources/heavy-command-hook-delivery.js +204 -0
- package/dist/resources/heavy-command-hook-delivery.js.map +1 -0
- package/dist/resources/heavy-command.d.ts +69 -0
- package/dist/resources/heavy-command.d.ts.map +1 -1
- package/dist/resources/heavy-command.js +479 -7
- package/dist/resources/heavy-command.js.map +1 -1
- package/dist/resources/run-class.d.ts.map +1 -1
- package/dist/resources/run-class.js +1 -0
- package/dist/resources/run-class.js.map +1 -1
- package/dist/routing/build-command-groups.d.ts +1 -1
- package/dist/routing/build-command-groups.d.ts.map +1 -1
- package/dist/routing/build-command-groups.js +4 -1
- package/dist/routing/build-command-groups.js.map +1 -1
- package/dist/scaffold/ensure-skills.d.ts.map +1 -1
- package/dist/scaffold/ensure-skills.js +58 -2
- package/dist/scaffold/ensure-skills.js.map +1 -1
- package/dist/selfimprove/fix-gen.js +277 -376
- package/dist/selfimprove/fix-gen.js.map +3 -3
- package/dist/sessions/briefing.d.ts.map +1 -1
- package/dist/sessions/briefing.js +11 -0
- package/dist/sessions/briefing.js.map +1 -1
- package/dist/store/dual-scope-db.d.ts.map +1 -1
- package/dist/store/dual-scope-db.js +6 -1
- package/dist/store/dual-scope-db.js.map +1 -1
- package/dist/store/table-classification.d.ts +27 -0
- package/dist/store/table-classification.d.ts.map +1 -1
- package/dist/store/table-classification.js +32 -0
- package/dist/store/table-classification.js.map +1 -1
- package/dist/store/vault-manifest.d.ts +5 -1
- package/dist/store/vault-manifest.d.ts.map +1 -1
- package/dist/store/vault-manifest.js +9 -2
- package/dist/store/vault-manifest.js.map +1 -1
- package/dist/system/health.d.ts +13 -0
- package/dist/system/health.d.ts.map +1 -1
- package/dist/system/health.js +96 -1
- package/dist/system/health.js.map +1 -1
- package/dist/tasks/affected-packages.d.ts +20 -7
- package/dist/tasks/affected-packages.d.ts.map +1 -1
- package/dist/tasks/affected-packages.js +39 -13
- package/dist/tasks/affected-packages.js.map +1 -1
- package/dist/tasks/affected-template.d.ts +93 -0
- package/dist/tasks/affected-template.d.ts.map +1 -0
- package/dist/tasks/affected-template.js +198 -0
- package/dist/tasks/affected-template.js.map +1 -0
- package/dist/tasks/command-line.d.ts +4 -1
- package/dist/tasks/command-line.d.ts.map +1 -1
- package/dist/tasks/command-line.js +7 -2
- package/dist/tasks/command-line.js.map +1 -1
- package/dist/tasks/done-plan.d.ts.map +1 -1
- package/dist/tasks/done-plan.js +12 -2
- package/dist/tasks/done-plan.js.map +1 -1
- package/dist/tasks/duplicate-detector.d.ts.map +1 -1
- package/dist/tasks/duplicate-detector.js +11 -5
- package/dist/tasks/duplicate-detector.js.map +1 -1
- package/dist/tasks/evidence.d.ts.map +1 -1
- package/dist/tasks/evidence.js +185 -11
- package/dist/tasks/evidence.js.map +1 -1
- package/dist/tasks/heavy-tool-env.d.ts +385 -18
- package/dist/tasks/heavy-tool-env.d.ts.map +1 -1
- package/dist/tasks/heavy-tool-env.js +719 -40
- package/dist/tasks/heavy-tool-env.js.map +1 -1
- package/dist/tasks/index.d.ts +2 -2
- package/dist/tasks/index.d.ts.map +1 -1
- package/dist/tasks/index.js +1 -1
- package/dist/tasks/index.js.map +1 -1
- package/dist/tasks/tool-cache-env.d.ts +20 -13
- package/dist/tasks/tool-cache-env.d.ts.map +1 -1
- package/dist/tasks/tool-cache-env.js +27 -48
- package/dist/tasks/tool-cache-env.js.map +1 -1
- package/dist/tasks/tool-cache.d.ts +8 -0
- package/dist/tasks/tool-cache.d.ts.map +1 -1
- package/dist/tasks/tool-cache.js +19 -6
- package/dist/tasks/tool-cache.js.map +1 -1
- package/dist/tasks/tool-semaphore.d.ts +66 -23
- package/dist/tasks/tool-semaphore.d.ts.map +1 -1
- package/dist/tasks/tool-semaphore.js +96 -34
- package/dist/tasks/tool-semaphore.js.map +1 -1
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +37 -4
- package/dist/upgrade.js.map +1 -1
- package/dist/validation/doctor/checks.d.ts +17 -0
- package/dist/validation/doctor/checks.d.ts.map +1 -1
- package/dist/validation/doctor/checks.js +83 -0
- package/dist/validation/doctor/checks.js.map +1 -1
- package/package.json +13 -13
- package/schemas/project-context.schema.json +5 -1
|
@@ -40,9 +40,9 @@
|
|
|
40
40
|
* @task T12983
|
|
41
41
|
* @epic T12978
|
|
42
42
|
*/
|
|
43
|
-
import { accessSync, existsSync, constants as fsConstants } from 'node:fs';
|
|
43
|
+
import { accessSync, existsSync, constants as fsConstants, realpathSync } from 'node:fs';
|
|
44
44
|
import { homedir } from 'node:os';
|
|
45
|
-
import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
45
|
+
import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
46
46
|
import { looksHeavy, resolveRunClass } from './run-class.js';
|
|
47
47
|
/** Operators, longest first so a prefix never shadows a longer one. */
|
|
48
48
|
const OPERATORS = [
|
|
@@ -71,6 +71,8 @@ const OPERATORS = [
|
|
|
71
71
|
')',
|
|
72
72
|
];
|
|
73
73
|
const OPERATOR_START = new Set(['|', '&', ';', '<', '>', '(', ')']);
|
|
74
|
+
/** A `$name` or special parameter right after a `$` (sticky: matched at `lastIndex`). */
|
|
75
|
+
const PARAM_NAME = /[A-Za-z_][A-Za-z0-9_]*|[0-9?$!#@*-]/y;
|
|
74
76
|
const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
75
77
|
/**
|
|
76
78
|
* Lex `src` from `from` the way a POSIX shell tokenises it. In `paren` mode
|
|
@@ -93,6 +95,7 @@ function lexShell(src, from, mode) {
|
|
|
93
95
|
expands: false,
|
|
94
96
|
assignment: false,
|
|
95
97
|
bare: true,
|
|
98
|
+
globby: false,
|
|
96
99
|
};
|
|
97
100
|
return cur;
|
|
98
101
|
};
|
|
@@ -107,6 +110,7 @@ function lexShell(src, from, mode) {
|
|
|
107
110
|
expands: cur.expands,
|
|
108
111
|
assignment: cur.assignment,
|
|
109
112
|
bare: cur.bare,
|
|
113
|
+
globby: cur.globby,
|
|
110
114
|
};
|
|
111
115
|
tokens.push(word);
|
|
112
116
|
cur = null;
|
|
@@ -170,7 +174,9 @@ function lexShell(src, from, mode) {
|
|
|
170
174
|
draft.bare = false;
|
|
171
175
|
return j + 1;
|
|
172
176
|
}
|
|
173
|
-
|
|
177
|
+
// Matched in place at `lastIndex` (sticky), anchored to the `$`.
|
|
178
|
+
PARAM_NAME.lastIndex = at + 1;
|
|
179
|
+
const name = PARAM_NAME.exec(src);
|
|
174
180
|
if (name) {
|
|
175
181
|
draft.value += `$${name[0]}`;
|
|
176
182
|
draft.expands = true;
|
|
@@ -321,6 +327,9 @@ function lexShell(src, from, mode) {
|
|
|
321
327
|
else {
|
|
322
328
|
const d = begin(i);
|
|
323
329
|
d.value += ch;
|
|
330
|
+
// zsh's EXTENDED_GLOB adds `^`, `~` and a mid-word `#` to `*?[` and braces.
|
|
331
|
+
if ('*?[{^~#'.includes(ch))
|
|
332
|
+
d.globby = true;
|
|
324
333
|
i++;
|
|
325
334
|
}
|
|
326
335
|
}
|
|
@@ -635,11 +644,474 @@ export function planHeavyCommand(command, opts) {
|
|
|
635
644
|
if (blockers.length > 0) {
|
|
636
645
|
return { action: 'warn', reason: [...new Set(blockers)].join('; '), segments };
|
|
637
646
|
}
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
647
|
+
// One pass over the sorted edits (linear; re-slicing per edit was quadratic).
|
|
648
|
+
const parts = [];
|
|
649
|
+
let at = 0;
|
|
650
|
+
for (const edit of [...edits].sort((a, b) => a.at - b.at)) {
|
|
651
|
+
parts.push(command.slice(at, edit.at), edit.text);
|
|
652
|
+
at = edit.at;
|
|
641
653
|
}
|
|
642
|
-
|
|
654
|
+
parts.push(command.slice(at));
|
|
655
|
+
return { action: 'rewrite', command: parts.join(''), segments };
|
|
656
|
+
}
|
|
657
|
+
// ---------------------------------------------------------------------------
|
|
658
|
+
// Claude Code pre-approval (T13124)
|
|
659
|
+
// ---------------------------------------------------------------------------
|
|
660
|
+
/** Claude Code prompts for any Bash command longer than this, whatever the rules. */
|
|
661
|
+
const CLAUDE_PARSE_LIMIT = 10_000;
|
|
662
|
+
/**
|
|
663
|
+
* Commands from Claude Code's built-in read-only set accepted without an allow
|
|
664
|
+
* rule, in the narrow form {@link claudePreApproval} checks. A subset: `find`,
|
|
665
|
+
* `diff`, `stat`, `du`, `which` and read-only `git` are left to the rules.
|
|
666
|
+
*/
|
|
667
|
+
const CLAUDE_READ_ONLY = new Set(['cat', 'echo', 'pwd', 'head', 'tail', 'grep', 'wc', 'ls']);
|
|
668
|
+
/**
|
|
669
|
+
* Exact flag allowlists for the rule-free read-only commands (fail-closed,
|
|
670
|
+
* T13124 review MED-2b). getopt_long accepts any unique prefix (`grep --rec`
|
|
671
|
+
* is `--recursive`, `--der` `--dereference-recursive`, GNU `wc --files` is
|
|
672
|
+
* `--files0-from`), so a denylist of long flags cannot hold: only these exact
|
|
673
|
+
* spellings pass. Nothing here reads a file, recurses, follows a symlink or
|
|
674
|
+
* takes a non-numeric value; `ls` has no `-R`, `-L` or `-H`.
|
|
675
|
+
*/
|
|
676
|
+
const READ_ONLY_FLAGS = {
|
|
677
|
+
cat: {
|
|
678
|
+
short: 'benstuv',
|
|
679
|
+
numericShort: '',
|
|
680
|
+
long: ['--number', '--number-nonblank', '--squeeze-blank', '--show-ends', '--show-tabs'],
|
|
681
|
+
numericLong: [],
|
|
682
|
+
bareCount: false,
|
|
683
|
+
positionals: 0,
|
|
684
|
+
},
|
|
685
|
+
head: {
|
|
686
|
+
short: 'qv',
|
|
687
|
+
numericShort: 'nc',
|
|
688
|
+
long: ['--quiet', '--silent', '--verbose'],
|
|
689
|
+
numericLong: ['--lines', '--bytes'],
|
|
690
|
+
bareCount: true,
|
|
691
|
+
positionals: 0,
|
|
692
|
+
},
|
|
693
|
+
tail: {
|
|
694
|
+
short: 'qv',
|
|
695
|
+
numericShort: 'nc',
|
|
696
|
+
long: ['--quiet', '--silent', '--verbose'],
|
|
697
|
+
numericLong: ['--lines', '--bytes'],
|
|
698
|
+
bareCount: true,
|
|
699
|
+
positionals: 0,
|
|
700
|
+
},
|
|
701
|
+
wc: {
|
|
702
|
+
short: 'clmw',
|
|
703
|
+
numericShort: '',
|
|
704
|
+
long: ['--bytes', '--chars', '--lines', '--words'],
|
|
705
|
+
numericLong: [],
|
|
706
|
+
bareCount: false,
|
|
707
|
+
positionals: 0,
|
|
708
|
+
},
|
|
709
|
+
ls: {
|
|
710
|
+
short: 'aAdFhilnpstu1',
|
|
711
|
+
numericShort: '',
|
|
712
|
+
long: ['--all', '--almost-all', '--human-readable'],
|
|
713
|
+
numericLong: [],
|
|
714
|
+
bareCount: false,
|
|
715
|
+
positionals: 0,
|
|
716
|
+
},
|
|
717
|
+
grep: {
|
|
718
|
+
short: 'EFGHIVabchinoqsvwxz',
|
|
719
|
+
numericShort: 'ABCm',
|
|
720
|
+
long: [
|
|
721
|
+
'--ignore-case',
|
|
722
|
+
'--invert-match',
|
|
723
|
+
'--count',
|
|
724
|
+
'--line-number',
|
|
725
|
+
'--word-regexp',
|
|
726
|
+
'--line-regexp',
|
|
727
|
+
'--only-matching',
|
|
728
|
+
'--quiet',
|
|
729
|
+
'--silent',
|
|
730
|
+
'--extended-regexp',
|
|
731
|
+
'--fixed-strings',
|
|
732
|
+
'--no-filename',
|
|
733
|
+
'--with-filename',
|
|
734
|
+
],
|
|
735
|
+
numericLong: ['--max-count', '--after-context', '--before-context', '--context'],
|
|
736
|
+
bareCount: false,
|
|
737
|
+
positionals: 1,
|
|
738
|
+
},
|
|
739
|
+
};
|
|
740
|
+
/**
|
|
741
|
+
* Why the arguments of a rule-free read-only command could make it read a
|
|
742
|
+
* file, or `null` when they cannot (T13124 review MED-2: a project symlink
|
|
743
|
+
* `notes.txt -> /etc/hosts` made `cat notes.txt` a read outside the project).
|
|
744
|
+
* Fail-closed: these commands only filter stdin. Every flag must be in
|
|
745
|
+
* {@link READ_ONLY_FLAGS} exactly, a numeric value may follow only a flag that
|
|
746
|
+
* takes one, and only grep takes a positional (its one pattern). `echo`
|
|
747
|
+
* prints its arguments; `pwd` takes none. Anything unrecognised refuses.
|
|
748
|
+
*/
|
|
749
|
+
function readOnlyArgsRefusal(cmd, args) {
|
|
750
|
+
if (cmd === 'echo')
|
|
751
|
+
return null;
|
|
752
|
+
if (cmd === 'pwd')
|
|
753
|
+
return args.length === 0 ? null : '`pwd` takes no arguments here';
|
|
754
|
+
const spec = READ_ONLY_FLAGS[cmd];
|
|
755
|
+
if (spec === undefined)
|
|
756
|
+
return `\`${cmd}\` is not a known stdin filter`;
|
|
757
|
+
const refuse = (arg) => `\`${cmd}\` takes \`${arg}\`, which is not in its stdin-filter allowlist`;
|
|
758
|
+
let positionals = 0;
|
|
759
|
+
for (let k = 0; k < args.length; k++) {
|
|
760
|
+
const arg = args[k];
|
|
761
|
+
const numericNext = () => {
|
|
762
|
+
const value = args[k + 1];
|
|
763
|
+
if (value === undefined || !/^\+?\d+$/.test(value))
|
|
764
|
+
return false;
|
|
765
|
+
k++;
|
|
766
|
+
return true;
|
|
767
|
+
};
|
|
768
|
+
// With POSIXLY_CORRECT, option parsing stops at the first operand, so a
|
|
769
|
+
// flag after grep's pattern would be a file name (review LOW).
|
|
770
|
+
if (positionals > 0 && arg.startsWith('-')) {
|
|
771
|
+
return `\`${cmd}\` takes a flag after its pattern (a file name under POSIXLY_CORRECT)`;
|
|
772
|
+
}
|
|
773
|
+
if (arg.startsWith('--')) {
|
|
774
|
+
if (spec.long.includes(arg))
|
|
775
|
+
continue;
|
|
776
|
+
const eq = arg.indexOf('=');
|
|
777
|
+
if (eq === -1
|
|
778
|
+
? spec.numericLong.includes(arg) && numericNext()
|
|
779
|
+
: spec.numericLong.includes(arg.slice(0, eq)) && /^\d+$/.test(arg.slice(eq + 1))) {
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
return refuse(arg);
|
|
783
|
+
}
|
|
784
|
+
if (arg.startsWith('-') && arg.length > 1) {
|
|
785
|
+
if (spec.bareCount && /^-\d+$/.test(arg))
|
|
786
|
+
continue;
|
|
787
|
+
let ok = true;
|
|
788
|
+
for (let c = 1; c < arg.length; c++) {
|
|
789
|
+
const ch = arg[c];
|
|
790
|
+
if (spec.short.includes(ch))
|
|
791
|
+
continue;
|
|
792
|
+
if (spec.numericShort.includes(ch)) {
|
|
793
|
+
const glued = arg.slice(c + 1);
|
|
794
|
+
ok = glued === '' ? numericNext() : /^\d+$/.test(glued);
|
|
795
|
+
break;
|
|
796
|
+
}
|
|
797
|
+
ok = false;
|
|
798
|
+
break;
|
|
799
|
+
}
|
|
800
|
+
if (!ok)
|
|
801
|
+
return refuse(arg);
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
if (positionals < spec.positionals) {
|
|
805
|
+
positionals++;
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
return `\`${cmd}\` names a file (only stdin filters are accepted without a rule)`;
|
|
809
|
+
}
|
|
810
|
+
return null;
|
|
811
|
+
}
|
|
812
|
+
/** Operators that end a line Claude Code then treats as unparseable. */
|
|
813
|
+
const DANGLING = new Set(['&&', '||', '|', '|&']);
|
|
814
|
+
/**
|
|
815
|
+
* The only word shapes a pre-approved line may hold (fail-closed allowlist,
|
|
816
|
+
* T13124 review): runs of `[A-Za-z0-9_./:@%+=,-]`, `'single-quoted'`
|
|
817
|
+
* literals, and `"double-quoted"` literals with no `$`, backtick, backslash or
|
|
818
|
+
* `!`. Quoted text is printable ASCII only (no control characters, no
|
|
819
|
+
* newline, no non-ASCII). Everything else is refused: `$` in any form
|
|
820
|
+
* (variables, `$'…'`, `$"…"`, substitutions), backticks, backslash escapes,
|
|
821
|
+
* globs (`*?[`), braces, `~`, `!`, `#`, `^`, operators, whitespace and any
|
|
822
|
+
* byte outside printable ASCII. A word may not start with `=` (zsh EQUALS
|
|
823
|
+
* expansion). Each alternative consumes a single plain character or one whole
|
|
824
|
+
* quoted literal, so matching is linear (no nested quantifier; T13124 review
|
|
825
|
+
* HIGH-1: `[…]+` inside `(…)+` backtracked exponentially).
|
|
826
|
+
*/
|
|
827
|
+
const STRICT_WORD = /^(?!=)(?:[A-Za-z0-9_./:@%+=,-]|'[\x20-\x26\x28-\x7e]*'|"[\x20\x23\x25-\x5b\x5d-\x5f\x61-\x7e]*")+$/;
|
|
828
|
+
/**
|
|
829
|
+
* zsh EQUALS expansion: a word-initial `=cmd` becomes the command's absolute
|
|
830
|
+
* path, and with MAGIC_EQUAL_SUBST so does `=cmd` after `=` or `:` in an
|
|
831
|
+
* `x=…` word. {@link STRICT_WORD} refuses the first; this the others.
|
|
832
|
+
*/
|
|
833
|
+
const ZSH_EQUALS = /[=:]=/;
|
|
834
|
+
/**
|
|
835
|
+
* Operators a pre-approved line may hold: list and pipe separators, and the
|
|
836
|
+
* redirections {@link claudeSubcommands} narrows to `/dev/null` and file
|
|
837
|
+
* descriptors. Anything else (`&`, `|&`, subshell parentheses, heredocs,
|
|
838
|
+
* newlines, case terminators) is refused.
|
|
839
|
+
*/
|
|
840
|
+
const STRICT_OPS = new Set([
|
|
841
|
+
'&&',
|
|
842
|
+
'||',
|
|
843
|
+
';',
|
|
844
|
+
'|',
|
|
845
|
+
'>',
|
|
846
|
+
'>>',
|
|
847
|
+
'&>',
|
|
848
|
+
'&>>',
|
|
849
|
+
'>&',
|
|
850
|
+
'<&',
|
|
851
|
+
]);
|
|
852
|
+
/**
|
|
853
|
+
* Why a lexed line falls outside the strict pre-approval grammar, or `null`
|
|
854
|
+
* when every token is in it: each word matches {@link STRICT_WORD}, each
|
|
855
|
+
* operator is in {@link STRICT_OPS}, and nothing but spaces sits between
|
|
856
|
+
* tokens (no tab, comment or line continuation). Fail-closed: a construct
|
|
857
|
+
* this grammar does not name is refused, whatever the lexer made of it.
|
|
858
|
+
*/
|
|
859
|
+
function outsideStrictGrammar(lexed) {
|
|
860
|
+
let at = 0;
|
|
861
|
+
for (const t of lexed.tokens) {
|
|
862
|
+
if (!/^ *$/.test(lexed.src.slice(at, t.start))) {
|
|
863
|
+
return 'it holds text outside any word (a comment or line continuation)';
|
|
864
|
+
}
|
|
865
|
+
at = t.end;
|
|
866
|
+
if (t.kind === 'op') {
|
|
867
|
+
if (!STRICT_OPS.has(t.op))
|
|
868
|
+
return `it uses \`${t.op === '\n' ? 'newline' : t.op}\``;
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
const raw = lexed.src.slice(t.start, t.end);
|
|
872
|
+
// zsh EQUALS also fires on the quote-removed word (`""=ls` is `/bin/ls`;
|
|
873
|
+
// review LOW-1), so check the value as well as the raw text.
|
|
874
|
+
if (!STRICT_WORD.test(raw) ||
|
|
875
|
+
ZSH_EQUALS.test(raw) ||
|
|
876
|
+
t.value.startsWith('=') ||
|
|
877
|
+
ZSH_EQUALS.test(t.value)) {
|
|
878
|
+
return 'a word is not a plain or simply quoted literal (an expansion, escape, glob, brace or tilde)';
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return /^ *$/.test(lexed.src.slice(at)) ? null : 'it holds text after the last word';
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Whether a Claude Code Bash rule pattern (the text inside `Bash(…)`) matches
|
|
885
|
+
* one subcommand's text, as the permissions reference specifies: `*` stands for
|
|
886
|
+
* any text including spaces, a trailing `:*` equals a trailing ` *`, a trailing
|
|
887
|
+
* ` *` that is the rule's only wildcard also matches the bare command, and a
|
|
888
|
+
* rule without `*` matches one exact command. The bare `Bash` rule is `*`.
|
|
889
|
+
*
|
|
890
|
+
* @param pattern - the rule pattern, e.g. `pnpm test *` or `npm run test:*`.
|
|
891
|
+
* @param text - one subcommand as written.
|
|
892
|
+
*
|
|
893
|
+
* @example
|
|
894
|
+
* ```ts
|
|
895
|
+
* claudeBashRuleMatches('pnpm test *', 'pnpm test'); // true
|
|
896
|
+
* claudeBashRuleMatches('pnpm test:*', 'pnpm test --run'); // true
|
|
897
|
+
* claudeBashRuleMatches('ls *', 'lsof'); // false
|
|
898
|
+
* ```
|
|
899
|
+
*/
|
|
900
|
+
export function claudeBashRuleMatches(pattern, text) {
|
|
901
|
+
const normalized = pattern.endsWith(':*') ? `${pattern.slice(0, -2)} *` : pattern;
|
|
902
|
+
if (wildcardMatch(normalized, text))
|
|
903
|
+
return true;
|
|
904
|
+
return (normalized.endsWith(' *') &&
|
|
905
|
+
normalized.indexOf('*') === normalized.length - 1 &&
|
|
906
|
+
text === normalized.slice(0, -2));
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* Whether `text` matches `pattern`, where `*` stands for any run of
|
|
910
|
+
* characters and everything else is literal. Iterative with one backtrack
|
|
911
|
+
* point, so it runs in O(pattern × text) at worst; a `RegExp` built from the
|
|
912
|
+
* pattern backtracks polynomially in the number of `*`s (T13124 review).
|
|
913
|
+
*/
|
|
914
|
+
function wildcardMatch(pattern, text) {
|
|
915
|
+
let p = 0;
|
|
916
|
+
let t = 0;
|
|
917
|
+
let star = -1;
|
|
918
|
+
let mark = 0;
|
|
919
|
+
while (t < text.length) {
|
|
920
|
+
if (p < pattern.length && pattern[p] !== '*' && pattern[p] === text[t]) {
|
|
921
|
+
p++;
|
|
922
|
+
t++;
|
|
923
|
+
}
|
|
924
|
+
else if (p < pattern.length && pattern[p] === '*') {
|
|
925
|
+
star = p++;
|
|
926
|
+
mark = t;
|
|
927
|
+
}
|
|
928
|
+
else if (star !== -1) {
|
|
929
|
+
p = star + 1;
|
|
930
|
+
t = ++mark;
|
|
931
|
+
}
|
|
932
|
+
else {
|
|
933
|
+
return false;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
while (p < pattern.length && pattern[p] === '*')
|
|
937
|
+
p++;
|
|
938
|
+
return p === pattern.length;
|
|
939
|
+
}
|
|
940
|
+
function inside(dir, path) {
|
|
941
|
+
const rel = relative(dir, path);
|
|
942
|
+
return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
943
|
+
}
|
|
944
|
+
function canonicalPath(path) {
|
|
945
|
+
try {
|
|
946
|
+
return realpathSync(path);
|
|
947
|
+
}
|
|
948
|
+
catch {
|
|
949
|
+
return null;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* Split one list element into pipeline stages, or a reason Claude Code might
|
|
954
|
+
* prompt for it regardless of rules: a redirection to anything but
|
|
955
|
+
* `/dev/null` or a file descriptor, or a `$` or backtick expansion.
|
|
956
|
+
*/
|
|
957
|
+
function claudeSubcommands(src, tokens) {
|
|
958
|
+
const out = [];
|
|
959
|
+
let stage = [];
|
|
960
|
+
const flush = () => {
|
|
961
|
+
if (stage.length === 0)
|
|
962
|
+
return 'it has an empty pipeline stage';
|
|
963
|
+
const words = [];
|
|
964
|
+
for (let k = 0; k < stage.length; k++) {
|
|
965
|
+
const t = stage[k];
|
|
966
|
+
if (t.kind === 'word') {
|
|
967
|
+
if (t.expands)
|
|
968
|
+
return 'it expands a variable or command';
|
|
969
|
+
words.push(t);
|
|
970
|
+
continue;
|
|
971
|
+
}
|
|
972
|
+
const target = stage[k + 1];
|
|
973
|
+
if (target?.kind !== 'word' || target.expands)
|
|
974
|
+
return `it redirects (${t.op})`;
|
|
975
|
+
// The raw operator includes its fd prefix (`2>&`). Only stdout and stderr
|
|
976
|
+
// may be redirected: to /dev/null, or duplicated onto each other (review LOW).
|
|
977
|
+
const rawOp = src.slice(t.start, t.end);
|
|
978
|
+
const toNull = /^(?:[12]?>>?|&>>?)$/.test(rawOp) && target.value === '/dev/null';
|
|
979
|
+
const toFd = /^[12]?>&$/.test(rawOp) && /^[12]$/.test(target.value);
|
|
980
|
+
if (!toNull && !toFd)
|
|
981
|
+
return `it redirects (${t.op} ${target.value})`;
|
|
982
|
+
k++;
|
|
983
|
+
}
|
|
984
|
+
const first = stage[0];
|
|
985
|
+
const last = stage[stage.length - 1];
|
|
986
|
+
out.push({
|
|
987
|
+
words,
|
|
988
|
+
text: src.slice(first.start, last.end),
|
|
989
|
+
wordsText: words.map((w) => src.slice(w.start, w.end)).join(' '),
|
|
990
|
+
});
|
|
991
|
+
stage = [];
|
|
992
|
+
return null;
|
|
993
|
+
};
|
|
994
|
+
for (const t of tokens) {
|
|
995
|
+
if (t.kind === 'op' && (t.op === '|' || t.op === '|&')) {
|
|
996
|
+
const err = flush();
|
|
997
|
+
if (err !== null)
|
|
998
|
+
return err;
|
|
999
|
+
}
|
|
1000
|
+
else {
|
|
1001
|
+
stage.push(t);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
const err = flush();
|
|
1005
|
+
return err ?? out;
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Whether Claude Code would run `command` WITHOUT a prompt because the user's
|
|
1009
|
+
* Bash allow rules approve it: every subcommand (split at `&&`, `||`, `;`,
|
|
1010
|
+
* `|`, `|&` and newlines, as Claude Code splits) matches an allow rule, or is
|
|
1011
|
+
* a narrow read-only form Claude Code runs unprompted (`cd` into the working
|
|
1012
|
+
* directory, `tail -50`, `grep -v x`, …).
|
|
1013
|
+
*
|
|
1014
|
+
* The heavy-command hook uses this to rewrite a command in Claude Code's
|
|
1015
|
+
* default, acceptEdits, dontAsk and auto modes with `permissionDecision:
|
|
1016
|
+
* "allow"`: the user already approved the command, and the rewrite only makes
|
|
1017
|
+
* it queue for the machine-wide budget. So this check must never approve more
|
|
1018
|
+
* than Claude Code would. It is deliberately narrower: no wrapper stripping
|
|
1019
|
+
* (`timeout`, `nice`), no leading assignments, no expansions, no
|
|
1020
|
+
* substitutions, subshells, compound commands or background jobs, no
|
|
1021
|
+
* redirection except to `/dev/null` or a file descriptor, and both the stage
|
|
1022
|
+
* as written and its words alone must match the same rule. Anything else is
|
|
1023
|
+
* "not pre-approved" and the hook falls back to a warning. Deny and ask rules
|
|
1024
|
+
* are not checked here (the hook checks them, and Claude Code enforces them
|
|
1025
|
+
* on the rewritten command regardless).
|
|
1026
|
+
*
|
|
1027
|
+
* @param command - the command line as the agent wrote it.
|
|
1028
|
+
* @param allowPatterns - the patterns of the user's `Bash(…)` allow rules (`*` for a bare `Bash`).
|
|
1029
|
+
* @param opts - where the line starts, and Claude Code's working directory.
|
|
1030
|
+
* @returns approved, or the first reason it is not.
|
|
1031
|
+
*
|
|
1032
|
+
* @example
|
|
1033
|
+
* ```ts
|
|
1034
|
+
* claudePreApproval('pnpm test 2>&1 | tail -50', ['pnpm test *'], { cwd: '/repo', workingDir: '/repo' });
|
|
1035
|
+
* // { approved: true }
|
|
1036
|
+
* claudePreApproval('pnpm test && rm -rf dist', ['pnpm test *'], { cwd: '/repo', workingDir: '/repo' });
|
|
1037
|
+
* // { approved: false, reason: '`rm` is not approved by an allow rule' }
|
|
1038
|
+
* ```
|
|
1039
|
+
*/
|
|
1040
|
+
export function claudePreApproval(command, allowPatterns, opts) {
|
|
1041
|
+
const no = (reason) => ({ approved: false, reason });
|
|
1042
|
+
if (command.length > CLAUDE_PARSE_LIMIT)
|
|
1043
|
+
return no('it is longer than Claude Code parses');
|
|
1044
|
+
const lexed = lexShell(command, 0, 'top');
|
|
1045
|
+
if (lexed.error !== null)
|
|
1046
|
+
return no('it does not parse');
|
|
1047
|
+
if (lexed.substitutions.length > 0)
|
|
1048
|
+
return no('it has a command or process substitution');
|
|
1049
|
+
// Fail-closed (review HIGH-1): every token must be a plain or simply quoted
|
|
1050
|
+
// literal, so the values checked below are exactly what the shell passes.
|
|
1051
|
+
// `$'\x2fetc'` is `/etc` to the shell but `\x2fetc` to the lexer.
|
|
1052
|
+
const outside = outsideStrictGrammar(lexed);
|
|
1053
|
+
if (outside !== null)
|
|
1054
|
+
return no(outside);
|
|
1055
|
+
const last = lexed.tokens[lexed.tokens.length - 1];
|
|
1056
|
+
if (last?.kind === 'op' && DANGLING.has(last.op))
|
|
1057
|
+
return no('it ends in an operator');
|
|
1058
|
+
const stages = [];
|
|
1059
|
+
for (const element of splitElements(lexed.tokens)) {
|
|
1060
|
+
if (element.nested || element.background) {
|
|
1061
|
+
return no('it has a subshell, compound command or background job');
|
|
1062
|
+
}
|
|
1063
|
+
const split = claudeSubcommands(lexed.src, element.tokens);
|
|
1064
|
+
if (typeof split === 'string')
|
|
1065
|
+
return no(split);
|
|
1066
|
+
stages.push(...split);
|
|
1067
|
+
}
|
|
1068
|
+
if (stages.length === 0)
|
|
1069
|
+
return no('it is empty');
|
|
1070
|
+
const name = (s) => s.words[0]?.value ?? '';
|
|
1071
|
+
if (stages.filter((s) => name(s) === 'cd').length > 1) {
|
|
1072
|
+
return no('it changes directory more than once');
|
|
1073
|
+
}
|
|
1074
|
+
const withGit = stages.some((s) => name(s) === 'git');
|
|
1075
|
+
const workingDir = canonicalPath(opts.workingDir);
|
|
1076
|
+
let cwd = opts.cwd;
|
|
1077
|
+
for (const stage of stages) {
|
|
1078
|
+
const ruled = allowPatterns.some((p) => claudeBashRuleMatches(p, stage.text) && claudeBashRuleMatches(p, stage.wordsText));
|
|
1079
|
+
if (ruled)
|
|
1080
|
+
continue;
|
|
1081
|
+
const cmd = name(stage);
|
|
1082
|
+
const readOnly = cmd === 'cd' || CLAUDE_READ_ONLY.has(cmd);
|
|
1083
|
+
if (!readOnly || stage.words.some((w) => w.assignment)) {
|
|
1084
|
+
return no(`\`${cmd}\` is not approved by an allow rule`);
|
|
1085
|
+
}
|
|
1086
|
+
if (workingDir === null)
|
|
1087
|
+
return no('the working directory cannot be resolved');
|
|
1088
|
+
if (cmd === 'cd') {
|
|
1089
|
+
const target = stage.words[1]?.value;
|
|
1090
|
+
if (stage.words.length !== 2 ||
|
|
1091
|
+
target === undefined ||
|
|
1092
|
+
target === '-' ||
|
|
1093
|
+
target.startsWith('~') ||
|
|
1094
|
+
stage.words[1]?.globby === true) {
|
|
1095
|
+
return no('its `cd` is not a plain path');
|
|
1096
|
+
}
|
|
1097
|
+
if (withGit)
|
|
1098
|
+
return no('it runs git after a `cd`');
|
|
1099
|
+
const next = resolve(cwd, target);
|
|
1100
|
+
const real = canonicalPath(next);
|
|
1101
|
+
if (!inside(opts.workingDir, next) || real === null || !inside(workingDir, real)) {
|
|
1102
|
+
return no('its `cd` leaves the working directory');
|
|
1103
|
+
}
|
|
1104
|
+
cwd = next;
|
|
1105
|
+
continue;
|
|
1106
|
+
}
|
|
1107
|
+
const here = canonicalPath(cwd);
|
|
1108
|
+
if (here === null || !inside(workingDir, here))
|
|
1109
|
+
return no('it runs outside the working directory');
|
|
1110
|
+
const refused = readOnlyArgsRefusal(cmd, stage.words.slice(1).map((w) => w.value));
|
|
1111
|
+
if (refused !== null)
|
|
1112
|
+
return no(refused);
|
|
1113
|
+
}
|
|
1114
|
+
return { approved: true };
|
|
643
1115
|
}
|
|
644
1116
|
// ---------------------------------------------------------------------------
|
|
645
1117
|
// Mode, availability, pressure
|