ctxman 3.0.0 → 3.1.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/.ctxman/llm-profiles.json +1067 -39
- package/.ctxman/profiles/backend-team.json +13 -0
- package/.ctxman/profiles/default.json +12 -0
- package/.ctxman/profiles/devops-team.json +21 -0
- package/.ctxman/profiles/frontend-team.json +20 -0
- package/.ctxman/templates/bug-fix.json +17 -0
- package/.ctxman/templates/code-review.json +9 -0
- package/.ctxman/templates/documentation.json +9 -0
- package/.ctxman/templates/feature.json +9 -0
- package/.ctxman/templates/full-context.json +9 -0
- package/.ctxman/templates/refactoring.json +18 -0
- package/.ctxman/templates/testing.json +9 -0
- package/.ctxman/wizard-profiles/security-audit/profile.json +7 -1
- package/LICENSE +21 -0
- package/README.md +381 -136
- package/bin/cli.js +1880 -529
- package/bin/cm-ask.js +18 -67
- package/bin/cm-audit.js +121 -0
- package/bin/cm-count.js +151 -0
- package/bin/cm-gitingest.js +128 -115
- package/bin/cm-map.js +242 -0
- package/bin/cm-mcp.js +293 -0
- package/bin/cm-pack.js +405 -0
- package/bin/cm-policy.js +170 -0
- package/bin/cm-pr.js +248 -0
- package/bin/cm-search.js +178 -0
- package/bin/cm-update.js +128 -128
- package/bin/cm-why.js +197 -0
- package/bin/mcp-server.js +8 -24
- package/ctxman.js +237 -183
- package/examples/README.md +19 -4
- package/examples/github-actions/ctxman-check.yml +49 -0
- package/examples/wizard-profiles/security-audit/profile.json +7 -1
- package/index.d.ts +1422 -0
- package/index.js +55 -31
- package/lib/agents/pr-failure-agent.js +48 -45
- package/lib/analyzers/ai-suggester.js +451 -0
- package/lib/analyzers/dependency-scanner.js +505 -0
- package/lib/analyzers/duplicate-detector.js +264 -0
- package/lib/analyzers/go-method-analyzer.js +151 -110
- package/lib/analyzers/import-tracker.js +431 -0
- package/lib/analyzers/method-analyzer.js +1055 -434
- package/lib/analyzers/performance-dashboard.js +687 -0
- package/lib/analyzers/token-calculator.js +895 -504
- package/lib/analyzers/unused-exports-detector.js +336 -0
- package/lib/api/mcp/install.js +424 -0
- package/lib/api/mcp/prompts.js +281 -281
- package/lib/api/mcp/resources.js +300 -241
- package/lib/api/mcp/sandbox.js +89 -0
- package/lib/api/mcp/server.js +772 -312
- package/lib/api/mcp/start.js +125 -0
- package/lib/api/mcp/transports/HttpSseTransport.js +60 -52
- package/lib/api/rest/server.js +105 -28
- package/lib/cache/CacheManager.js +9 -17
- package/lib/core/Analyzer.js +16 -10
- package/lib/core/ContextBuilder.js +19 -28
- package/lib/core/Reporter.js +15 -14
- package/lib/core/Scanner.js +81 -12
- package/lib/count/index.js +100 -0
- package/lib/formatters/format-registry.js +394 -372
- package/lib/formatters/gitingest-formatter.js +656 -621
- package/lib/formatters/toon-formatter-v1.3.js +637 -632
- package/lib/formatters/toon-formatter.js +239 -240
- package/lib/integrations/git/BlameTracker.js +4 -4
- package/lib/integrations/git/DiffAnalyzer.js +38 -31
- package/lib/integrations/git/GitClient.js +71 -53
- package/lib/lsp/server.js +311 -0
- package/lib/map/graph.js +299 -0
- package/lib/map/imports.js +233 -0
- package/lib/map/index.js +33 -0
- package/lib/map/rank.js +253 -0
- package/lib/map/render.js +346 -0
- package/lib/map/resolver.js +574 -0
- package/lib/pack/budget.js +111 -0
- package/lib/pack/code-lexer.js +360 -0
- package/lib/pack/compress.js +414 -0
- package/lib/pack/index.js +354 -0
- package/lib/pack/renderers/common.js +134 -0
- package/lib/pack/renderers/index.js +24 -0
- package/lib/pack/renderers/json.js +47 -0
- package/lib/pack/renderers/markdown.js +54 -0
- package/lib/pack/renderers/plain.js +38 -0
- package/lib/pack/renderers/xml.js +85 -0
- package/lib/pack/select.js +177 -0
- package/lib/pack/split.js +182 -0
- package/lib/pack/tree.js +35 -0
- package/lib/parsers/gitignore-parser.js +116 -107
- package/lib/parsers/method-filter-parser.js +50 -33
- package/lib/plugins/ExporterPlugin.js +3 -3
- package/lib/plugins/LanguagePlugin.js +3 -3
- package/lib/plugins/PluginManager.js +7 -13
- package/lib/policy/audit.js +312 -0
- package/lib/policy/enforce.js +125 -0
- package/lib/policy/ignore-files.js +169 -0
- package/lib/policy/index.js +53 -0
- package/lib/policy/policy.js +489 -0
- package/lib/pr/diff-parser.js +267 -0
- package/lib/pr/method-mapper.js +148 -0
- package/lib/pr/pr-context.js +829 -0
- package/lib/pr/pr-git.js +149 -0
- package/lib/pr/pr-renderer.js +644 -0
- package/lib/pr/related-tests.js +296 -0
- package/lib/rag/DataSourcePlugin.js +5 -5
- package/lib/rag/EmbeddingProvider.js +19 -8
- package/lib/rag/Indexer.js +14 -5
- package/lib/rag/VectorStoreFactory.js +3 -3
- package/lib/rag/ask.js +122 -0
- package/lib/rag/sources/FileDataSource.js +12 -9
- package/lib/rag/stores/BaseVectorStore.js +5 -5
- package/lib/rag/stores/ChromaDBStore.js +10 -10
- package/lib/rag/stores/LanceDBStore.js +29 -19
- package/lib/rag/stores/PineconeStore.js +11 -9
- package/lib/search/bm25.js +433 -0
- package/lib/search/code-units.js +201 -0
- package/lib/search/symbols.js +189 -0
- package/lib/ui/dashboard.js +120 -89
- package/lib/ui/index.js +1 -6
- package/lib/ui/init-wizard-ui.js +550 -0
- package/lib/ui/progress-bar.js +94 -88
- package/lib/ui/select-input.js +40 -32
- package/lib/ui/template-selector.js +99 -0
- package/lib/ui/wizard.js +374 -329
- package/lib/utils/claude-token-counter.js +151 -0
- package/lib/utils/cli-output.js +17 -0
- package/lib/utils/clipboard-utils.js +49 -50
- package/lib/utils/config-utils.js +81 -66
- package/lib/utils/context-versioning.js +532 -0
- package/lib/utils/error-handler.js +112 -112
- package/lib/utils/file-utils.js +153 -69
- package/lib/utils/format-converter.js +356 -283
- package/lib/utils/git-utils.js +366 -294
- package/lib/utils/llm-cost-estimator.js +281 -0
- package/lib/utils/llm-detector.js +242 -78
- package/lib/utils/logger.js +246 -237
- package/lib/utils/messages.js +355 -0
- package/lib/utils/multi-repo-manager.js +364 -0
- package/lib/utils/profile-manager.js +380 -0
- package/lib/utils/safe-read.js +61 -0
- package/lib/utils/secret-redactor.js +61 -0
- package/lib/utils/snapshot-manager.js +679 -0
- package/lib/utils/template-manager.js +378 -0
- package/lib/utils/token-utils.js +201 -170
- package/lib/utils/tokenizer-adapter.js +421 -377
- package/lib/utils/updater.js +539 -506
- package/lib/watch/FileWatcher.js +12 -8
- package/lib/watch/IncrementalAnalyzer.js +7 -7
- package/lib/why/coupling.js +81 -0
- package/lib/why/errors.js +23 -0
- package/lib/why/git.js +169 -0
- package/lib/why/history.js +108 -0
- package/lib/why/index.js +720 -0
- package/lib/why/ownership.js +79 -0
- package/lib/why/paths.js +207 -0
- package/lib/why/references.js +259 -0
- package/lib/why/render.js +722 -0
- package/lib/why/search.js +358 -0
- package/lib/why/target.js +389 -0
- package/lib/wizards/config-generator.js +257 -0
- package/lib/wizards/project-detector.js +572 -0
- package/package.json +44 -10
- package/.ctxman/logs/ctxman-2026-07-02.log +0 -156
- package/.ctxman/self-knowledge/documents.lance/_transactions/0-b3dd1558-f95b-44b9-a154-18e8c330ae7a.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/1-44bff6d4-e552-4010-a769-e09ad822698d.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/10-37260794-5947-4ecb-b4c8-d732f58f40f1.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/11-0d52a883-5518-463c-9b6d-986c3af89dfd.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/12-ae354646-52f2-49c9-90fb-2e6ed8932663.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/13-5d7d5e57-27da-498b-b734-091b9d38da13.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/14-558ce928-810b-4252-a364-fdd1ee6f8a25.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/15-27e3a58f-8b64-43b8-a9f4-e3369446968b.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/16-a110c390-e5c8-4464-b976-bc7f5195fa15.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/17-97513225-53de-4216-b61a-6337bd0cc4ad.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/18-5ba450f0-c527-459c-bf4f-6d79b5e4285f.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/19-600d3a63-5225-49e9-b8b7-30e037b564ab.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/2-48a61b4e-92cf-4342-8376-914c1b99a9f9.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/20-121dd912-5410-410e-84ff-82c7a23604a0.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/3-bc6844ab-7ea3-4bb3-9ee1-538ce5664a9c.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/4-d5e1754f-dbb5-454e-a9be-2a7da8d7bcf9.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/5-4fae93df-0568-4b6f-8acf-8bef4bba6047.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/6-4b1b8b87-068e-49a3-ba2b-d8dae628b252.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/7-63d040fb-d2fa-4c0f-bed0-53411918a7b0.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/8-c5628cf6-264c-420f-aab8-bc96a9083591.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/9-c94570aa-5e53-4fcb-971f-cecc1d1d5492.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/1.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/10.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/11.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/12.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/13.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/14.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/15.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/16.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/17.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/18.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/19.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/2.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/20.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/21.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/3.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/4.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/5.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/6.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/7.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/8.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/9.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/000010010011011101000010de8621405d9d5cb06540dbf02c.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/0000101001011100011000003fb62a4aa8978974075c144f1c.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/000011010000100111001011c6cc824d628f3bb1f4e04cd5d0.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/001000001000011001011100d8f74349ada090b332cd1df669.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/01000001111010001000010045ab44467bb1e7e4d8ae06bd58.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/01000101101110111110011131ef904241a89e1ffb6ddaf8e3.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/010010110000010000011010fbfcc145c6a8a827b28057a07e.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1000011000101010001110003fb4f045c3835982efa1405324.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/100111011101110111101011f4396b410fa06af3b028d204e6.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/100111100111101000001100596b174e00995d6cc2969cc8dc.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1010001100011000110010102ea16b484d887c7c9725feffd6.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/101101100010101111110111c5230944d9b3af7301b6392dda.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1011011100100111111110105892bf41b5a530fedd7cf516f1.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/10111110000010001011110164e8774742bade87b483c10f36.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1100000100010110001110103ffaca40a2a42c85067ebaf9ae.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/110000010001110110101110664bde4c76a78dfc14b05e83b8.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/11001100111000010100110050645c4281b1f35b2022234f7f.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1101111011001100110111017214924a368f5719eab2246619.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1110000111001010001000002150c24dcab653d39d07032e63.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1110100000000100000011014d20534967a18b5c87721659ff.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/11110101111010000101011135a0114ef2b35dc6f1297a3381.lance +0 -0
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export policy: loading, validation and merging
|
|
3
|
+
*
|
|
4
|
+
* A project keeps its policy in .ctxman/policy.json; an organisation can add one more file
|
|
5
|
+
* through the CTXMAN_POLICY environment variable. Both apply at once and the stricter value
|
|
6
|
+
* wins: deny lists are combined, limits take the lowest value, and a target model must be
|
|
7
|
+
* allowed by every file that restricts models. This is a repository/organisation control
|
|
8
|
+
* against accidental exports, not a security boundary against the local user.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import GitIgnoreParser from '../parsers/gitignore-parser.js';
|
|
14
|
+
import { isValidIgnoreFileName, resolveIgnoreFileNames } from './ignore-files.js';
|
|
15
|
+
|
|
16
|
+
/** Project policy file, relative to the project root */
|
|
17
|
+
export const POLICY_FILE = '.ctxman/policy.json';
|
|
18
|
+
/** Environment variable naming the organisation policy file */
|
|
19
|
+
export const POLICY_ENV = 'CTXMAN_POLICY';
|
|
20
|
+
/** Audit log used when `audit.path` is not set, relative to the project root */
|
|
21
|
+
export const DEFAULT_AUDIT_PATH = '.ctxman/audit.log';
|
|
22
|
+
export const POLICY_DOCS_URL =
|
|
23
|
+
'https://github.com/hakkisagdic/ctxman/blob/main/docs/commands/policy.md';
|
|
24
|
+
|
|
25
|
+
const POLICY_VERSION = 1;
|
|
26
|
+
const FIELDS = [
|
|
27
|
+
'version',
|
|
28
|
+
'deny',
|
|
29
|
+
'requireRedaction',
|
|
30
|
+
'maxExportTokens',
|
|
31
|
+
'allowedTargetModels',
|
|
32
|
+
'ignoreFiles',
|
|
33
|
+
'audit',
|
|
34
|
+
];
|
|
35
|
+
const AUDIT_FIELDS = ['enabled', 'path'];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A policy file that cannot be read or does not match the schema
|
|
39
|
+
*/
|
|
40
|
+
export class PolicyConfigError extends Error {
|
|
41
|
+
/**
|
|
42
|
+
* @param {string} file - Policy file
|
|
43
|
+
* @param {Array<{field: string|null, message: string}>} errors
|
|
44
|
+
*/
|
|
45
|
+
constructor(file, errors) {
|
|
46
|
+
const describe = ({ field, message }) => (field ? `"${field}" ${message}` : message);
|
|
47
|
+
const detail =
|
|
48
|
+
errors.length === 1
|
|
49
|
+
? describe(errors[0])
|
|
50
|
+
: errors.map((error) => `\n - ${describe(error)}`).join('');
|
|
51
|
+
super(`Invalid ctxman policy ${file}: ${detail}`);
|
|
52
|
+
this.name = 'PolicyConfigError';
|
|
53
|
+
this.code = 'POLICY_CONFIG';
|
|
54
|
+
this.file = file;
|
|
55
|
+
this.field = errors[0]?.field ?? null;
|
|
56
|
+
this.errors = errors;
|
|
57
|
+
// A broken policy is a server-side configuration problem, and a CLI usage error
|
|
58
|
+
this.statusCode = 500;
|
|
59
|
+
this.exitCode = 2;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Normalize a path for matching: forward slashes, no leading "./" or "/"
|
|
65
|
+
* @param {string} relativePath
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
export function normalizeRelativePath(relativePath) {
|
|
69
|
+
return String(relativePath ?? '')
|
|
70
|
+
.replace(/\\/g, '/')
|
|
71
|
+
.replace(/^(\.\/)+/, '')
|
|
72
|
+
.replace(/^\/+/, '');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Project-relative path of a file entry: a string, or an object with relativePath or path
|
|
77
|
+
* @param {string|{relativePath?: string, path?: string}} item
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
export function relativePathOf(item) {
|
|
81
|
+
if (typeof item === 'string') return normalizeRelativePath(item);
|
|
82
|
+
return normalizeRelativePath(item?.relativePath ?? item?.path ?? '');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Compile a model glob (`*` any characters, `?` one character), case-insensitive
|
|
87
|
+
* @param {string} glob
|
|
88
|
+
* @returns {RegExp}
|
|
89
|
+
*/
|
|
90
|
+
export function modelGlobToRegex(glob) {
|
|
91
|
+
const source = glob
|
|
92
|
+
.split('')
|
|
93
|
+
.map((char) => {
|
|
94
|
+
if (char === '*') return '.*';
|
|
95
|
+
if (char === '?') return '.';
|
|
96
|
+
return char.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
97
|
+
})
|
|
98
|
+
.join('');
|
|
99
|
+
return new RegExp(`^${source}$`, 'i');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const isPlainObject = (value) =>
|
|
103
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
104
|
+
const show = (value) => JSON.stringify(value);
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Validate one parsed policy document and return its rules in normal form
|
|
108
|
+
* @param {*} doc - Parsed JSON
|
|
109
|
+
* @param {object} context
|
|
110
|
+
* @param {string} context.file - Policy file, for messages
|
|
111
|
+
* @param {'organisation'|'project'} context.scope - Who owns the file
|
|
112
|
+
* @param {string} context.root - Project root, for the audit path
|
|
113
|
+
* @returns {{deny: string[], requireRedaction: boolean, maxExportTokens: number|null,
|
|
114
|
+
* allowedTargetModels: string[]|null, ignoreFiles: string[],
|
|
115
|
+
* audit: {enabled: boolean, path: string|null}}}
|
|
116
|
+
* @throws {PolicyConfigError}
|
|
117
|
+
*/
|
|
118
|
+
export function validatePolicyDocument(doc, { file, scope, root }) {
|
|
119
|
+
const errors = [];
|
|
120
|
+
const fail = (field, message) => errors.push({ field, message });
|
|
121
|
+
|
|
122
|
+
if (!isPlainObject(doc)) {
|
|
123
|
+
throw new PolicyConfigError(file, [
|
|
124
|
+
{ field: null, message: `must contain a JSON object (got ${show(doc)})` },
|
|
125
|
+
]);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Keys starting with "$" ($comment, $schema) are free-form notes: JSON has no comments
|
|
129
|
+
for (const key of Object.keys(doc)) {
|
|
130
|
+
if (!key.startsWith('$') && !FIELDS.includes(key)) {
|
|
131
|
+
fail(key, `is not a policy field (known fields: ${FIELDS.join(', ')})`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!('version' in doc)) {
|
|
136
|
+
fail('version', `is required (use ${POLICY_VERSION})`);
|
|
137
|
+
} else if (doc.version !== POLICY_VERSION) {
|
|
138
|
+
fail('version', `must be ${POLICY_VERSION} (got ${show(doc.version)})`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const deny = validateStringList(doc.deny, 'deny', fail, { allowNull: false }) ?? [];
|
|
142
|
+
deny.forEach((pattern, index) => {
|
|
143
|
+
const body = pattern.replace(/^!/, '');
|
|
144
|
+
if (!body || body === '/') {
|
|
145
|
+
fail(`deny[${index}]`, `is not a usable pattern (got ${show(pattern)})`);
|
|
146
|
+
} else if (/(^|[/\\])\.\.([/\\]|$)/.test(body)) {
|
|
147
|
+
fail(`deny[${index}]`, `must not contain ".." (patterns are relative to the project root)`);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
let requireRedaction = false;
|
|
152
|
+
if (doc.requireRedaction !== undefined) {
|
|
153
|
+
if (typeof doc.requireRedaction === 'boolean') requireRedaction = doc.requireRedaction;
|
|
154
|
+
else fail('requireRedaction', `must be true or false (got ${show(doc.requireRedaction)})`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let maxExportTokens = null;
|
|
158
|
+
if (doc.maxExportTokens !== undefined && doc.maxExportTokens !== null) {
|
|
159
|
+
if (Number.isInteger(doc.maxExportTokens) && doc.maxExportTokens > 0) {
|
|
160
|
+
maxExportTokens = doc.maxExportTokens;
|
|
161
|
+
} else {
|
|
162
|
+
fail(
|
|
163
|
+
'maxExportTokens',
|
|
164
|
+
`must be a positive integer or null (got ${show(doc.maxExportTokens)})`
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const allowedTargetModels = validateStringList(
|
|
170
|
+
doc.allowedTargetModels,
|
|
171
|
+
'allowedTargetModels',
|
|
172
|
+
fail,
|
|
173
|
+
{ allowNull: true }
|
|
174
|
+
);
|
|
175
|
+
if (Array.isArray(allowedTargetModels) && allowedTargetModels.length === 0) {
|
|
176
|
+
fail('allowedTargetModels', 'must list at least one model pattern; omit it to allow any model');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const ignoreFiles = validateStringList(doc.ignoreFiles, 'ignoreFiles', fail, {
|
|
180
|
+
allowNull: false,
|
|
181
|
+
});
|
|
182
|
+
(ignoreFiles ?? []).forEach((name, index) => {
|
|
183
|
+
if (name !== 'all' && !isValidIgnoreFileName(name)) {
|
|
184
|
+
fail(
|
|
185
|
+
`ignoreFiles[${index}]`,
|
|
186
|
+
`must be a file name in the project root or "all" (got ${show(name)})`
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const audit = validateAudit(doc.audit, { scope, root, fail });
|
|
192
|
+
|
|
193
|
+
if (errors.length > 0) throw new PolicyConfigError(file, errors);
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
deny,
|
|
197
|
+
requireRedaction,
|
|
198
|
+
maxExportTokens,
|
|
199
|
+
allowedTargetModels: allowedTargetModels ?? null,
|
|
200
|
+
ignoreFiles: resolveIgnoreFileNames(ignoreFiles ?? []),
|
|
201
|
+
audit,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function validateStringList(value, field, fail, { allowNull }) {
|
|
206
|
+
if (value === undefined || (allowNull && value === null)) return null;
|
|
207
|
+
if (!Array.isArray(value)) {
|
|
208
|
+
fail(field, `must be an array of strings${allowNull ? ' or null' : ''} (got ${show(value)})`);
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
const valid = [];
|
|
212
|
+
value.forEach((item, index) => {
|
|
213
|
+
if (typeof item === 'string' && item.trim()) valid.push(item.trim());
|
|
214
|
+
else fail(`${field}[${index}]`, `must be a non-empty string (got ${show(item)})`);
|
|
215
|
+
});
|
|
216
|
+
return valid;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function validateAudit(audit, { scope, root, fail }) {
|
|
220
|
+
if (audit === undefined || audit === null) return { enabled: false, path: null };
|
|
221
|
+
if (!isPlainObject(audit)) {
|
|
222
|
+
fail('audit', `must be an object like {"enabled": true} (got ${show(audit)})`);
|
|
223
|
+
return { enabled: false, path: null };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
for (const key of Object.keys(audit)) {
|
|
227
|
+
if (!AUDIT_FIELDS.includes(key)) {
|
|
228
|
+
fail(`audit.${key}`, `is not an audit field (known fields: ${AUDIT_FIELDS.join(', ')})`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let enabled = true;
|
|
233
|
+
if (audit.enabled !== undefined) {
|
|
234
|
+
if (typeof audit.enabled === 'boolean') enabled = audit.enabled;
|
|
235
|
+
else fail('audit.enabled', `must be true or false (got ${show(audit.enabled)})`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let logPath = DEFAULT_AUDIT_PATH;
|
|
239
|
+
if (audit.path !== undefined) {
|
|
240
|
+
if (typeof audit.path === 'string' && audit.path.trim()) logPath = audit.path.trim();
|
|
241
|
+
else fail('audit.path', `must be a non-empty string (got ${show(audit.path)})`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// A project file travels with the repository, so it may only write inside it; the
|
|
245
|
+
// organisation file comes from the user's environment and may name any absolute path
|
|
246
|
+
if (path.isAbsolute(logPath)) {
|
|
247
|
+
if (scope !== 'organisation') {
|
|
248
|
+
fail('audit.path', 'must be relative to the project root in a project policy');
|
|
249
|
+
return { enabled: false, path: null };
|
|
250
|
+
}
|
|
251
|
+
return { enabled, path: path.resolve(logPath) };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const resolved = path.resolve(root, logPath);
|
|
255
|
+
const relative = path.relative(root, resolved);
|
|
256
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
257
|
+
fail('audit.path', `must stay inside the project root (got ${show(audit.path)})`);
|
|
258
|
+
return { enabled: false, path: null };
|
|
259
|
+
}
|
|
260
|
+
return { enabled, path: resolved };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Read and validate one policy file
|
|
265
|
+
* @returns {{scope: string, file: string, rules: object}|null} Null when the file is absent
|
|
266
|
+
*/
|
|
267
|
+
function readPolicySource(file, scope, root, { required }) {
|
|
268
|
+
if (!fs.existsSync(file)) {
|
|
269
|
+
if (!required) return null;
|
|
270
|
+
throw new PolicyConfigError(file, [
|
|
271
|
+
{ field: null, message: `was not found (named by ${POLICY_ENV})` },
|
|
272
|
+
]);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
let text;
|
|
276
|
+
try {
|
|
277
|
+
text = fs.readFileSync(file, 'utf8');
|
|
278
|
+
} catch (error) {
|
|
279
|
+
throw new PolicyConfigError(file, [
|
|
280
|
+
{ field: null, message: `cannot be read: ${error.message}` },
|
|
281
|
+
]);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
let doc;
|
|
285
|
+
try {
|
|
286
|
+
doc = JSON.parse(text);
|
|
287
|
+
} catch (error) {
|
|
288
|
+
throw new PolicyConfigError(file, [
|
|
289
|
+
{ field: null, message: `is not valid JSON: ${error.message}` },
|
|
290
|
+
]);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return { scope, file, rules: validatePolicyDocument(doc, { file, scope, root }) };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Load the effective policy for a project: CTXMAN_POLICY (organisation) plus
|
|
298
|
+
* <root>/.ctxman/policy.json (project). Without either file the result is a permissive
|
|
299
|
+
* policy whose `active` is false.
|
|
300
|
+
* @param {string} [root=process.cwd()] - Project root
|
|
301
|
+
* @param {object} [options]
|
|
302
|
+
* @param {object} [options.env=process.env] - Environment to read CTXMAN_POLICY from
|
|
303
|
+
* @returns {Policy}
|
|
304
|
+
* @throws {PolicyConfigError} When a policy file is invalid, or CTXMAN_POLICY names a
|
|
305
|
+
* missing file
|
|
306
|
+
*/
|
|
307
|
+
export function loadPolicy(root = process.cwd(), { env = process.env } = {}) {
|
|
308
|
+
const absRoot = path.resolve(root);
|
|
309
|
+
const sources = [];
|
|
310
|
+
|
|
311
|
+
const orgSetting = typeof env?.[POLICY_ENV] === 'string' ? env[POLICY_ENV].trim() : '';
|
|
312
|
+
const orgFile = orgSetting ? path.resolve(orgSetting) : null;
|
|
313
|
+
if (orgFile) {
|
|
314
|
+
sources.push(readPolicySource(orgFile, 'organisation', absRoot, { required: true }));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const projectFile = path.join(absRoot, POLICY_FILE);
|
|
318
|
+
if (projectFile !== orgFile) {
|
|
319
|
+
const project = readPolicySource(projectFile, 'project', absRoot, { required: false });
|
|
320
|
+
if (project) sources.push(project);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return new Policy({ root: absRoot, sources });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The effective (merged) policy of a project
|
|
328
|
+
*/
|
|
329
|
+
export class Policy {
|
|
330
|
+
/**
|
|
331
|
+
* @param {object} params
|
|
332
|
+
* @param {string} params.root - Project root
|
|
333
|
+
* @param {Array<{scope: string, file: string, rules: object}>} [params.sources] - Validated
|
|
334
|
+
* files, organisation first
|
|
335
|
+
*/
|
|
336
|
+
constructor({ root, sources = [] }) {
|
|
337
|
+
const parser = new GitIgnoreParser(null, null, null);
|
|
338
|
+
this.root = path.resolve(root);
|
|
339
|
+
this.sources = sources.map(({ scope, file }) => ({ scope, file }));
|
|
340
|
+
this.active = sources.length > 0;
|
|
341
|
+
|
|
342
|
+
this.denyRules = sources
|
|
343
|
+
.filter((source) => source.rules.deny.length > 0)
|
|
344
|
+
.map(({ scope, file, rules }) => ({
|
|
345
|
+
scope,
|
|
346
|
+
file,
|
|
347
|
+
patterns: rules.deny,
|
|
348
|
+
compiled: rules.deny.map((pattern) => parser.convertToRegex(pattern)),
|
|
349
|
+
}));
|
|
350
|
+
|
|
351
|
+
this.requireRedaction = sources.some((source) => source.rules.requireRedaction);
|
|
352
|
+
this.requireRedactionSources = sources
|
|
353
|
+
.filter((source) => source.rules.requireRedaction)
|
|
354
|
+
.map((source) => source.file);
|
|
355
|
+
|
|
356
|
+
const limits = sources.filter((source) => source.rules.maxExportTokens !== null);
|
|
357
|
+
const strictest = limits.reduce(
|
|
358
|
+
(best, source) =>
|
|
359
|
+
!best || source.rules.maxExportTokens < best.rules.maxExportTokens ? source : best,
|
|
360
|
+
null
|
|
361
|
+
);
|
|
362
|
+
this.maxExportTokens = strictest ? strictest.rules.maxExportTokens : null;
|
|
363
|
+
this.maxExportTokensSource = strictest ? strictest.file : null;
|
|
364
|
+
|
|
365
|
+
this.modelRules = sources
|
|
366
|
+
.filter((source) => source.rules.allowedTargetModels)
|
|
367
|
+
.map(({ scope, file, rules }) => ({
|
|
368
|
+
scope,
|
|
369
|
+
file,
|
|
370
|
+
patterns: rules.allowedTargetModels,
|
|
371
|
+
regexes: rules.allowedTargetModels.map(modelGlobToRegex),
|
|
372
|
+
}));
|
|
373
|
+
|
|
374
|
+
this.ignoreFiles = [...new Set(sources.flatMap((source) => source.rules.ignoreFiles))];
|
|
375
|
+
|
|
376
|
+
const auditPaths = sources
|
|
377
|
+
.filter((source) => source.rules.audit.enabled && source.rules.audit.path)
|
|
378
|
+
.map((source) => source.rules.audit.path);
|
|
379
|
+
this.audit = { enabled: auditPaths.length > 0, paths: [...new Set(auditPaths)] };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Whether a file may never be exported. Each policy file is evaluated on its own with
|
|
384
|
+
* .gitignore syntax (the last matching line wins, `!` re-allows, `dir/` covers everything
|
|
385
|
+
* below); the path is denied when any file denies it, so a project file cannot re-allow
|
|
386
|
+
* what the organisation file denies.
|
|
387
|
+
* @param {string} relativePath - Path relative to the project root
|
|
388
|
+
* @returns {boolean}
|
|
389
|
+
*/
|
|
390
|
+
isDenied(relativePath) {
|
|
391
|
+
return this.deniedBy(relativePath) !== null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* @param {string} relativePath
|
|
396
|
+
* @returns {string|null} The policy file whose deny rules match, or null
|
|
397
|
+
*/
|
|
398
|
+
deniedBy(relativePath) {
|
|
399
|
+
const rel = normalizeRelativePath(relativePath);
|
|
400
|
+
if (!rel) return null;
|
|
401
|
+
|
|
402
|
+
for (const rule of this.denyRules) {
|
|
403
|
+
let denied = false;
|
|
404
|
+
for (const pattern of rule.compiled) {
|
|
405
|
+
if (pattern.regex.test(rel)) denied = !pattern.isNegation;
|
|
406
|
+
}
|
|
407
|
+
if (denied) return rule.file;
|
|
408
|
+
}
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Split file entries into the ones that may be exported and the denied ones
|
|
414
|
+
* @template T
|
|
415
|
+
* @param {T[]} items - Paths or objects with relativePath/path
|
|
416
|
+
* @returns {{kept: T[], removed: T[]}}
|
|
417
|
+
*/
|
|
418
|
+
filterFiles(items) {
|
|
419
|
+
const kept = [];
|
|
420
|
+
const removed = [];
|
|
421
|
+
for (const item of items) {
|
|
422
|
+
(this.isDenied(relativePathOf(item)) ? removed : kept).push(item);
|
|
423
|
+
}
|
|
424
|
+
return { kept, removed };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Whether exports may target this model; true when no file restricts models or no
|
|
429
|
+
* model was named
|
|
430
|
+
* @param {string|null|undefined} model
|
|
431
|
+
* @returns {boolean}
|
|
432
|
+
*/
|
|
433
|
+
isModelAllowed(model) {
|
|
434
|
+
if (!model) return true;
|
|
435
|
+
return this.modelRules.every((rule) => rule.regexes.some((regex) => regex.test(model)));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Whether a project-relative path is one of ctxman's audit logs, which must never be
|
|
440
|
+
* exported themselves
|
|
441
|
+
* @param {string} relativePath
|
|
442
|
+
* @returns {boolean}
|
|
443
|
+
*/
|
|
444
|
+
isAuditLog(relativePath) {
|
|
445
|
+
const rel = normalizeRelativePath(relativePath);
|
|
446
|
+
if (!rel) return false;
|
|
447
|
+
if (rel === DEFAULT_AUDIT_PATH) return true;
|
|
448
|
+
const absolute = path.resolve(this.root, rel);
|
|
449
|
+
return this.audit.paths.includes(absolute);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Plain-object view of the effective policy (for `ctxman policy check --json`)
|
|
454
|
+
* @returns {object}
|
|
455
|
+
*/
|
|
456
|
+
toJSON() {
|
|
457
|
+
return {
|
|
458
|
+
active: this.active,
|
|
459
|
+
root: this.root,
|
|
460
|
+
sources: this.sources,
|
|
461
|
+
deny: this.denyRules.map(({ scope, file, patterns }) => ({ scope, file, patterns })),
|
|
462
|
+
requireRedaction: this.requireRedaction,
|
|
463
|
+
maxExportTokens: this.maxExportTokens,
|
|
464
|
+
allowedTargetModels:
|
|
465
|
+
this.modelRules.length > 0
|
|
466
|
+
? this.modelRules.map(({ scope, file, patterns }) => ({ scope, file, patterns }))
|
|
467
|
+
: null,
|
|
468
|
+
ignoreFiles: this.ignoreFiles,
|
|
469
|
+
audit: this.audit,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Starter policy written by `ctxman policy init`
|
|
476
|
+
* @returns {object}
|
|
477
|
+
*/
|
|
478
|
+
export function createStarterPolicy() {
|
|
479
|
+
return {
|
|
480
|
+
$comment: `ctxman export policy. Files matching "deny" are never exported; set a field to null or remove it to lift that limit. Reference: ${POLICY_DOCS_URL}`,
|
|
481
|
+
version: POLICY_VERSION,
|
|
482
|
+
deny: ['.env', '.env.*', '!.env.example', '**/*.pem', '**/*.key', '**/id_rsa*', 'secrets/'],
|
|
483
|
+
requireRedaction: true,
|
|
484
|
+
maxExportTokens: null,
|
|
485
|
+
allowedTargetModels: null,
|
|
486
|
+
ignoreFiles: [],
|
|
487
|
+
audit: { enabled: true, path: DEFAULT_AUDIT_PATH },
|
|
488
|
+
};
|
|
489
|
+
}
|