Skip to content

Commit c66d9bc

Browse files
enforce context-only reads from canonical inventory
1 parent ed49f3b commit c66d9bc

1 file changed

Lines changed: 35 additions & 76 deletions

File tree

Lines changed: 35 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,98 +1,57 @@
11
import fs from 'node:fs';
22
import path from 'node:path';
3-
import { spawnSync } from 'node:child_process';
43
import { active, readStdinJson, resolveWorkspacePath, isWithin, deny } from './docgen-common.mjs';
54

65
const payload = await readStdinJson();
76
if (!active()) process.exit(0);
7+
88
const cwd = path.resolve(payload.cwd ?? process.env.COMMANDCODE_PROJECT_DIR ?? process.cwd());
99
const input = payload.tool_input ?? {};
1010
const toolName = String(payload.tool_name ?? payload.tool ?? '').toLowerCase();
11+
const stage = String(process.env.DOCGEN_STAGE ?? '');
12+
const contextOnly = ['1', 'true', 'yes', 'on'].includes(String(process.env.DOCGEN_CONTEXT_ONLY ?? '').toLowerCase());
13+
const norm = (value) => String(value ?? '').replaceAll('\\', '/').replace(/^\.\//, '').replace(/^\/+/, '').replace(/\/+$/, '');
1114

12-
function norm(value) { return String(value ?? '').replaceAll('\\', '/').replace(/^\.\//, '').replace(/^\/+/, '').replace(/\/+$/, ''); }
13-
function esc(value) { return value.replace(/[|\\{}()[\]^$+?.]/g, '\\$&'); }
14-
function patternRegex(pattern, anchored = false) {
15-
let body='';
16-
for(let i=0;i<pattern.length;i++){
17-
if(pattern[i]==='*'){ if(pattern[i+1]==='*'){while(pattern[i+1]==='*')i++;body+='.*';}else body+='[^/]*'; }
18-
else if(pattern[i]==='?') body+='[^/]'; else body+=esc(pattern[i]);
19-
}
20-
return new RegExp(anchored?`^${body}(?:/.*)?$`:`(?:^|/)${body}(?:/.*)?$`);
21-
}
22-
function rules(file){
23-
if(!fs.existsSync(file))return[];
24-
return fs.readFileSync(file,'utf8').split(/\r?\n/).map((raw)=>{
25-
let line=raw.trim(); if(!line||line.startsWith('#'))return null; let negated=false;
26-
if(line.startsWith('!')){negated=true;line=line.slice(1);} const directoryOnly=line.endsWith('/');
27-
if(directoryOnly)line=line.replace(/\/+$/,''); const anchored=line.startsWith('/'); if(anchored)line=line.slice(1);
28-
return {raw,negated,directoryOnly,pattern:line,regex:patternRegex(line,anchored)};
29-
}).filter(Boolean);
30-
}
31-
function matchRules(rel,isDir,items){let decision=null;for(const rule of items){if(rule.directoryOnly&&!isDir&&!rel.startsWith(`${rule.pattern}/`)&&!rel.includes(`/${rule.pattern}/`))continue;if(rule.regex.test(rel))decision={ignored:!rule.negated,reason:`.docgenignore:${rule.raw}`};}return decision;}
32-
function config(){try{return JSON.parse(fs.readFileSync(path.join(cwd,'.docgen','config','documentation.json'),'utf8'));}catch{return{};}}
33-
const binaryExt=new Set(['.png','.jpg','.jpeg','.gif','.webp','.bmp','.ico','.tif','.tiff','.avif','.heic','.psd','.mp3','.wav','.flac','.aac','.ogg','.m4a','.mp4','.mov','.avi','.mkv','.webm','.wmv','.pdf','.doc','.docx','.xls','.xlsx','.ppt','.pptx','.zip','.gz','.tgz','.bz2','.xz','.7z','.rar','.tar','.jar','.war','.ear','.class','.dll','.exe','.so','.dylib','.o','.a','.lib','.woff','.woff2','.ttf','.otf','.eot','.bin','.dat','.db','.sqlite','.sqlite3','.p12','.pfx','.jks','.keystore','.apk','.ipa','.iso','.dmg','.img','.wasm','.pyc']);
34-
function binaryDecision(target,rel,cfg){
35-
if(cfg.ignore?.binary?.enabled===false||!fs.existsSync(target)||!fs.statSync(target).isFile())return null;
36-
const ext=path.extname(rel).toLowerCase();const deny=new Set((cfg.ignore?.binary?.denyExtensions??[]).map((x)=>String(x).toLowerCase()));
37-
const allow=new Set([...(cfg.sourceExtensions??[]),...(cfg.ignore?.binary?.allowExtensions??[])].map((x)=>String(x).toLowerCase()));
38-
if(deny.has(ext)||(!allow.has(ext)&&binaryExt.has(ext)))return `binary-extension:${ext||'<none>'}`;
39-
const stat=fs.statSync(target);const max=Number(cfg.ignore?.binary?.maxTextFileBytes??4194304);if(stat.size>max)return `text-file-too-large:${stat.size}`;
40-
let buf;try{const fd=fs.openSync(target,'r');buf=Buffer.alloc(Math.min(Number(cfg.ignore?.binary?.probeBytes??16384),stat.size));const n=fs.readSync(fd,buf,0,buf.length,0);fs.closeSync(fd);buf=buf.subarray(0,n);}catch{return'non-text-unreadable';}
41-
const sig=(...xs)=>xs.every((b,i)=>buf[i]===b);if(sig(0x89,0x50,0x4e,0x47)||sig(0xff,0xd8,0xff)||sig(0x25,0x50,0x44,0x46)||sig(0x50,0x4b,0x03,0x04)||sig(0x1f,0x8b)||sig(0x7f,0x45,0x4c,0x46)||sig(0x4d,0x5a)||sig(0x00,0x61,0x73,0x6d))return'binary-magic-signature';
42-
if(buf.includes(0))return'binary-null-byte';
43-
try{new TextDecoder('utf-8',{fatal:true}).decode(buf);}catch{return'non-utf8-content';}
44-
return null;
45-
}
46-
function configExcluded(rel,cfg){for(const raw of cfg.exclude??[]){const p=String(raw).replaceAll('\\','/').replace(/^\.\//,'');const cleaned=p.replace(/\/\*\*$/,'').replace(/\/+$/,'');if(patternRegex(cleaned,cleaned.startsWith('/')).test(rel)||rel===cleaned||rel.startsWith(`${cleaned}/`))return `config.exclude:${raw}`;}return null;}
47-
function gitRepositoryAvailable(){
48-
if(fs.existsSync(path.join(cwd,'.git')))return true;
49-
const r=spawnSync('git',['rev-parse','--is-inside-work-tree'],{cwd,encoding:'utf8',stdio:['ignore','pipe','ignore'],shell:process.platform==='win32'});
50-
return r.status===0&&String(r.stdout??'').trim()==='true';
15+
function inventoryFiles() {
16+
const file = path.join(cwd, '.docgen', 'index', 'inventory.json');
17+
try { return new Set((JSON.parse(fs.readFileSync(file, 'utf8')).files ?? []).map((item) => norm(item.path))); }
18+
catch { return null; }
5119
}
52-
function fallbackGitIgnored(rel){
53-
const seg=norm(rel).split('/');let ignored=false;
54-
for(let depth=0;depth<seg.length;depth++){const base=seg.slice(0,depth).join('/');const file=path.join(cwd,base,'.gitignore');const m=matchRules(seg.slice(depth).join('/'),false,rules(file));if(m)ignored=m.ignored;}
55-
return ignored;
56-
}
57-
function gitIgnored(rel){
58-
if(!gitRepositoryAvailable())return fallbackGitIgnored(rel);
59-
const r=spawnSync('git',['check-ignore','--no-index','-q','--',rel],{cwd,stdio:'ignore',shell:process.platform==='win32'});
60-
if(r.status===0)return true;
61-
if(r.error?.code==='ENOENT'||r.status===127)return fallbackGitIgnored(rel);
20+
21+
function contextAllowed(rel) {
22+
if (rel === '.docgen/context' || rel.startsWith('.docgen/context/')) return true;
23+
if (stage === 'modelCore' || stage === 'modelEnterprise') return rel === '.docgen/model' || rel.startsWith('.docgen/model/');
24+
if (stage === 'plan') return rel === '.docgen/plan' || rel.startsWith('.docgen/plan/');
25+
if (stage === 'generate') return rel === 'docs' || rel.startsWith('docs/') || rel === '.docgen/traceability' || rel.startsWith('.docgen/traceability/');
26+
if (stage === 'audit') return rel === 'docs' || rel.startsWith('docs/') || rel === '.docgen/audit' || rel.startsWith('.docgen/audit/');
6227
return false;
6328
}
64-
function decision(rawPath){
65-
const target=resolveWorkspacePath(rawPath,cwd); if(!target)return null;
66-
if(!isWithin(target,cwd))return {ignored:true,reason:'outside repository workspace'};
67-
const rel=norm(path.relative(cwd,target));
68-
if(rel==='.docgen'||rel.startsWith('.docgen/')||rel==='docs'||rel.startsWith('docs/'))return {ignored:false};
69-
const cfg=config(); const hard=['.git','.commandcode','node_modules','target','build','dist','coverage','vendor'];
70-
for(const prefix of hard)if(rel===prefix||rel.startsWith(`${prefix}/`))return {ignored:true,reason:`docgen-hard-exclude:${prefix}/**`};
71-
const ce=configExcluded(rel,cfg);if(ce)return{ignored:true,reason:ce};
72-
if(cfg.ignore?.useGitignore!==false&&gitIgnored(rel))return{ignored:true,reason:'.gitignore'};
73-
if(cfg.ignore?.useDocgenignore!==false){const file=path.join(cwd,cfg.ignore?.docgenignoreFile||'.docgenignore');const d=matchRules(rel,fs.existsSync(target)&&fs.statSync(target).isDirectory(),rules(file));if(d)return d;}
74-
const binary=binaryDecision(target,rel,cfg);if(binary)return{ignored:true,reason:binary};
75-
return {ignored:false};
76-
}
7729

78-
if (['grep','glob','read_multiple_files'].some((x)=>toolName.includes(x))) {
30+
if (['grep', 'glob', 'read_multiple_files', 'read_directory'].some((name) => toolName.includes(name))) {
7931
const explicit = input.path ?? input.directory ?? input.file_path;
8032
const pattern = input.pattern ?? input.glob ?? input.query;
81-
if (!explicit || (typeof pattern === 'string' && /[?*\[]/.test(pattern))) {
82-
deny(`DocGen blocks broad ${toolName || 'read/search'} operations because they may include ignored files. Use .docgen/state/source-files.txt, explicit read_file calls, or \`docgen source-grep <text>\`.`);
33+
if (!explicit || typeof pattern === 'string' && /[?*\[]/.test(pattern)) {
34+
deny(`DocGen blocks broad ${toolName || 'read/search'} operations. Read one explicit bounded context or inventory-approved source path.`);
8335
process.exit(0);
8436
}
8537
}
8638

87-
const rawPaths=[];
88-
for(const key of ['file_path','path','directory']) if(typeof input[key]==='string') rawPaths.push(input[key]);
89-
for(const key of ['paths','files']) if(Array.isArray(input[key])) rawPaths.push(...input[key].filter((x)=>typeof x==='string'));
90-
for(const key of ['pattern','glob']) if(typeof input[key]==='string') {
91-
const value=input[key];
92-
if(/[?*\[]/.test(value) && !norm(value).startsWith('.docgen/') && !norm(value).startsWith('docs/')) {
93-
deny(`DocGen blocks wildcard source reads because they may bypass .gitignore/.docgenignore. Read .docgen/state/source-files.txt, then read explicit included paths. Blocked pattern: ${value}`);
94-
process.exit(0);
39+
const rawPaths = [];
40+
for (const key of ['file_path', 'path', 'directory']) if (typeof input[key] === 'string') rawPaths.push(input[key]);
41+
for (const key of ['paths', 'files']) if (Array.isArray(input[key])) rawPaths.push(...input[key].filter((value) => typeof value === 'string'));
42+
for (const key of ['pattern', 'glob']) if (typeof input[key] === 'string') rawPaths.push(input[key]);
43+
44+
const included = inventoryFiles();
45+
for (const raw of rawPaths) {
46+
if (/[?*\[]/.test(raw)) { deny(`DocGen blocks wildcard reads: ${raw}`); process.exit(0); }
47+
const target = resolveWorkspacePath(raw, cwd); if (!target) continue;
48+
if (!isWithin(target, cwd)) { deny(`DocGen blocks reads outside the repository: ${raw}`); process.exit(0); }
49+
const rel = norm(path.relative(cwd, target));
50+
if (contextOnly) {
51+
if (!contextAllowed(rel)) { deny(`Context-only ${stage || 'provider'} run cannot read ${raw}. Allowed inputs are precompiled .docgen/context packs and stage outputs only.`); process.exit(0); }
52+
continue;
9553
}
96-
rawPaths.push(value);
54+
if (rel === '.docgen' || rel.startsWith('.docgen/') || rel === 'docs' || rel.startsWith('docs/')) continue;
55+
if (!included) { deny('Source inventory is missing. Run `docgen index` before reading repository source.'); process.exit(0); }
56+
if (!included.has(rel)) { deny(`DocGen will not read a path outside the canonical source inventory: ${raw}. Inventory: .docgen/index/source-files.txt`); process.exit(0); }
9757
}
98-
for(const raw of rawPaths){const d=decision(raw);if(d?.ignored){deny(`DocGen will not read ignored path: ${raw} (${d.reason}). Active source inventory: .docgen/state/source-files.txt`);process.exit(0);}}

0 commit comments

Comments
 (0)