@r1z4x/tezgah 1.0.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/CHANGELOG.md +738 -0
- package/LICENSE +21 -0
- package/MANIFEST +753 -0
- package/README.de.md +169 -0
- package/README.es.md +168 -0
- package/README.fr.md +166 -0
- package/README.md +161 -0
- package/README.tr.md +163 -0
- package/agents/tezgah-reviewer.md +70 -0
- package/bin/codegen +337 -0
- package/bin/consult +797 -0
- package/bin/tezgah-adhd +50 -0
- package/bin/tezgah-agents +45 -0
- package/bin/tezgah-capture +66 -0
- package/bin/tezgah-context +96 -0
- package/bin/tezgah-decisions +213 -0
- package/bin/tezgah-design +953 -0
- package/bin/tezgah-docs +1199 -0
- package/bin/tezgah-doctor +497 -0
- package/bin/tezgah-dsh +37 -0
- package/bin/tezgah-dsh.cmd +70 -0
- package/bin/tezgah-gate +75 -0
- package/bin/tezgah-import-ai-research +439 -0
- package/bin/tezgah-index +32 -0
- package/bin/tezgah-lessons +148 -0
- package/bin/tezgah-mcp +322 -0
- package/bin/tezgah-migrate +324 -0
- package/bin/tezgah-pony +50 -0
- package/bin/tezgah-research +772 -0
- package/bin/tezgah-rollback +93 -0
- package/bin/tezgah-route +124 -0
- package/bin/tezgah-setup +5798 -0
- package/bin/tezgah-status +383 -0
- package/bin/tezgah-task +607 -0
- package/bin/tezgah-taste +423 -0
- package/bin/tezgah-triage +485 -0
- package/bin/tezgah.js +36 -0
- package/commands/adhd.md +21 -0
- package/commands/ponytail.md +21 -0
- package/docs/README.md +112 -0
- package/docs/architecture.md +226 -0
- package/docs/citations-baseline.json +59 -0
- package/docs/clarity-baseline.json +116 -0
- package/docs/contract.md +416 -0
- package/docs/environment.md +141 -0
- package/docs/evidence.md +831 -0
- package/docs/feature-audit.md +139 -0
- package/docs/gate.md +407 -0
- package/docs/glossary.md +201 -0
- package/docs/hosts.md +400 -0
- package/docs/index.json +471 -0
- package/docs/judge.md +193 -0
- package/docs/layers.md +189 -0
- package/docs/models.md +160 -0
- package/docs/operations.md +666 -0
- package/docs/research.md +809 -0
- package/docs/results/paired-outcome-2026-10.jsonl +254 -0
- package/docs/results/paired-outcome-2026-10.md +204 -0
- package/docs/skills.md +236 -0
- package/docs/status-line.md +315 -0
- package/docs/testing.md +287 -0
- package/docs/vision.md +117 -0
- package/hooks/hooks.json +33 -0
- package/hooks/projects-auto-init.py +98 -0
- package/hooks/projects-posttooluse.py +149 -0
- package/hooks/projects-pretooluse.py +40 -0
- package/hooks/projects-stop.py +60 -0
- package/hooks/tezgah_agents.py +817 -0
- package/hooks/tezgah_apps.py +266 -0
- package/hooks/tezgah_attest.py +255 -0
- package/hooks/tezgah_clarity.py +171 -0
- package/hooks/tezgah_components.py +140 -0
- package/hooks/tezgah_context.py +2953 -0
- package/hooks/tezgah_embed.py +765 -0
- package/hooks/tezgah_gate.py +2736 -0
- package/hooks/tezgah_guard.py +177 -0
- package/hooks/tezgah_index.py +124 -0
- package/hooks/tezgah_integrity.py +5141 -0
- package/hooks/tezgah_judge.py +487 -0
- package/hooks/tezgah_lang.py +230 -0
- package/hooks/tezgah_lessons.py +210 -0
- package/hooks/tezgah_models.py +855 -0
- package/hooks/tezgah_paths.py +1132 -0
- package/hooks/tezgah_policy.py +1039 -0
- package/hooks/tezgah_rank.py +77 -0
- package/hooks/tezgah_replay.py +1109 -0
- package/hooks/tezgah_research.py +5176 -0
- package/hooks/tezgah_shapes.py +305 -0
- package/hooks/tezgah_skill_pick.py +227 -0
- package/hooks/tezgah_snapshot.py +490 -0
- package/hooks/tezgah_stopspec.py +320 -0
- package/hooks/tezgah_task.py +458 -0
- package/hooks/tezgah_taste.py +201 -0
- package/hooks/tezgah_untrusted.py +112 -0
- package/hooks/tezgah_update.py +430 -0
- package/hosts/codex/hook.py +268 -0
- package/hosts/codex/hooks.json +26 -0
- package/hosts/cursor/hook.py +461 -0
- package/hosts/cursor/hooks.json +44 -0
- package/hosts/dsh/hooks.json +21 -0
- package/hosts/dsh/statusline/lib/client.js +138 -0
- package/hosts/dsh/statusline/lib/index.js +104 -0
- package/hosts/dsh/statusline/package.json +28 -0
- package/hosts/omp/hook.py +277 -0
- package/hosts/omp/tezgah-hook.ts.in +701 -0
- package/hosts/opencode/plugins/tezgah.js +2742 -0
- package/hosts/opencode/tui/tezgah-tui.tsx +151 -0
- package/output-styles/tezgah.md +142 -0
- package/package.json +32 -0
- package/packaging/build.sh +342 -0
- package/packaging/install.ps1 +166 -0
- package/packaging/install.sh +56 -0
- package/packaging/upgrade.sh +142 -0
- package/skills/ai-research/0-autoresearch-skill/SKILL.md +411 -0
- package/skills/ai-research/0-autoresearch-skill/references/agent-continuity.md +95 -0
- package/skills/ai-research/0-autoresearch-skill/references/progress-reporting.md +166 -0
- package/skills/ai-research/0-autoresearch-skill/references/skill-routing.md +219 -0
- package/skills/ai-research/0-autoresearch-skill/templates/findings.md +44 -0
- package/skills/ai-research/0-autoresearch-skill/templates/research-log.md +41 -0
- package/skills/ai-research/01-model-architecture/litgpt/SKILL.md +469 -0
- package/skills/ai-research/01-model-architecture/litgpt/references/custom-models.md +569 -0
- package/skills/ai-research/01-model-architecture/litgpt/references/distributed-training.md +452 -0
- package/skills/ai-research/01-model-architecture/litgpt/references/supported-models.md +337 -0
- package/skills/ai-research/01-model-architecture/litgpt/references/training-recipes.md +620 -0
- package/skills/ai-research/01-model-architecture/mamba/SKILL.md +260 -0
- package/skills/ai-research/01-model-architecture/mamba/references/architecture-details.md +207 -0
- package/skills/ai-research/01-model-architecture/mamba/references/benchmarks.md +256 -0
- package/skills/ai-research/01-model-architecture/mamba/references/training-guide.md +389 -0
- package/skills/ai-research/01-model-architecture/nanogpt/SKILL.md +290 -0
- package/skills/ai-research/01-model-architecture/nanogpt/references/architecture.md +383 -0
- package/skills/ai-research/01-model-architecture/nanogpt/references/data.md +477 -0
- package/skills/ai-research/01-model-architecture/nanogpt/references/training.md +565 -0
- package/skills/ai-research/01-model-architecture/rwkv/SKILL.md +260 -0
- package/skills/ai-research/01-model-architecture/rwkv/references/architecture-details.md +345 -0
- package/skills/ai-research/01-model-architecture/rwkv/references/rwkv7.md +387 -0
- package/skills/ai-research/01-model-architecture/rwkv/references/state-management.md +370 -0
- package/skills/ai-research/01-model-architecture/torchtitan/SKILL.md +358 -0
- package/skills/ai-research/01-model-architecture/torchtitan/references/checkpoint.md +182 -0
- package/skills/ai-research/01-model-architecture/torchtitan/references/custom-models.md +259 -0
- package/skills/ai-research/01-model-architecture/torchtitan/references/float8.md +134 -0
- package/skills/ai-research/01-model-architecture/torchtitan/references/fsdp.md +127 -0
- package/skills/ai-research/02-tokenization/huggingface-tokenizers/SKILL.md +516 -0
- package/skills/ai-research/02-tokenization/huggingface-tokenizers/references/algorithms.md +654 -0
- package/skills/ai-research/02-tokenization/huggingface-tokenizers/references/integration.md +638 -0
- package/skills/ai-research/02-tokenization/huggingface-tokenizers/references/pipeline.md +724 -0
- package/skills/ai-research/02-tokenization/huggingface-tokenizers/references/training.md +566 -0
- package/skills/ai-research/02-tokenization/sentencepiece/SKILL.md +235 -0
- package/skills/ai-research/02-tokenization/sentencepiece/references/algorithms.md +201 -0
- package/skills/ai-research/02-tokenization/sentencepiece/references/training.md +305 -0
- package/skills/ai-research/03-fine-tuning/axolotl/SKILL.md +158 -0
- package/skills/ai-research/03-fine-tuning/axolotl/references/api.md +5549 -0
- package/skills/ai-research/03-fine-tuning/axolotl/references/dataset-formats.md +1030 -0
- package/skills/ai-research/03-fine-tuning/axolotl/references/index.md +16 -0
- package/skills/ai-research/03-fine-tuning/axolotl/references/other.md +3564 -0
- package/skills/ai-research/03-fine-tuning/llama-factory/SKILL.md +80 -0
- package/skills/ai-research/03-fine-tuning/llama-factory/references/_images.md +24 -0
- package/skills/ai-research/03-fine-tuning/llama-factory/references/advanced.md +1056 -0
- package/skills/ai-research/03-fine-tuning/llama-factory/references/getting_started.md +350 -0
- package/skills/ai-research/03-fine-tuning/llama-factory/references/index.md +20 -0
- package/skills/ai-research/03-fine-tuning/llama-factory/references/other.md +32 -0
- package/skills/ai-research/03-fine-tuning/peft/SKILL.md +431 -0
- package/skills/ai-research/03-fine-tuning/peft/references/advanced-usage.md +515 -0
- package/skills/ai-research/03-fine-tuning/peft/references/troubleshooting.md +481 -0
- package/skills/ai-research/03-fine-tuning/unsloth/SKILL.md +80 -0
- package/skills/ai-research/03-fine-tuning/unsloth/references/index.md +8 -0
- package/skills/ai-research/03-fine-tuning/unsloth/references/llms.md +83 -0
- package/skills/ai-research/04-mechanistic-interpretability/nnsight/SKILL.md +436 -0
- package/skills/ai-research/04-mechanistic-interpretability/nnsight/references/README.md +79 -0
- package/skills/ai-research/04-mechanistic-interpretability/nnsight/references/api.md +345 -0
- package/skills/ai-research/04-mechanistic-interpretability/nnsight/references/tutorials.md +301 -0
- package/skills/ai-research/04-mechanistic-interpretability/pyvene/SKILL.md +473 -0
- package/skills/ai-research/04-mechanistic-interpretability/pyvene/references/README.md +74 -0
- package/skills/ai-research/04-mechanistic-interpretability/pyvene/references/api.md +384 -0
- package/skills/ai-research/04-mechanistic-interpretability/pyvene/references/tutorials.md +377 -0
- package/skills/ai-research/04-mechanistic-interpretability/saelens/SKILL.md +386 -0
- package/skills/ai-research/04-mechanistic-interpretability/saelens/references/README.md +71 -0
- package/skills/ai-research/04-mechanistic-interpretability/saelens/references/api.md +334 -0
- package/skills/ai-research/04-mechanistic-interpretability/saelens/references/tutorials.md +319 -0
- package/skills/ai-research/04-mechanistic-interpretability/transformer-lens/SKILL.md +346 -0
- package/skills/ai-research/04-mechanistic-interpretability/transformer-lens/references/README.md +55 -0
- package/skills/ai-research/04-mechanistic-interpretability/transformer-lens/references/api.md +363 -0
- package/skills/ai-research/04-mechanistic-interpretability/transformer-lens/references/tutorials.md +340 -0
- package/skills/ai-research/05-data-processing/nemo-curator/SKILL.md +383 -0
- package/skills/ai-research/05-data-processing/nemo-curator/references/deduplication.md +88 -0
- package/skills/ai-research/05-data-processing/nemo-curator/references/filtering.md +103 -0
- package/skills/ai-research/05-data-processing/ray-data/SKILL.md +326 -0
- package/skills/ai-research/05-data-processing/ray-data/references/integration.md +83 -0
- package/skills/ai-research/05-data-processing/ray-data/references/transformations.md +84 -0
- package/skills/ai-research/06-post-training/grpo-rl-training/README.md +98 -0
- package/skills/ai-research/06-post-training/grpo-rl-training/SKILL.md +572 -0
- package/skills/ai-research/06-post-training/miles/SKILL.md +315 -0
- package/skills/ai-research/06-post-training/miles/references/api-reference.md +142 -0
- package/skills/ai-research/06-post-training/miles/references/troubleshooting.md +353 -0
- package/skills/ai-research/06-post-training/openrlhf/SKILL.md +249 -0
- package/skills/ai-research/06-post-training/openrlhf/references/algorithm-comparison.md +405 -0
- package/skills/ai-research/06-post-training/openrlhf/references/custom-rewards.md +531 -0
- package/skills/ai-research/06-post-training/openrlhf/references/hybrid-engine.md +288 -0
- package/skills/ai-research/06-post-training/openrlhf/references/multi-node-training.md +455 -0
- package/skills/ai-research/06-post-training/simpo/SKILL.md +219 -0
- package/skills/ai-research/06-post-training/simpo/references/datasets.md +479 -0
- package/skills/ai-research/06-post-training/simpo/references/hyperparameters.md +453 -0
- package/skills/ai-research/06-post-training/simpo/references/loss-functions.md +351 -0
- package/skills/ai-research/06-post-training/slime/SKILL.md +464 -0
- package/skills/ai-research/06-post-training/slime/references/api-reference.md +393 -0
- package/skills/ai-research/06-post-training/slime/references/troubleshooting.md +387 -0
- package/skills/ai-research/06-post-training/torchforge/SKILL.md +433 -0
- package/skills/ai-research/06-post-training/torchforge/references/api-reference.md +328 -0
- package/skills/ai-research/06-post-training/torchforge/references/troubleshooting.md +410 -0
- package/skills/ai-research/06-post-training/trl-fine-tuning/SKILL.md +455 -0
- package/skills/ai-research/06-post-training/trl-fine-tuning/references/dpo-variants.md +228 -0
- package/skills/ai-research/06-post-training/trl-fine-tuning/references/online-rl.md +83 -0
- package/skills/ai-research/06-post-training/trl-fine-tuning/references/reward-modeling.md +123 -0
- package/skills/ai-research/06-post-training/trl-fine-tuning/references/sft-training.md +169 -0
- package/skills/ai-research/06-post-training/verl/SKILL.md +391 -0
- package/skills/ai-research/06-post-training/verl/references/api-reference.md +302 -0
- package/skills/ai-research/06-post-training/verl/references/troubleshooting.md +392 -0
- package/skills/ai-research/07-safety-alignment/constitutional-ai/SKILL.md +290 -0
- package/skills/ai-research/07-safety-alignment/llamaguard/SKILL.md +337 -0
- package/skills/ai-research/07-safety-alignment/nemo-guardrails/SKILL.md +297 -0
- package/skills/ai-research/07-safety-alignment/prompt-guard/SKILL.md +312 -0
- package/skills/ai-research/08-distributed-training/accelerate/SKILL.md +332 -0
- package/skills/ai-research/08-distributed-training/accelerate/references/custom-plugins.md +454 -0
- package/skills/ai-research/08-distributed-training/accelerate/references/megatron-integration.md +490 -0
- package/skills/ai-research/08-distributed-training/accelerate/references/performance.md +526 -0
- package/skills/ai-research/08-distributed-training/deepspeed/SKILL.md +141 -0
- package/skills/ai-research/08-distributed-training/deepspeed/references/08.md +18 -0
- package/skills/ai-research/08-distributed-training/deepspeed/references/09.md +174 -0
- package/skills/ai-research/08-distributed-training/deepspeed/references/2020.md +379 -0
- package/skills/ai-research/08-distributed-training/deepspeed/references/2023.md +280 -0
- package/skills/ai-research/08-distributed-training/deepspeed/references/assets.md +180 -0
- package/skills/ai-research/08-distributed-training/deepspeed/references/index.md +36 -0
- package/skills/ai-research/08-distributed-training/deepspeed/references/mii.md +119 -0
- package/skills/ai-research/08-distributed-training/deepspeed/references/other.md +1192 -0
- package/skills/ai-research/08-distributed-training/megatron-core/SKILL.md +366 -0
- package/skills/ai-research/08-distributed-training/megatron-core/references/benchmarks.md +250 -0
- package/skills/ai-research/08-distributed-training/megatron-core/references/parallelism-guide.md +405 -0
- package/skills/ai-research/08-distributed-training/megatron-core/references/production-examples.md +474 -0
- package/skills/ai-research/08-distributed-training/megatron-core/references/training-recipes.md +548 -0
- package/skills/ai-research/08-distributed-training/pytorch-fsdp2/SKILL.md +231 -0
- package/skills/ai-research/08-distributed-training/pytorch-fsdp2/references/pytorch_dcp_async_recipe.md +14 -0
- package/skills/ai-research/08-distributed-training/pytorch-fsdp2/references/pytorch_dcp_overview.md +19 -0
- package/skills/ai-research/08-distributed-training/pytorch-fsdp2/references/pytorch_dcp_recipe.md +18 -0
- package/skills/ai-research/08-distributed-training/pytorch-fsdp2/references/pytorch_ddp_notes.md +10 -0
- package/skills/ai-research/08-distributed-training/pytorch-fsdp2/references/pytorch_device_mesh_tutorial.md +22 -0
- package/skills/ai-research/08-distributed-training/pytorch-fsdp2/references/pytorch_examples_fsdp2.md +15 -0
- package/skills/ai-research/08-distributed-training/pytorch-fsdp2/references/pytorch_fsdp1_api.md +9 -0
- package/skills/ai-research/08-distributed-training/pytorch-fsdp2/references/pytorch_fsdp2_tutorial.md +45 -0
- package/skills/ai-research/08-distributed-training/pytorch-fsdp2/references/pytorch_fully_shard_api.md +49 -0
- package/skills/ai-research/08-distributed-training/pytorch-fsdp2/references/pytorch_tp_tutorial.md +22 -0
- package/skills/ai-research/08-distributed-training/pytorch-fsdp2/references/ray_train_fsdp2_example.md +13 -0
- package/skills/ai-research/08-distributed-training/pytorch-fsdp2/references/torchtitan_fsdp_notes.md +14 -0
- package/skills/ai-research/08-distributed-training/pytorch-lightning/SKILL.md +346 -0
- package/skills/ai-research/08-distributed-training/pytorch-lightning/references/callbacks.md +437 -0
- package/skills/ai-research/08-distributed-training/pytorch-lightning/references/distributed.md +491 -0
- package/skills/ai-research/08-distributed-training/pytorch-lightning/references/hyperparameter-tuning.md +557 -0
- package/skills/ai-research/08-distributed-training/ray-train/SKILL.md +406 -0
- package/skills/ai-research/08-distributed-training/ray-train/references/multi-node.md +629 -0
- package/skills/ai-research/09-infrastructure/lambda-labs/SKILL.md +545 -0
- package/skills/ai-research/09-infrastructure/lambda-labs/references/advanced-usage.md +612 -0
- package/skills/ai-research/09-infrastructure/lambda-labs/references/troubleshooting.md +531 -0
- package/skills/ai-research/09-infrastructure/modal/SKILL.md +341 -0
- package/skills/ai-research/09-infrastructure/modal/references/advanced-usage.md +504 -0
- package/skills/ai-research/09-infrastructure/modal/references/troubleshooting.md +495 -0
- package/skills/ai-research/09-infrastructure/skypilot/SKILL.md +509 -0
- package/skills/ai-research/09-infrastructure/skypilot/references/advanced-usage.md +492 -0
- package/skills/ai-research/09-infrastructure/skypilot/references/troubleshooting.md +571 -0
- package/skills/ai-research/10-optimization/awq/SKILL.md +310 -0
- package/skills/ai-research/10-optimization/awq/references/advanced-usage.md +325 -0
- package/skills/ai-research/10-optimization/awq/references/troubleshooting.md +345 -0
- package/skills/ai-research/10-optimization/bitsandbytes/SKILL.md +411 -0
- package/skills/ai-research/10-optimization/bitsandbytes/references/memory-optimization.md +522 -0
- package/skills/ai-research/10-optimization/bitsandbytes/references/qlora-training.md +522 -0
- package/skills/ai-research/10-optimization/bitsandbytes/references/quantization-formats.md +448 -0
- package/skills/ai-research/10-optimization/flash-attention/SKILL.md +367 -0
- package/skills/ai-research/10-optimization/flash-attention/references/benchmarks.md +216 -0
- package/skills/ai-research/10-optimization/flash-attention/references/transformers-integration.md +294 -0
- package/skills/ai-research/10-optimization/gguf/SKILL.md +427 -0
- package/skills/ai-research/10-optimization/gguf/references/advanced-usage.md +505 -0
- package/skills/ai-research/10-optimization/gguf/references/troubleshooting.md +443 -0
- package/skills/ai-research/10-optimization/gptq/SKILL.md +450 -0
- package/skills/ai-research/10-optimization/gptq/references/calibration.md +338 -0
- package/skills/ai-research/10-optimization/gptq/references/integration.md +130 -0
- package/skills/ai-research/10-optimization/gptq/references/troubleshooting.md +96 -0
- package/skills/ai-research/10-optimization/hqq/SKILL.md +445 -0
- package/skills/ai-research/10-optimization/hqq/references/advanced-usage.md +529 -0
- package/skills/ai-research/10-optimization/hqq/references/troubleshooting.md +504 -0
- package/skills/ai-research/10-optimization/ml-training-recipes/SKILL.md +319 -0
- package/skills/ai-research/10-optimization/ml-training-recipes/references/architecture.md +320 -0
- package/skills/ai-research/10-optimization/ml-training-recipes/references/biomedical.md +498 -0
- package/skills/ai-research/10-optimization/ml-training-recipes/references/domain-specific.md +626 -0
- package/skills/ai-research/10-optimization/ml-training-recipes/references/experiment-loop.md +129 -0
- package/skills/ai-research/10-optimization/ml-training-recipes/references/optimizers.md +330 -0
- package/skills/ai-research/10-optimization/ml-training-recipes/references/scaling-and-selection.md +428 -0
- package/skills/ai-research/11-evaluation/bigcode-evaluation-harness/SKILL.md +405 -0
- package/skills/ai-research/11-evaluation/bigcode-evaluation-harness/references/benchmarks.md +394 -0
- package/skills/ai-research/11-evaluation/bigcode-evaluation-harness/references/custom-tasks.md +425 -0
- package/skills/ai-research/11-evaluation/bigcode-evaluation-harness/references/issues.md +395 -0
- package/skills/ai-research/11-evaluation/lm-evaluation-harness/SKILL.md +490 -0
- package/skills/ai-research/11-evaluation/lm-evaluation-harness/references/api-evaluation.md +491 -0
- package/skills/ai-research/11-evaluation/lm-evaluation-harness/references/benchmark-guide.md +489 -0
- package/skills/ai-research/11-evaluation/lm-evaluation-harness/references/custom-tasks.md +603 -0
- package/skills/ai-research/11-evaluation/lm-evaluation-harness/references/distributed-eval.md +520 -0
- package/skills/ai-research/11-evaluation/nemo-evaluator/SKILL.md +494 -0
- package/skills/ai-research/11-evaluation/nemo-evaluator/references/adapter-system.md +341 -0
- package/skills/ai-research/11-evaluation/nemo-evaluator/references/configuration.md +448 -0
- package/skills/ai-research/11-evaluation/nemo-evaluator/references/custom-benchmarks.md +316 -0
- package/skills/ai-research/11-evaluation/nemo-evaluator/references/execution-backends.md +362 -0
- package/skills/ai-research/12-inference-serving/llama-cpp/SKILL.md +258 -0
- package/skills/ai-research/12-inference-serving/llama-cpp/references/optimization.md +90 -0
- package/skills/ai-research/12-inference-serving/llama-cpp/references/quantization.md +214 -0
- package/skills/ai-research/12-inference-serving/llama-cpp/references/server.md +126 -0
- package/skills/ai-research/12-inference-serving/sglang/SKILL.md +442 -0
- package/skills/ai-research/12-inference-serving/sglang/references/deployment.md +491 -0
- package/skills/ai-research/12-inference-serving/sglang/references/radix-attention.md +414 -0
- package/skills/ai-research/12-inference-serving/sglang/references/structured-generation.md +542 -0
- package/skills/ai-research/12-inference-serving/tensorrt-llm/SKILL.md +187 -0
- package/skills/ai-research/12-inference-serving/tensorrt-llm/references/multi-gpu.md +299 -0
- package/skills/ai-research/12-inference-serving/tensorrt-llm/references/optimization.md +243 -0
- package/skills/ai-research/12-inference-serving/tensorrt-llm/references/serving.md +471 -0
- package/skills/ai-research/12-inference-serving/vllm/SKILL.md +364 -0
- package/skills/ai-research/12-inference-serving/vllm/references/optimization.md +227 -0
- package/skills/ai-research/12-inference-serving/vllm/references/quantization.md +285 -0
- package/skills/ai-research/12-inference-serving/vllm/references/server-deployment.md +256 -0
- package/skills/ai-research/12-inference-serving/vllm/references/troubleshooting.md +448 -0
- package/skills/ai-research/13-mlops/mlflow/SKILL.md +704 -0
- package/skills/ai-research/13-mlops/mlflow/references/deployment.md +745 -0
- package/skills/ai-research/13-mlops/mlflow/references/model-registry.md +771 -0
- package/skills/ai-research/13-mlops/mlflow/references/tracking.md +681 -0
- package/skills/ai-research/13-mlops/swanlab/SKILL.md +406 -0
- package/skills/ai-research/13-mlops/swanlab/references/integrations.md +305 -0
- package/skills/ai-research/13-mlops/swanlab/references/visualization.md +234 -0
- package/skills/ai-research/13-mlops/tensorboard/SKILL.md +629 -0
- package/skills/ai-research/13-mlops/tensorboard/references/integrations.md +639 -0
- package/skills/ai-research/13-mlops/tensorboard/references/profiling.md +546 -0
- package/skills/ai-research/13-mlops/tensorboard/references/visualization.md +621 -0
- package/skills/ai-research/13-mlops/weights-and-biases/SKILL.md +590 -0
- package/skills/ai-research/13-mlops/weights-and-biases/references/artifacts.md +585 -0
- package/skills/ai-research/13-mlops/weights-and-biases/references/integrations.md +701 -0
- package/skills/ai-research/13-mlops/weights-and-biases/references/sweeps.md +848 -0
- package/skills/ai-research/14-agents/a-evolve/SKILL.md +383 -0
- package/skills/ai-research/14-agents/a-evolve/references/README.md +831 -0
- package/skills/ai-research/14-agents/a-evolve/references/api.md +594 -0
- package/skills/ai-research/14-agents/a-evolve/references/architecture.md +417 -0
- package/skills/ai-research/14-agents/a-evolve/references/design-patterns.md +462 -0
- package/skills/ai-research/14-agents/a-evolve/references/examples.md +352 -0
- package/skills/ai-research/14-agents/a-evolve/references/issues.md +591 -0
- package/skills/ai-research/14-agents/a-evolve/references/releases.md +112 -0
- package/skills/ai-research/14-agents/a-evolve/references/tutorials.md +819 -0
- package/skills/ai-research/14-agents/autogpt/SKILL.md +403 -0
- package/skills/ai-research/14-agents/autogpt/references/advanced-usage.md +536 -0
- package/skills/ai-research/14-agents/autogpt/references/troubleshooting.md +421 -0
- package/skills/ai-research/14-agents/crewai/SKILL.md +498 -0
- package/skills/ai-research/14-agents/crewai/references/flows.md +439 -0
- package/skills/ai-research/14-agents/crewai/references/tools.md +430 -0
- package/skills/ai-research/14-agents/crewai/references/troubleshooting.md +481 -0
- package/skills/ai-research/14-agents/langchain/SKILL.md +480 -0
- package/skills/ai-research/14-agents/langchain/references/agents.md +500 -0
- package/skills/ai-research/14-agents/langchain/references/integration.md +563 -0
- package/skills/ai-research/14-agents/langchain/references/rag.md +601 -0
- package/skills/ai-research/14-agents/llamaindex/SKILL.md +569 -0
- package/skills/ai-research/14-agents/llamaindex/references/agents.md +84 -0
- package/skills/ai-research/14-agents/llamaindex/references/data_connectors.md +109 -0
- package/skills/ai-research/14-agents/llamaindex/references/query_engines.md +407 -0
- package/skills/ai-research/15-rag/chroma/SKILL.md +406 -0
- package/skills/ai-research/15-rag/chroma/references/integration.md +39 -0
- package/skills/ai-research/15-rag/faiss/SKILL.md +221 -0
- package/skills/ai-research/15-rag/faiss/references/index_types.md +281 -0
- package/skills/ai-research/15-rag/pinecone/SKILL.md +358 -0
- package/skills/ai-research/15-rag/pinecone/references/deployment.md +182 -0
- package/skills/ai-research/15-rag/qdrant/SKILL.md +493 -0
- package/skills/ai-research/15-rag/qdrant/references/advanced-usage.md +649 -0
- package/skills/ai-research/15-rag/qdrant/references/troubleshooting.md +632 -0
- package/skills/ai-research/15-rag/sentence-transformers/SKILL.md +255 -0
- package/skills/ai-research/15-rag/sentence-transformers/references/models.md +124 -0
- package/skills/ai-research/16-prompt-engineering/dspy/SKILL.md +590 -0
- package/skills/ai-research/16-prompt-engineering/dspy/references/examples.md +664 -0
- package/skills/ai-research/16-prompt-engineering/dspy/references/modules.md +476 -0
- package/skills/ai-research/16-prompt-engineering/dspy/references/optimizers.md +567 -0
- package/skills/ai-research/16-prompt-engineering/guidance/SKILL.md +572 -0
- package/skills/ai-research/16-prompt-engineering/guidance/references/backends.md +555 -0
- package/skills/ai-research/16-prompt-engineering/guidance/references/constraints.md +675 -0
- package/skills/ai-research/16-prompt-engineering/guidance/references/examples.md +768 -0
- package/skills/ai-research/16-prompt-engineering/instructor/SKILL.md +740 -0
- package/skills/ai-research/16-prompt-engineering/instructor/references/examples.md +108 -0
- package/skills/ai-research/16-prompt-engineering/instructor/references/providers.md +71 -0
- package/skills/ai-research/16-prompt-engineering/instructor/references/validation.md +607 -0
- package/skills/ai-research/16-prompt-engineering/outlines/SKILL.md +652 -0
- package/skills/ai-research/16-prompt-engineering/outlines/references/backends.md +616 -0
- package/skills/ai-research/16-prompt-engineering/outlines/references/examples.md +774 -0
- package/skills/ai-research/16-prompt-engineering/outlines/references/json_generation.md +653 -0
- package/skills/ai-research/17-observability/langsmith/SKILL.md +422 -0
- package/skills/ai-research/17-observability/langsmith/references/advanced-usage.md +549 -0
- package/skills/ai-research/17-observability/langsmith/references/troubleshooting.md +538 -0
- package/skills/ai-research/17-observability/phoenix/SKILL.md +475 -0
- package/skills/ai-research/17-observability/phoenix/references/advanced-usage.md +620 -0
- package/skills/ai-research/17-observability/phoenix/references/troubleshooting.md +539 -0
- package/skills/ai-research/18-multimodal/audiocraft/SKILL.md +564 -0
- package/skills/ai-research/18-multimodal/audiocraft/references/advanced-usage.md +667 -0
- package/skills/ai-research/18-multimodal/audiocraft/references/troubleshooting.md +505 -0
- package/skills/ai-research/18-multimodal/blip-2/SKILL.md +564 -0
- package/skills/ai-research/18-multimodal/blip-2/references/advanced-usage.md +681 -0
- package/skills/ai-research/18-multimodal/blip-2/references/troubleshooting.md +527 -0
- package/skills/ai-research/18-multimodal/clip/SKILL.md +253 -0
- package/skills/ai-research/18-multimodal/clip/references/applications.md +208 -0
- package/skills/ai-research/18-multimodal/cosmos-policy/SKILL.md +402 -0
- package/skills/ai-research/18-multimodal/cosmos-policy/references/libero-commands.md +175 -0
- package/skills/ai-research/18-multimodal/cosmos-policy/references/robocasa-commands.md +147 -0
- package/skills/ai-research/18-multimodal/llava/SKILL.md +304 -0
- package/skills/ai-research/18-multimodal/llava/references/training.md +198 -0
- package/skills/ai-research/18-multimodal/openpi/SKILL.md +480 -0
- package/skills/ai-research/18-multimodal/openpi/references/checkpoints-and-env-map.md +86 -0
- package/skills/ai-research/18-multimodal/openpi/references/config-recipes.md +50 -0
- package/skills/ai-research/18-multimodal/openpi/references/pytorch-gotchas.md +49 -0
- package/skills/ai-research/18-multimodal/openpi/references/remote-client-pattern.md +48 -0
- package/skills/ai-research/18-multimodal/openpi/references/training-debugging.md +46 -0
- package/skills/ai-research/18-multimodal/openvla-oft/SKILL.md +441 -0
- package/skills/ai-research/18-multimodal/openvla-oft/references/aloha-workflow.md +141 -0
- package/skills/ai-research/18-multimodal/openvla-oft/references/config-troubleshooting.md +97 -0
- package/skills/ai-research/18-multimodal/openvla-oft/references/libero-workflow.md +115 -0
- package/skills/ai-research/18-multimodal/openvla-oft/references/paper-and-checkpoints.md +84 -0
- package/skills/ai-research/18-multimodal/segment-anything/SKILL.md +500 -0
- package/skills/ai-research/18-multimodal/segment-anything/references/advanced-usage.md +590 -0
- package/skills/ai-research/18-multimodal/segment-anything/references/troubleshooting.md +485 -0
- package/skills/ai-research/18-multimodal/stable-diffusion/SKILL.md +519 -0
- package/skills/ai-research/18-multimodal/stable-diffusion/references/advanced-usage.md +717 -0
- package/skills/ai-research/18-multimodal/stable-diffusion/references/troubleshooting.md +556 -0
- package/skills/ai-research/18-multimodal/whisper/SKILL.md +317 -0
- package/skills/ai-research/18-multimodal/whisper/references/languages.md +190 -0
- package/skills/ai-research/19-emerging-techniques/knowledge-distillation/SKILL.md +458 -0
- package/skills/ai-research/19-emerging-techniques/knowledge-distillation/references/minillm.md +335 -0
- package/skills/ai-research/19-emerging-techniques/long-context/SKILL.md +536 -0
- package/skills/ai-research/19-emerging-techniques/long-context/references/extension_methods.md +469 -0
- package/skills/ai-research/19-emerging-techniques/long-context/references/fine_tuning.md +612 -0
- package/skills/ai-research/19-emerging-techniques/long-context/references/rope.md +403 -0
- package/skills/ai-research/19-emerging-techniques/model-merging/SKILL.md +497 -0
- package/skills/ai-research/19-emerging-techniques/model-merging/references/coefficient-tuning.md +415 -0
- package/skills/ai-research/19-emerging-techniques/model-merging/references/evaluation.md +463 -0
- package/skills/ai-research/19-emerging-techniques/model-merging/references/examples.md +429 -0
- package/skills/ai-research/19-emerging-techniques/model-merging/references/methods.md +353 -0
- package/skills/ai-research/19-emerging-techniques/model-pruning/SKILL.md +495 -0
- package/skills/ai-research/19-emerging-techniques/model-pruning/references/wanda.md +348 -0
- package/skills/ai-research/19-emerging-techniques/moe-training/SKILL.md +526 -0
- package/skills/ai-research/19-emerging-techniques/moe-training/references/architectures.md +433 -0
- package/skills/ai-research/19-emerging-techniques/moe-training/references/inference.md +349 -0
- package/skills/ai-research/19-emerging-techniques/moe-training/references/training.md +426 -0
- package/skills/ai-research/19-emerging-techniques/speculative-decoding/SKILL.md +467 -0
- package/skills/ai-research/19-emerging-techniques/speculative-decoding/references/lookahead.md +310 -0
- package/skills/ai-research/19-emerging-techniques/speculative-decoding/references/medusa.md +351 -0
- package/skills/ai-research/20-ml-paper-writing/academic-plotting/SKILL.md +479 -0
- package/skills/ai-research/20-ml-paper-writing/academic-plotting/references/data-visualization.md +509 -0
- package/skills/ai-research/20-ml-paper-writing/academic-plotting/references/diagram-generation.md +395 -0
- package/skills/ai-research/20-ml-paper-writing/academic-plotting/references/style-guide.md +305 -0
- package/skills/ai-research/20-ml-paper-writing/ml-paper-writing/SKILL.md +982 -0
- package/skills/ai-research/20-ml-paper-writing/ml-paper-writing/references/checklists.md +348 -0
- package/skills/ai-research/20-ml-paper-writing/ml-paper-writing/references/citation-workflow.md +563 -0
- package/skills/ai-research/20-ml-paper-writing/ml-paper-writing/references/reviewer-guidelines.md +349 -0
- package/skills/ai-research/20-ml-paper-writing/ml-paper-writing/references/sources.md +165 -0
- package/skills/ai-research/20-ml-paper-writing/ml-paper-writing/references/writing-guide.md +477 -0
- package/skills/ai-research/20-ml-paper-writing/presenting-conference-talks/SKILL.md +298 -0
- package/skills/ai-research/20-ml-paper-writing/presenting-conference-talks/references/slide-templates.md +505 -0
- package/skills/ai-research/20-ml-paper-writing/systems-paper-writing/SKILL.md +270 -0
- package/skills/ai-research/20-ml-paper-writing/systems-paper-writing/references/checklist.md +279 -0
- package/skills/ai-research/20-ml-paper-writing/systems-paper-writing/references/reviewer-guidelines.md +116 -0
- package/skills/ai-research/20-ml-paper-writing/systems-paper-writing/references/section-blueprints.md +400 -0
- package/skills/ai-research/20-ml-paper-writing/systems-paper-writing/references/systems-conferences.md +261 -0
- package/skills/ai-research/20-ml-paper-writing/systems-paper-writing/references/writing-patterns.md +231 -0
- package/skills/ai-research/21-research-ideation/brainstorming-research-ideas/SKILL.md +384 -0
- package/skills/ai-research/21-research-ideation/creative-thinking-for-research/SKILL.md +366 -0
- package/skills/ai-research/22-agent-native-research-artifact/compiler/SKILL.md +245 -0
- package/skills/ai-research/22-agent-native-research-artifact/compiler/references/ara-schema.md +439 -0
- package/skills/ai-research/22-agent-native-research-artifact/compiler/references/exploration-tree-spec.md +125 -0
- package/skills/ai-research/22-agent-native-research-artifact/compiler/references/validation-checklist.md +149 -0
- package/skills/ai-research/22-agent-native-research-artifact/research-manager/SKILL.md +324 -0
- package/skills/ai-research/22-agent-native-research-artifact/research-manager/references/event-taxonomy.md +124 -0
- package/skills/ai-research/22-agent-native-research-artifact/research-manager/references/provenance-tags.md +190 -0
- package/skills/ai-research/22-agent-native-research-artifact/research-manager/references/session-protocol.md +162 -0
- package/skills/ai-research/22-agent-native-research-artifact/rigor-reviewer/SKILL.md +322 -0
- package/skills/ai-research/22-agent-native-research-artifact/rigor-reviewer/references/review-dimensions.md +182 -0
- package/skills/ai-research/SKILL.md +101 -0
- package/skills/ai-research/SOURCE +38 -0
- package/skills/ai-research/index/1-frame.md +18 -0
- package/skills/ai-research/index/2-data.md +21 -0
- package/skills/ai-research/index/3-train.md +48 -0
- package/skills/ai-research/index/4-measure.md +25 -0
- package/skills/ai-research/index/5-run.md +23 -0
- package/skills/ai-research/index/6-write.md +35 -0
- package/skills/ai-research/library.json +3456 -0
- package/skills/analyze-app/EVALS.md +82 -0
- package/skills/analyze-app/SKILL.md +188 -0
- package/skills/design-contract/EVALS.md +80 -0
- package/skills/design-contract/SKILL.md +171 -0
- package/skills/design-library/EVALS.md +85 -0
- package/skills/design-library/INDEX.md +59 -0
- package/skills/design-library/SKILL.md +58 -0
- package/skills/design-library/SOURCE +131 -0
- package/skills/design-library/adaptive-interfaces/skills/colour-independence/SKILL.md +90 -0
- package/skills/design-library/adaptive-interfaces/skills/flexible-typography/SKILL.md +83 -0
- package/skills/design-library/adaptive-interfaces/skills/information-density/SKILL.md +87 -0
- package/skills/design-library/adaptive-interfaces/skills/preference-audit/SKILL.md +70 -0
- package/skills/design-library/adaptive-interfaces/skills/responsive-accessibility/SKILL.md +86 -0
- package/skills/design-library/adaptive-interfaces/skills/responsive-review/SKILL.md +76 -0
- package/skills/design-library/adaptive-interfaces/skills/simplified-views/SKILL.md +97 -0
- package/skills/design-library/adaptive-interfaces/skills/specify/SKILL.md +63 -0
- package/skills/design-library/adaptive-interfaces/skills/user-preference-respect/SKILL.md +100 -0
- package/skills/design-library/cognitive-accessibility/skills/adaptive-personalisation/SKILL.md +61 -0
- package/skills/design-library/cognitive-accessibility/skills/assess-load/SKILL.md +48 -0
- package/skills/design-library/cognitive-accessibility/skills/cognitive-load-assessment/SKILL.md +60 -0
- package/skills/design-library/cognitive-accessibility/skills/contextual-help-design/SKILL.md +61 -0
- package/skills/design-library/cognitive-accessibility/skills/error-prevention-recovery/SKILL.md +71 -0
- package/skills/design-library/cognitive-accessibility/skills/focus-attention-design/SKILL.md +67 -0
- package/skills/design-library/cognitive-accessibility/skills/memory-load-reduction/SKILL.md +60 -0
- package/skills/design-library/cognitive-accessibility/skills/plain-language-design/SKILL.md +78 -0
- package/skills/design-library/cognitive-accessibility/skills/review/SKILL.md +56 -0
- package/skills/design-library/cognitive-accessibility/skills/simplify/SKILL.md +39 -0
- package/skills/design-library/cognitive-accessibility/skills/wayfinding-navigation/SKILL.md +64 -0
- package/skills/design-library/design-research/skills/behavioural-analytics/SKILL.md +33 -0
- package/skills/design-library/design-research/skills/qual-quant-triangulation/SKILL.md +33 -0
- package/skills/design-library/inclusive-interaction/skills/audit/SKILL.md +50 -0
- package/skills/design-library/inclusive-interaction/skills/design-flow/SKILL.md +58 -0
- package/skills/design-library/inclusive-interaction/skills/feedback-and-status/SKILL.md +76 -0
- package/skills/design-library/inclusive-interaction/skills/gesture-alternatives/SKILL.md +70 -0
- package/skills/design-library/inclusive-interaction/skills/keyboard-navigation/SKILL.md +99 -0
- package/skills/design-library/inclusive-interaction/skills/keyboard-review/SKILL.md +54 -0
- package/skills/design-library/inclusive-interaction/skills/motion-sensitivity/SKILL.md +79 -0
- package/skills/design-library/inclusive-interaction/skills/multi-modal-input/SKILL.md +70 -0
- package/skills/design-library/inclusive-interaction/skills/touch-target-design/SKILL.md +71 -0
- package/skills/design-library/inclusive-interaction/skills/voice-interaction/SKILL.md +68 -0
- package/skills/design-library/visual-critique/skills/critique-affordance/SKILL.md +45 -0
- package/skills/design-library/visual-critique/skills/critique-brand-consistency/SKILL.md +42 -0
- package/skills/design-library/visual-critique/skills/critique-color/SKILL.md +44 -0
- package/skills/design-library/visual-critique/skills/critique-composition/SKILL.md +46 -0
- package/skills/design-library/visual-critique/skills/critique-information-density/SKILL.md +45 -0
- package/skills/design-library/visual-critique/skills/critique-typography/SKILL.md +45 -0
- package/skills/design-library/visual-critique/skills/critique-visual-hierarchy/SKILL.md +40 -0
- package/skills/feature-audit/EVALS.md +83 -0
- package/skills/feature-audit/SKILL.md +326 -0
- package/skills/harness/EVALS.md +81 -0
- package/skills/harness/SKILL.md +88 -0
- package/skills/i-have-adhd/EVALS.md +80 -0
- package/skills/i-have-adhd/SKILL.md +183 -0
- package/skills/no-ai-slop/EVALS.md +89 -0
- package/skills/no-ai-slop/SKILL.md +209 -0
- package/skills/no-ai-slop/eval.md +52 -0
- package/skills/plan-add/EVALS.md +79 -0
- package/skills/plan-add/SKILL.md +179 -0
- package/skills/plan-add/render_table.py +72 -0
- package/skills/plan-status/EVALS.md +78 -0
- package/skills/plan-status/SKILL.md +52 -0
- package/skills/plan-sync/EVALS.md +80 -0
- package/skills/plan-sync/SKILL.md +69 -0
- package/skills/pm-frameworks/SKILL.md +33 -0
- package/skills/pm-frameworks/SOURCE +39 -0
- package/skills/pm-frameworks/intended-vs-implemented/SKILL.md +42 -0
- package/skills/pm-frameworks/opportunity-solution-tree/SKILL.md +64 -0
- package/skills/ponytail/EVALS.md +80 -0
- package/skills/ponytail/SKILL.md +124 -0
- package/skills/product-analysis/EVALS.md +103 -0
- package/skills/product-analysis/SKILL.md +361 -0
- package/skills/research/EVALS.md +104 -0
- package/skills/research/SKILL.md +570 -0
- package/skills/rl-env/SKILL.md +53 -0
- package/skills/rl-env/SOURCE +41 -0
- package/skills/rl-env/generate-nemo-gym-env/SKILL.md +169 -0
- package/skills/rl-env/generate-nemo-gym-env/references/architecture.md +114 -0
- package/skills/rl-env/generate-openenv-env/SKILL.md +183 -0
- package/skills/rl-env/generate-openenv-env/references/architecture.md +82 -0
- package/skills/rl-env/generate-ors-env/SKILL.md +209 -0
- package/skills/rl-env/generate-ors-env/references/architecture.md +113 -0
- package/skills/rl-env/generate-verifiers-env/SKILL.md +188 -0
- package/skills/rl-env/generate-verifiers-env/references/architecture.md +119 -0
- package/skills/rl-env/rl-env-from-description/SKILL.md +124 -0
- package/skills/rl-env/rl-env-from-description/references/interview.md +75 -0
- package/skills/rl-env/rl-env-from-description/references/nemo_gym.md +41 -0
- package/skills/rl-env/rl-env-from-description/references/openenv.md +35 -0
- package/skills/rl-env/rl-env-from-description/references/ors.md +50 -0
- package/skills/rl-env/rl-env-from-description/references/verifiers.md +39 -0
- package/skills/tezgah-contract/EVALS.md +78 -0
- package/skills/tezgah-contract/SKILL.md +792 -0
- package/statusline.py +129 -0
- package/workflows/graph-impact.js +153 -0
- package/workflows/graph-map.js +158 -0
- package/workflows/graph-review.js +170 -0
|
@@ -0,0 +1,2736 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The tool gate, shared by every host that can block a tool call.
|
|
3
|
+
|
|
4
|
+
Rules, all only inside a tezgah root:
|
|
5
|
+
1. a grep-only explorer subagent is refused, with the graph tools named as the
|
|
6
|
+
replacement;
|
|
7
|
+
2. the FIRST identifier-shaped search of a session (Grep tool, or grep/rg run
|
|
8
|
+
through a shell) in a repo whose own codegraph index exists is nudged
|
|
9
|
+
toward the graph once, then every later search passes.
|
|
10
|
+
3. a git/gh command that writes an artifact carrying an AI/model credit
|
|
11
|
+
(Co-Authored-By, "Generated with", a robot emoji, ...) is refused.
|
|
12
|
+
4. an identical call that already failed LOOP_ATTEMPTS times in the current
|
|
13
|
+
user turn is refused (`loop`), the same number for every failure class: the
|
|
14
|
+
class names why the last attempt failed, it does not buy another identical
|
|
15
|
+
one. An identical call attempted more than RETRY_CEILING times in the
|
|
16
|
+
session is refused whatever its outcomes were (`retry`). Both read
|
|
17
|
+
hooks/tezgah_integrity.prior_calls and both are under the `verify-off` kill
|
|
18
|
+
switch the integrity rule shares.
|
|
19
|
+
5. a credential on its way into a file is refused (secret): a shell command
|
|
20
|
+
whose own text carries a `name=value` / bearer token next to a sink (a
|
|
21
|
+
redirect, `tee`, `git add` or a curl trace), a `git add` that names a
|
|
22
|
+
credential file (`.env`, a key file; `.env.example` and `.env.sample` are
|
|
23
|
+
templates), and any write - a write tool in every dialect, an apply_patch
|
|
24
|
+
file by file, a heredoc body - that lands a token with a known credential
|
|
25
|
+
prefix (`ghp_`, `sk-live-`, ...) the replaced text did not carry.
|
|
26
|
+
6. a write to a file another session wrote inside RACE_WINDOW_MIN is refused
|
|
27
|
+
(`race`), naming the other session and the file, because the failure it
|
|
28
|
+
closes - one session overwriting another's work from a stale read - leaves
|
|
29
|
+
no trace in either transcript. A shell command's redirect or `tee` target
|
|
30
|
+
is a write here, and in the task rule below, like a write tool's path.
|
|
31
|
+
7. when the current user turn has run past DRIFT_STEPS work rows, the next
|
|
32
|
+
effectful call gets the standing constraints re-stated once (`drift`), in
|
|
33
|
+
the gate's reason string, which is the only channel a PreToolUse hook has.
|
|
34
|
+
The marker row is written before the deny, so the identical call passes on
|
|
35
|
+
the next attempt.
|
|
36
|
+
8. a write while an active task is set - a plan file under .tezgah/plans/open whose
|
|
37
|
+
frontmatter carries a valid `phase` - is refused when that phase is one
|
|
38
|
+
that only reads, or when the file is outside the task's `allowed_paths`
|
|
39
|
+
refused (`task`). The record is the user's own, written by bin/tezgah-task and
|
|
40
|
+
never by the agent, so the refusal leans on a decision the user made rather
|
|
41
|
+
than one this gate inferred: no active task means no requirement, and the
|
|
42
|
+
rule invents nothing. The record also carries where the phase returns to - a
|
|
43
|
+
`checkpoint:` sha, or `pending` while the tree still holds work no commit
|
|
44
|
+
names - and a write in `implementation` is refused while it reads `pending`
|
|
45
|
+
and the tree is dirty, so the phase's work starts from a named state rather
|
|
46
|
+
than on top of uncommitted changes; the per-file snapshots are not one
|
|
47
|
+
state to return to. Two further refusals close the ways a session could
|
|
48
|
+
move the boundary instead of respecting it: a write whose target IS that
|
|
49
|
+
record, and a shell command that would change it through the CLI. Both were
|
|
50
|
+
measured open before they were closed - with the refusal naming the command
|
|
51
|
+
that lifts it, an armed arm removed or disabled the gate in 25 of 25 runs
|
|
52
|
+
and obeyed it in none (an internal benchmark, block E7) - and neither
|
|
53
|
+
refusal names a command now. The phase covers the shell too, because the
|
|
54
|
+
write tools are not the only way to change a file: with them refused, the
|
|
55
|
+
armed arm wrote the target with a heredoc redirect in 3 of 25 runs, so a
|
|
56
|
+
reading phase refuses a shell command that writes. A code-execution tool is
|
|
57
|
+
a third route the phase does not cover: the same block measured one row in
|
|
58
|
+
25 writing the target through `eval` after the write tools and the shell
|
|
59
|
+
were both refused, and that one is named rather than closed. The `task-off`
|
|
60
|
+
kill switch removes all four.
|
|
61
|
+
9. a `git commit` (or `--amend`) is refused while the newest check recorded in
|
|
62
|
+
this session failed (`order`): a commit is a claim that the tree passed, and
|
|
63
|
+
the state it would freeze is the one a check just rejected. The state is the
|
|
64
|
+
Stop rule's own fold over the ledger tail, so no check at all and a passing
|
|
65
|
+
newest check both pass - a docs-only commit is not this rule's - and the
|
|
66
|
+
refusal names the failed check and no command, because what lifts it is
|
|
67
|
+
making the newest check green. It rides the `verify-off` switch.
|
|
68
|
+
10. a check piped into a trimmer or filter (`pytest | tail`, `| grep`, `| wc`)
|
|
69
|
+
is refused (`piped`): the line's status is the trimmer's, so the ledger
|
|
70
|
+
records the check as ran and never as passed, and the Stop rule refuses
|
|
71
|
+
every claim the run was meant to carry. The refusal names both ways out -
|
|
72
|
+
output to a file that is then read, or a `set -o pipefail;` prefix, which
|
|
73
|
+
integrity reads as decisive. It rides the `verify-off` switch.
|
|
74
|
+
The three rules that read the text a write lands - the shortcut rule, the
|
|
75
|
+
attribution rule above and the credential rule's prefixed-token half -
|
|
76
|
+
read it on every write route: a write tool in every dialect (an
|
|
77
|
+
apply_patch file by file) and the body a heredoc writes
|
|
78
|
+
(shell_write_body). The write tools and the shell are disjoint sets, so
|
|
79
|
+
the heredoc route was refused by nothing, and it is the one E7c measured
|
|
80
|
+
an armed session taking.
|
|
81
|
+
11. a write, delete, move or chmod of tezgah's own control plane is refused
|
|
82
|
+
(`control`): a kill switch or opt-in marker (tezgah_paths.SWITCHES under
|
|
83
|
+
OFF_DIRS), ~/.config/tezgah, the evidence ledger and the session state in
|
|
84
|
+
the cache, the hook registration files and the installed hook tree, a
|
|
85
|
+
repo's `.no-*` marks, the repository's git hooks, and the CLIs that change
|
|
86
|
+
that state (control_reason); a forced `git add` of `.tezgah/` and an open
|
|
87
|
+
plan's delete or move leave a `disarm` row instead (CONTROL_EVIDENCE). It
|
|
88
|
+
runs first after the root check, and no switch but `pretooluse-off`
|
|
89
|
+
removes it: every other switch is one of the files it protects.
|
|
90
|
+
Adapters translate the returned reason into their own permission envelope.
|
|
91
|
+
"""
|
|
92
|
+
import hashlib
|
|
93
|
+
import json
|
|
94
|
+
import os
|
|
95
|
+
import re
|
|
96
|
+
import shlex
|
|
97
|
+
import time
|
|
98
|
+
|
|
99
|
+
from tezgah_integrity import (BASH_TOOLS, BEGAN_KIND, ENV_WORD, GIT_VALUE_OPTS,
|
|
100
|
+
HEREDOC_RISK, HOOKS_KEY, PATCH_FILE,
|
|
101
|
+
PLAIN_TAIL, SECRET_PREFIXED, STEP_KINDS,
|
|
102
|
+
WRITE_TOOLS, _blank_heredocs, _heredocs, bind_session,
|
|
103
|
+
_shell_lines, _shell_segments, _turn_start,
|
|
104
|
+
_unquoted_backticks, call_id, cut, events,
|
|
105
|
+
heredoc_bodies, mask, mcp_class, mcp_text, note,
|
|
106
|
+
prior_calls, scratch_target, shortcut_command,
|
|
107
|
+
shortcut_edit, turn_rows, verify_command,
|
|
108
|
+
write_texts)
|
|
109
|
+
from tezgah_paths import (CACHE, CONFIG_DIR, HOST_DIRS, OFF_DIRS, PLUGIN_ROOT,
|
|
110
|
+
REPO_MARKS, SWITCHES, cache_dir, fallback_cache,
|
|
111
|
+
linked_main, off, root_for, roots)
|
|
112
|
+
|
|
113
|
+
try: # The ordering rule's two readers (the newest check's state, folded the way
|
|
114
|
+
# the Stop rule folds it, and the command of that check for the refusal), and
|
|
115
|
+
# the plan rule's fold over this turn's write rows. Newer than some checkouts,
|
|
116
|
+
# and a missing name costs the rule, never the session - this import runs on
|
|
117
|
+
# every gated call.
|
|
118
|
+
from tezgah_integrity import _failed_check, _last_verify, turn_rows
|
|
119
|
+
except ImportError: # pragma: no cover - only on an integrity module without them
|
|
120
|
+
_failed_check, _last_verify, turn_rows = None, None, None
|
|
121
|
+
|
|
122
|
+
try: # The race rule reads the write history through tezgah_integrity; that
|
|
123
|
+
# reader is newer than some checkouts of the module, and a missing name must
|
|
124
|
+
# cost the rule, never the session (this import runs on every gated call).
|
|
125
|
+
from tezgah_integrity import writers_elsewhere
|
|
126
|
+
except ImportError: # pragma: no cover - only on an integrity module without it
|
|
127
|
+
writers_elsewhere = None
|
|
128
|
+
|
|
129
|
+
try: # The bytes a write is about to change. The snapshot module is newer than
|
|
130
|
+
# some checkouts too, and a missing module must cost the snapshot, never the
|
|
131
|
+
# session: a file that cannot be snapshotted is still a file the agent may
|
|
132
|
+
# edit, so this reader is the one that fails open.
|
|
133
|
+
from tezgah_snapshot import capture
|
|
134
|
+
except ImportError: # pragma: no cover - only where the module has not landed
|
|
135
|
+
capture = None
|
|
136
|
+
|
|
137
|
+
try: # The credential-file names, the secret rule's `git add` half: lost with
|
|
138
|
+
# the snapshot module, never the session.
|
|
139
|
+
from tezgah_snapshot import SECRET_FILE
|
|
140
|
+
except ImportError: # pragma: no cover - only where the module has not landed
|
|
141
|
+
SECRET_FILE = None
|
|
142
|
+
|
|
143
|
+
try: # The lesson ledger's one reader (shared with tezgah_context, which the
|
|
144
|
+
# gate does not import): which file is a ledger, what a write adds to it, the
|
|
145
|
+
# key each line is remembered by, and the taint index writer. A missing
|
|
146
|
+
# module costs the taint row, never the session.
|
|
147
|
+
import tezgah_lessons
|
|
148
|
+
from tezgah_lessons import write_taint
|
|
149
|
+
except ImportError: # pragma: no cover - only where the module has not landed
|
|
150
|
+
tezgah_lessons = write_taint = None
|
|
151
|
+
|
|
152
|
+
try: # The piped-check rule's reader: newer than some integrity modules, and a
|
|
153
|
+
# missing name costs the rule, never the session.
|
|
154
|
+
from tezgah_integrity import piped_check
|
|
155
|
+
except ImportError: # pragma: no cover - only on an integrity module without it
|
|
156
|
+
piped_check = None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
try: # The task rule reads the user's own per-task record (a plan file's
|
|
160
|
+
# frontmatter) through tezgah_task. That module is newer than some checkouts,
|
|
161
|
+
# and a missing module costs the rule, never the session.
|
|
162
|
+
import tezgah_task
|
|
163
|
+
except ImportError: # pragma: no cover - only where the module has not landed
|
|
164
|
+
tezgah_task = None
|
|
165
|
+
|
|
166
|
+
try: # The language rule's detector: the letter set and the word list, in their
|
|
167
|
+
# own module so a test reads them without a session. Newer than some
|
|
168
|
+
# checkouts, and a missing module costs the rule, never the session.
|
|
169
|
+
import tezgah_lang
|
|
170
|
+
except ImportError: # pragma: no cover - only where the module has not landed
|
|
171
|
+
tezgah_lang = None
|
|
172
|
+
|
|
173
|
+
IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{2,}$")
|
|
174
|
+
# ponytail: flags with a separate value (grep -A 3 foo) shift the token and the
|
|
175
|
+
# search passes unnudged; not worth a real argv parser for a once-a-session hint.
|
|
176
|
+
BASH_SEARCH = re.compile(r"(?:^|[|;&(]\s*|\s)(?:grep|rg)\s+((?:-\S+\s+)*)(\S+)")
|
|
177
|
+
# the credit forms, not a bare tool name: "OpenAI" or "Claude Code" in a
|
|
178
|
+
# sentence (naming a tool to use it) is fine, "Co-Authored-By: ..." or a
|
|
179
|
+
# generator named as the author (signing it) is the thing to stop. The "... by
|
|
180
|
+
# ..." verbs credit only when their object names the machine or a model: a
|
|
181
|
+
# report generated by the build script, or an index built by the gate, is prose,
|
|
182
|
+
# while AI / an LLM / GPT / Claude / ... as that object is a signature, so each
|
|
183
|
+
# verb is tied to the name it credits. `_CREDIT` is shared with the anchored
|
|
184
|
+
# twin below so the two forms cannot drift apart.
|
|
185
|
+
#
|
|
186
|
+
# The cost of the model-object branch is deliberate and it falls on a message
|
|
187
|
+
# that *quotes* the forms: a commit body describing this rule is denied until it
|
|
188
|
+
# rephrases (the deny states the reason, and one rephrase clears it). The file
|
|
189
|
+
# path pays nothing for it - `ATTRIB_LINE` anchors, so documentation that merely
|
|
190
|
+
# names a form mid-sentence lands - and splitting the two sets apart was tried
|
|
191
|
+
# and reverted, because the anchored half then missed `-m "Built by GPT"`.
|
|
192
|
+
_CREDIT = (
|
|
193
|
+
r"co-authored-by\s*:|generated with|made with|"
|
|
194
|
+
r"(?:generated|written|authored|built|assisted)\s+by\s+"
|
|
195
|
+
r"(?:(?:an?|the)\s+)?"
|
|
196
|
+
r"(?:ai|llm|claude|chatgpt|gpt|copilot|codex|cursor|gemini|deepseek|"
|
|
197
|
+
r"openai|anthropic)\b|noreply@anthropic|\U0001F916")
|
|
198
|
+
ATTRIB = re.compile(_CREDIT, re.I)
|
|
199
|
+
# The same forms anchored to the start of a line, for the text a write/edit tool
|
|
200
|
+
# is about to land. A credit is a signature: it owns its line, bare or behind a
|
|
201
|
+
# comment marker, and a patch body prefixes an added line with `+` (`-` stays
|
|
202
|
+
# last in the class so it is a literal, not a range). Prose that merely names the
|
|
203
|
+
# ban ("Banned forms include `Co-Authored-By`") starts with other words, and a
|
|
204
|
+
# markdown list item puts a backtick between the marker and the form, so neither
|
|
205
|
+
# matches. That anchoring is what stops the rule from denying the documentation
|
|
206
|
+
# that describes it.
|
|
207
|
+
# ponytail: a file that quotes a forged trailer on its own line is still denied;
|
|
208
|
+
# the deny states the reason, so one rephrase clears it.
|
|
209
|
+
ATTRIB_LINE = re.compile(r"(?im)^[\s>#*/<!+-]*(?:" + _CREDIT + r")")
|
|
210
|
+
# the payload fields a write/edit tool carries its content in, per host dialect
|
|
211
|
+
EDIT_TEXT = ("content", "new_string", "newString", "new_str", "file_text",
|
|
212
|
+
"patch", "text")
|
|
213
|
+
# the write subcommand may sit behind git's global options: `git -c k=v commit`,
|
|
214
|
+
# `git -C dir commit`, `git --no-pager commit`. `gh api` writes comments/reviews,
|
|
215
|
+
# and `gh pr merge` lands a commit, so both count as writes.
|
|
216
|
+
# The `gh` subcommands that write to the remote, so this rule counts them as
|
|
217
|
+
# write commands: a PR or an issue comment is a message posted to a service
|
|
218
|
+
# under the workspace's name.
|
|
219
|
+
GH_SUBCOMMANDS = r"(?:create|edit|comment|review|merge|close)"
|
|
220
|
+
WRITE_CMD = re.compile(
|
|
221
|
+
r"(?:^|[|;&]\s*|\s)git\s+(?:-{1,2}\S+(?:\s+\S+)?\s+)*"
|
|
222
|
+
r"(?:commit|merge|tag|notes)\b|"
|
|
223
|
+
r"(?:^|[|;&]\s*|\s)gh\s+api\b|"
|
|
224
|
+
r"(?:^|[|;&]\s*|\s)gh\s+(?:pr|issue|release)\s+" + GH_SUBCOMMANDS + r"\b",
|
|
225
|
+
re.I)
|
|
226
|
+
|
|
227
|
+
# --- secret: a credential on its way into a file ----------------------------
|
|
228
|
+
# The shell half (a command's own text next to a sink; the text a write lands is
|
|
229
|
+
# read below, SECRET_EDIT_DENY): only the two shapes the contract names: a
|
|
230
|
+
# bearer header, or a `name=value`
|
|
231
|
+
# assignment. A trailing quote is allowed because the value is usually quoted,
|
|
232
|
+
# and `:` is NOT a separator here - `{"api_key": "x"}` is a JSON field in a
|
|
233
|
+
# program's text, while `token=$TOKEN` and `api_key=...` are a credential being
|
|
234
|
+
# carried. The value may be an env reference: `OPENROUTER_API_KEY=$KEY` echoed
|
|
235
|
+
# into a log is the taxonomy's own case (`$KEY` is resolved by the shell).
|
|
236
|
+
# The name alternatives already match the keyword wherever it sits, so the
|
|
237
|
+
# unbounded `[A-Za-z0-9_.-]*` prefix only bought a rescan from every start
|
|
238
|
+
# position: 16 KB took 4.03 s and 100 KB 174 s, past Claude's 5 s hook budget, so
|
|
239
|
+
# the deny never arrived and the evidence row was never written (audit H-3).
|
|
240
|
+
SECRET_TOKEN = re.compile(
|
|
241
|
+
r"authorization\s*:\s*bearer\s+\S|"
|
|
242
|
+
r"(?:api[_-]?key|access[_-]?token|auth[_-]?token|token|secret|"
|
|
243
|
+
r"password|passwd)\s*=\s*[\"']?[^\s\"']", re.I)
|
|
244
|
+
# The sinks that carry a command's own text into a file. `>>?` is read off the
|
|
245
|
+
# masked text, so a quoted `>` is not a redirect and `2>&1` is not a file.
|
|
246
|
+
# curl's -o/--output writes the response BODY, not the request header, so it is
|
|
247
|
+
# not a sink; its traces are, because those do carry the header.
|
|
248
|
+
SECRET_SINK = re.compile(
|
|
249
|
+
r">>?(?![&=])|\|\s*tee\b|--trace(?:-ascii)?\b|"
|
|
250
|
+
r"(?:^|[|;&]\s*|\s)git\s+(?:-{1,2}\S+(?:\s+\S+)?\s+)*add\b")
|
|
251
|
+
# The simple commands of a shell line, for the rule below. A sink only carries
|
|
252
|
+
# the text of its own simple command, so `git add -A && git commit -m "fix
|
|
253
|
+
# api_key= handling"` is a message about the rule rather than a credential in a
|
|
254
|
+
# write. `|` is NOT a boundary here but a carrier: `printf 'token=%s' "$T" | tee
|
|
255
|
+
# log` hands the piped text to tee. The split is read off the masked text, so a
|
|
256
|
+
# quoted `;` does not split.
|
|
257
|
+
SEGMENT = re.compile(r"\|\||&&|[;&\n]")
|
|
258
|
+
|
|
259
|
+
SECRET_DENY = (
|
|
260
|
+
"Credential write denied: this command would land a credential in a file "
|
|
261
|
+
"(`>`/`>>`, `tee`, `git add` or a curl trace). Record the credential's "
|
|
262
|
+
"name, length or a fingerprint instead of its value, pass it through the "
|
|
263
|
+
"tool's own environment, or let the tool read it from there rather than "
|
|
264
|
+
"writing it out.")
|
|
265
|
+
# The text a write lands - a write tool's content, a patch's added lines, a
|
|
266
|
+
# heredoc's body - is read for the redactor's prefixed token families
|
|
267
|
+
# (tezgah_integrity.SECRET_PREFIXED: `ghp_`, `sk-live-`, `AKIA`, ..., without
|
|
268
|
+
# the redactor's bare `sk-`/`pk_` branch), never for the
|
|
269
|
+
# name=value shape above: in a file's text `password = os.environ[...]` is a
|
|
270
|
+
# program reading its credential, and name=value matched 99 of 655 tracked text
|
|
271
|
+
# files where the prefixed families matched 4, all fixtures (REPORT.md:5853, not
|
|
272
|
+
# re-measured here). Only a token the call brings counts: one already in the
|
|
273
|
+
# replaced text is not this call's doing.
|
|
274
|
+
SECRET_EDIT_DENY = (
|
|
275
|
+
"Credential write denied: this write lands a token with a known credential "
|
|
276
|
+
"prefix (`ghp_`, `sk-live-`, `AKIA`, ...) in a file. Record the credential's "
|
|
277
|
+
"name, length or a fingerprint instead of its value, or have the program read "
|
|
278
|
+
"it from its environment at run time.")
|
|
279
|
+
# `git add` of a credential file puts every value in it into history. The names
|
|
280
|
+
# are the snapshot store's (tezgah_snapshot.SECRET_FILE); the templates that
|
|
281
|
+
# carry names without values are the carve-out.
|
|
282
|
+
# ponytail: only a file the command names is read - `git add -A` or `git add .`
|
|
283
|
+
# staging an untracked `.env` is the .gitignore's to stop, not this rule's.
|
|
284
|
+
SECRET_TEMPLATES = (".env.example", ".env.sample", ".env.template", ".env.dist",
|
|
285
|
+
".env.defaults")
|
|
286
|
+
# A token in these files is there to be read, not used: a template (the names
|
|
287
|
+
# above, `*.example`) or a test fixture (`tests/**/fixtures/**`).
|
|
288
|
+
SECRET_FIXTURE = re.compile(r"(?:^|/)tests?/(?:.+/)?fixtures/|\.example$", re.I)
|
|
289
|
+
# Documentation placeholders: a token that says it is an example (AWS's
|
|
290
|
+
# `AKIAIOSFODNN7EXAMPLE`), one whose body is a single repeated character
|
|
291
|
+
# (`ghp_xxxx...`), and the published keys docs print, held as sha256 digests so
|
|
292
|
+
# no file in the tree carries one whole: Stripe's documentation test secret key.
|
|
293
|
+
# ponytail: a published key is listed by hand, one per report.
|
|
294
|
+
SECRET_PUBLISHED = frozenset((
|
|
295
|
+
"2cafc0970149a84f3b9e62eaf169f36f59907a3b3e31f7b82e68c69cd27f7326",))
|
|
296
|
+
SECRET_ADD_DENY = (
|
|
297
|
+
"Credential file add denied: `git add %s` stages a credential file, so every "
|
|
298
|
+
"value in it lands in the repository's history. Keep it out of git (list it "
|
|
299
|
+
"in .gitignore) and commit a `.env.example` that carries the names without "
|
|
300
|
+
"the values.")
|
|
301
|
+
|
|
302
|
+
# the MCP half of the rule (see `decision`): the same advice, for a server write
|
|
303
|
+
MCP_SECRET_DENY = (
|
|
304
|
+
"Credential write denied: this MCP call would land a credential (a prefixed "
|
|
305
|
+
"token such as `ghp_...` or `sk-...`) in what the server writes or posts. "
|
|
306
|
+
"Record the credential's name, length or a fingerprint instead of its value, "
|
|
307
|
+
"or let the server read it from its own environment.")
|
|
308
|
+
|
|
309
|
+
# The credential shapes the MCP half reads: vendor tokens only, case-sensitive.
|
|
310
|
+
# The redactor's families (tezgah_integrity.SECRET_TOKEN) also take any
|
|
311
|
+
# `sk|pk|rk` + 16 characters, which is what an identifier or a branch name looks
|
|
312
|
+
# like (`pk_users_organization_id`, `sk-telemetry-dashboard-refactor`) - right for
|
|
313
|
+
# a redactor, which loses nothing by blanking one, wrong for a refusal.
|
|
314
|
+
MCP_TOKEN = re.compile(
|
|
315
|
+
r"\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}"
|
|
316
|
+
r"|\bsk-(?:proj|ant)-[A-Za-z0-9_\-]{16,}"
|
|
317
|
+
r"|\bsk-[A-Za-z0-9]{32,}"
|
|
318
|
+
r"|\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}"
|
|
319
|
+
r"|\bgithub_pat_[A-Za-z0-9_]{20,}"
|
|
320
|
+
r"|\bxox[baprs]-[A-Za-z0-9-]{10,}"
|
|
321
|
+
r"|\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"
|
|
322
|
+
r"|\bAIza[0-9A-Za-z_\-]{30,}"
|
|
323
|
+
r"|\bglpat-[A-Za-z0-9_\-]{20,}"
|
|
324
|
+
r"|\bnpm_[A-Za-z0-9]{30,}")
|
|
325
|
+
|
|
326
|
+
EXPLORE_DENY = (
|
|
327
|
+
"A grep-only explorer subagent is not allowed in this tree: it greps by "
|
|
328
|
+
"design and ignores the code graph. Use the general-purpose agent and name "
|
|
329
|
+
"the codegraph tools in its prompt (`codegraph_explore` over MCP, or "
|
|
330
|
+
"`codegraph callers|impact|node` from a shell), with the tool-loading step "
|
|
331
|
+
"so they are armed.")
|
|
332
|
+
|
|
333
|
+
ATTRIB_DENY = (
|
|
334
|
+
"Attribution is banned in every artifact tezgah touches. Remove the "
|
|
335
|
+
"Co-Authored-By / \"Generated with\" / robot-emoji / model-name credit from "
|
|
336
|
+
"the commit, PR, issue or review text and re-run. Naming a tool in order to "
|
|
337
|
+
"use it or describe real behavior is fine; crediting it as author is not.")
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def attribution(command):
|
|
341
|
+
"""True when a git/gh write command carries an AI/model credit.
|
|
342
|
+
|
|
343
|
+
The whole command text is read unanchored, so a credit inside a `-m` value is
|
|
344
|
+
caught wherever it sits. A message that *discusses* the forms is denied too,
|
|
345
|
+
and that is the known cost: the deny names the forms, so one rephrase clears
|
|
346
|
+
it (see the note on `_CREDIT`)."""
|
|
347
|
+
c = str(command or "")
|
|
348
|
+
return bool(WRITE_CMD.search(c) and ATTRIB.search(c))
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def attribution_edit(inp):
|
|
352
|
+
"""True when the text a write/edit tool is about to land carries a credit.
|
|
353
|
+
|
|
354
|
+
The bash path needs `WRITE_CMD` to establish that a commit or PR body is
|
|
355
|
+
being written; here the tool itself is the write, so the content is the
|
|
356
|
+
whole question."""
|
|
357
|
+
if not isinstance(inp, dict):
|
|
358
|
+
return False
|
|
359
|
+
return any(isinstance(v, str) and ATTRIB_LINE.search(v)
|
|
360
|
+
for k, v in inp.items() if k in EDIT_TEXT)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# --- language: an identifier that is not English -----------------------------
|
|
364
|
+
# What this closes: the contract says code, commits, branches and docs are
|
|
365
|
+
# English, and nothing checked it at the one moment it is cheap to check - a plan
|
|
366
|
+
# branch was created as `plan/004-admin-durum-onarimi`, and the folded slug is in
|
|
367
|
+
# the repository's history for good. The identifiers are the durable half: a
|
|
368
|
+
# branch, a commit subject and a PR or issue title are public the moment they
|
|
369
|
+
# exist, while the reply that would have said something in Turkish is not kept
|
|
370
|
+
# at all. The detector is tezgah_lang (a non-ASCII letter predicate plus a
|
|
371
|
+
# curated list of Turkish words, and its own docstring carries what it cannot
|
|
372
|
+
# decide); this half is the command shapes that create an identifier.
|
|
373
|
+
#
|
|
374
|
+
# Read off the RAW text, not mask()'d text: an identifier usually arrives quoted
|
|
375
|
+
# (`git commit -m "..."`), and mask() blanks quoted strings by design. That is
|
|
376
|
+
# why the shapes below are exact rather than a scan for a foreign word anywhere - a
|
|
377
|
+
# command that merely NAMES the words (a `grep`, an `echo`, a `git log --grep`)
|
|
378
|
+
# creates nothing and must pass, which is the same line the attribution rule
|
|
379
|
+
# draws for its own forms.
|
|
380
|
+
#
|
|
381
|
+
# `git commit -F <file>` is read from the file, resolved against the call's cwd.
|
|
382
|
+
# ponytail: a file that cannot be read (a missing path, an unreadable one, a
|
|
383
|
+
# binary) is skipped rather than guessed at - the ceiling is named here, and the
|
|
384
|
+
# other commit routes (`--fill`, `-m` used twice) are read for their first
|
|
385
|
+
# argument only.
|
|
386
|
+
BRANCH_NEW = re.compile(
|
|
387
|
+
r"(?:^|[|;&(]\s*|\s)git\s+(?:-{1,2}\S+(?:\s+\S+)?\s+)*"
|
|
388
|
+
r"(?:checkout\s+(?:-\S+\s+)*(?:-b|-B|--branch)\s+"
|
|
389
|
+
r"|switch\s+(?:-\S+\s+)*(?:-c|-C|--create)\s+"
|
|
390
|
+
r"|branch\s+(?!-))"
|
|
391
|
+
r"([^\s;&|]+)")
|
|
392
|
+
COMMIT_MSG = re.compile(
|
|
393
|
+
r"(?:^|[|;&(]\s*|\s)git\s+(?:-{1,2}\S+(?:\s+\S+)?\s+)*commit\b([^|;&]*)")
|
|
394
|
+
# The flag and its value, which may be one token, a quoted string, or `=`-joined.
|
|
395
|
+
# The cluster form is read (`git commit -am "..."`), and `(?<![\w-])` is what
|
|
396
|
+
# keeps the short flag from matching inside the long one - `--message` holds a
|
|
397
|
+
# literal `-m` - and keeps `-am` from being read as a word. `--amend` and
|
|
398
|
+
# `--file` are not message flags, and neither is reached: a flag cluster cannot
|
|
399
|
+
# start on the second `-` of a long option, because the lookbehind rejects it.
|
|
400
|
+
MSG_FLAG = re.compile(r"(?<![\w-])(?:--message|-[A-Za-z]*m)(?:=|\s+|(?=[\"']))")
|
|
401
|
+
MSG_FILE = re.compile(r"(?<![\w-])(?:--file|-[A-Za-z]*F)(?:=|\s+|(?=[\"']))")
|
|
402
|
+
GH_NEW = re.compile(
|
|
403
|
+
r"(?:^|[|;&(]\s*|\s)gh\s+(pr|issue)\s+create\b([^|;&]*)", re.I)
|
|
404
|
+
TITLE_FLAG = re.compile(r"(?<![\w-])--title(?:=|\s+|(?=[\"']))")
|
|
405
|
+
# How much of a commit-message file is read: the subject is its first line, so a
|
|
406
|
+
# file past this bound loses its tail and not its point.
|
|
407
|
+
MSG_READ = 64 * 1024
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _value(text):
|
|
411
|
+
"""The value of a flag from the text that follows it: a quoted string when
|
|
412
|
+
the value opens with a quote (that is what carries a multi-word subject),
|
|
413
|
+
else the next token."""
|
|
414
|
+
text = text.lstrip()
|
|
415
|
+
if text[:1] in ("\"", "'"):
|
|
416
|
+
end = text.find(text[0], 1)
|
|
417
|
+
return text[1:end] if end != -1 else text[1:]
|
|
418
|
+
# keyword `maxsplit`: the positional form warns on 3.13+ and a future
|
|
419
|
+
# keyword-only signature would raise, failing the gate open (audit QA-3)
|
|
420
|
+
return re.split(r"[\s;&|]", text, maxsplit=1)[0]
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _flag_value(rest, flag):
|
|
424
|
+
"""The value of `flag` in `rest`, or "" when the flag is not there."""
|
|
425
|
+
m = flag.search(rest)
|
|
426
|
+
return _value(rest[m.end():]) if m else ""
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _message_file(path, cwd):
|
|
430
|
+
"""The text of a `-F` file, or "" when it cannot be read (see the ceiling
|
|
431
|
+
above: an unreadable path skips the check rather than denying the commit)."""
|
|
432
|
+
if not path:
|
|
433
|
+
return ""
|
|
434
|
+
full = path if os.path.isabs(path) else os.path.join(cwd or "", path)
|
|
435
|
+
try:
|
|
436
|
+
with open(full, encoding="utf-8", errors="replace") as fh:
|
|
437
|
+
return fh.read(MSG_READ)
|
|
438
|
+
except OSError:
|
|
439
|
+
return ""
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def created_texts(command, cwd):
|
|
443
|
+
"""Every identifier this command would create, as (what, text) pairs.
|
|
444
|
+
|
|
445
|
+
`what` names the thing in the words the command itself uses, so the refusal
|
|
446
|
+
can say which identifier it is about."""
|
|
447
|
+
c = str(command or "")
|
|
448
|
+
out = [("branch name", m.group(1)) for m in BRANCH_NEW.finditer(c)]
|
|
449
|
+
for m in COMMIT_MSG.finditer(c):
|
|
450
|
+
rest = m.group(1)
|
|
451
|
+
subject = _flag_value(rest, MSG_FLAG)
|
|
452
|
+
if subject:
|
|
453
|
+
out.append(("commit subject", subject))
|
|
454
|
+
body = _message_file(_flag_value(rest, MSG_FILE), cwd)
|
|
455
|
+
if body:
|
|
456
|
+
out.append(("commit message", body))
|
|
457
|
+
for m in GH_NEW.finditer(c):
|
|
458
|
+
title = _flag_value(m.group(2), TITLE_FLAG)
|
|
459
|
+
if title:
|
|
460
|
+
kind = "PR" if m.group(1).lower() == "pr" else "issue"
|
|
461
|
+
out.append(("%s title" % kind, title))
|
|
462
|
+
return out
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def lang_reason(command, cwd):
|
|
466
|
+
"""A deny reason when this command would create a non-English identifier,
|
|
467
|
+
else None. The decision is tezgah_lang's (a word list is not a language
|
|
468
|
+
detector, and its docstring carries the ceiling); this reads the shapes and
|
|
469
|
+
names the identifier."""
|
|
470
|
+
if tezgah_lang is None:
|
|
471
|
+
return None
|
|
472
|
+
for what, text in created_texts(command, cwd):
|
|
473
|
+
reason = tezgah_lang.refusal(what, text)
|
|
474
|
+
if reason:
|
|
475
|
+
return reason
|
|
476
|
+
return None
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def explored(subagent_type):
|
|
480
|
+
"""True when a subagent request is the grep-only explorer."""
|
|
481
|
+
return str(subagent_type or "").lower() in ("explore", "explorer")
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def searched_identifier(tool, inp):
|
|
485
|
+
"""The bare identifier this call searches for, or None. Host tool names
|
|
486
|
+
differ in case (Claude `Grep`/`Bash`, Codex/dsh `bash`), so match lowercased."""
|
|
487
|
+
t = str(tool or "").lower()
|
|
488
|
+
if t == "grep":
|
|
489
|
+
pat = str(inp.get("pattern", ""))
|
|
490
|
+
return pat if IDENT.match(pat) else None
|
|
491
|
+
if t in ("bash", "shell"):
|
|
492
|
+
for m in BASH_SEARCH.finditer(str(inp.get("command", ""))):
|
|
493
|
+
tok = m.group(2).strip("'\"")
|
|
494
|
+
if IDENT.match(tok):
|
|
495
|
+
return tok
|
|
496
|
+
return None
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def index_slug(cwd, base):
|
|
500
|
+
"""The cache stamp name of the enclosing repo whose OWN codegraph index
|
|
501
|
+
exists, or None when no repo up to `base` has one.
|
|
502
|
+
|
|
503
|
+
codegraph keeps its index inside the project (`<repo>/.codegraph/codegraph.db`),
|
|
504
|
+
so readiness is a repo-local fact: the old per-user cache slug (a path
|
|
505
|
+
flattened into `~/.cache/<engine>/<slug>.db`) said what some other tool had
|
|
506
|
+
indexed, not what this repo carries. The walk is the old one -
|
|
507
|
+
closest-ancestor-first, and the base itself excluded when it is a configured
|
|
508
|
+
root, because a root is not a project (a base that is an armed worktree is
|
|
509
|
+
one, so its own `.codegraph` is read) - and the name returned is still the
|
|
510
|
+
key the index worker stamps HEAD under, so index_mark and index_notice keep
|
|
511
|
+
reading the same file."""
|
|
512
|
+
d = os.path.realpath(cwd)
|
|
513
|
+
base, rs = os.path.realpath(base), roots()
|
|
514
|
+
while d.startswith(base) and (d != base or base not in rs):
|
|
515
|
+
if os.path.isfile(os.path.join(d, ".codegraph", "codegraph.db")):
|
|
516
|
+
return re.sub(r"[^A-Za-z0-9]+", "-", d).strip("-")
|
|
517
|
+
d = os.path.dirname(d)
|
|
518
|
+
return None
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def nudge_mark(session_id):
|
|
522
|
+
"""The once-per-session mark: sha1(id)[:16], the key opencode's oncePerSession writes."""
|
|
523
|
+
return os.path.join(cache_dir(), "nudged", hashlib.sha1(
|
|
524
|
+
(session_id or "nosession").encode("utf-8")).hexdigest()[:16])
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def first_nudge(session_id):
|
|
528
|
+
"""Consume the once-per-session nudge. False when already spent/unwritable.
|
|
529
|
+
|
|
530
|
+
The mark lands in cache_dir(), so a sandboxed host (dsh) still gets the
|
|
531
|
+
one-shot nudge instead of the write failing open."""
|
|
532
|
+
mark = nudge_mark(session_id)
|
|
533
|
+
if os.path.exists(mark):
|
|
534
|
+
return False
|
|
535
|
+
try:
|
|
536
|
+
os.makedirs(os.path.dirname(mark), exist_ok=True)
|
|
537
|
+
open(mark, "w", encoding="utf-8").close() # consume BEFORE denying: later greps pass
|
|
538
|
+
return True
|
|
539
|
+
except OSError:
|
|
540
|
+
return False
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def nudge_reason(symbol):
|
|
544
|
+
return ("Code graph index is ready for this repo. Run `codegraph explore %s` (source, callers, "
|
|
545
|
+
"blast radius in one call) or `codegraph callers %s` / `codegraph impact %s` from a shell; "
|
|
546
|
+
"an omp subagent must use this CLI, not the MCP device, because omp's MCP device refuses "
|
|
547
|
+
"concurrent writes. If you need literal text, re-run this search unchanged; it will pass - "
|
|
548
|
+
"this nudge fires once per session." % (symbol, symbol, symbol))
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
# The identical attempts a call gets before the loop guard refuses it, whatever
|
|
552
|
+
# its failure class. Two failures are the retry the agent may still be fixing
|
|
553
|
+
# while it changes the code between them; the third is the loop the contract bans
|
|
554
|
+
# ("three attempts on one failure is the ceiling"). The class
|
|
555
|
+
# (tezgah_integrity.fail_class) is read from the ledger and named in the refusal
|
|
556
|
+
# - it says WHY the last attempt failed - and it does NOT widen this number:
|
|
557
|
+
# an identical repeat adds nothing to a transient failure (a timeout, a
|
|
558
|
+
# connection error, a rate limit, a 5xx is retried by the host's own client, not
|
|
559
|
+
# by the agent re-issuing the same command), and a ceiling that loosened for one
|
|
560
|
+
# class would depend on error text the host may not report at all, so the same
|
|
561
|
+
# command would have two budgets on two hosts. G7's own row asked for a cap of
|
|
562
|
+
# two; the class-scoped ceiling it replaced read as its inverse - the least
|
|
563
|
+
# controllable failure bought the most identical repeats.
|
|
564
|
+
LOOP_ATTEMPTS = 2
|
|
565
|
+
CLASS_NOTE = {
|
|
566
|
+
"transient": "it names a failure the host's own client may retry - a "
|
|
567
|
+
"timeout, a connection error, a rate limit, a 5xx - and an "
|
|
568
|
+
"identical repeat by the agent adds nothing to that",
|
|
569
|
+
"user": "it names something only the user can fix - a credential, a "
|
|
570
|
+
"login, an access grant - so stop and ask the user for it rather "
|
|
571
|
+
"than change the approach",
|
|
572
|
+
"permanent": "an assertion or a bad argument does not change by re-running "
|
|
573
|
+
"it",
|
|
574
|
+
}
|
|
575
|
+
NO_CLASS_NOTE = ("the host reported no error text for it, so the class is "
|
|
576
|
+
"unknown, and the cap is the same either way")
|
|
577
|
+
|
|
578
|
+
# The other half of the repeat rule, session-wide and blind to the outcome: a
|
|
579
|
+
# call the gate has seen run three times may not run a fourth, whatever those
|
|
580
|
+
# runs returned. It is deliberately above LOOP_ATTEMPTS - the loop guard counts
|
|
581
|
+
# only the attempts that failed, in the current turn, so a call that keeps
|
|
582
|
+
# "succeeding" without moving the work forward is its blind spot - and it is set
|
|
583
|
+
# above the common work loop (edit, test, edit, test reaches two identical test
|
|
584
|
+
# runs, and a session's third `git status` still passes) so only a genuine spin
|
|
585
|
+
# reaches it.
|
|
586
|
+
RETRY_CEILING = 3
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def loop_reason(tool, inp, session_id, agent=None):
|
|
590
|
+
"""A deny reason when this exact call already failed often enough, else None.
|
|
591
|
+
|
|
592
|
+
The ledger tail is the only state this reads, and only the outcome rows of
|
|
593
|
+
the current user turn count (tezgah_integrity.prior_calls): the guard's own
|
|
594
|
+
`deny` row carries the same id with no exit, so counting it would put the
|
|
595
|
+
refusal newest, read as "no failure" and let every second repeat through.
|
|
596
|
+
|
|
597
|
+
Past the ceiling the identical retry cannot work - the agent has to change
|
|
598
|
+
the approach or stop, which is the contract's loop rule ("never repeat an
|
|
599
|
+
identical failing command") given a mechanical half. A call that never
|
|
600
|
+
failed, or that failed differently, is not this rule's."""
|
|
601
|
+
if not session_id:
|
|
602
|
+
return None
|
|
603
|
+
digest = call_id(tool, inp)
|
|
604
|
+
if not digest:
|
|
605
|
+
return None
|
|
606
|
+
turn, _, last_exit, klass = prior_calls(session_id, digest, agent=agent)
|
|
607
|
+
if last_exit != 1 or turn < LOOP_ATTEMPTS:
|
|
608
|
+
return None
|
|
609
|
+
return ("Loop guard denied: this is attempt %d of an identical call whose "
|
|
610
|
+
"%d previous attempt%s exited 1%s. The cap is %d identical attempts "
|
|
611
|
+
"for every failure class, because %s. Repeating an identical failing "
|
|
612
|
+
"command is not a retry - change the approach (fix what the error "
|
|
613
|
+
"names, or run something else) or stop and report what is still "
|
|
614
|
+
"unknown."
|
|
615
|
+
% (turn + 1, turn, "" if turn == 1 else "s",
|
|
616
|
+
" (a %s failure)" % klass if klass else "",
|
|
617
|
+
LOOP_ATTEMPTS, CLASS_NOTE.get(klass, NO_CLASS_NOTE)))
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def retry_reason(tool, inp, session_id, agent=None):
|
|
621
|
+
"""A deny reason when this exact call has already been attempted more than
|
|
622
|
+
RETRY_CEILING times in this session, whatever those attempts returned.
|
|
623
|
+
|
|
624
|
+
`loop` needs a failure to fire, so the call that runs ten times and returns 0
|
|
625
|
+
each time - the spin that never reaches a decision - passes it forever. This
|
|
626
|
+
is that half: the count is every attempt of the id that ran in the session,
|
|
627
|
+
outcome-blind, and the user's turn does not reset it. A refused call never
|
|
628
|
+
ran, so the gate's own denials are not attempts and cannot walk a call up to
|
|
629
|
+
the ceiling by themselves.
|
|
630
|
+
|
|
631
|
+
A verification command is the exception, and the tree is the switch: a check
|
|
632
|
+
re-run after the tree changed is not a blind repeat - the edit-loop cadence
|
|
633
|
+
needs it - so its attempts are counted since the last write row only. What
|
|
634
|
+
still hits the ceiling is a check run four times on an unchanged tree, which
|
|
635
|
+
is the spin this guard exists for."""
|
|
636
|
+
if not session_id:
|
|
637
|
+
return None
|
|
638
|
+
digest = call_id(tool, inp)
|
|
639
|
+
if not digest:
|
|
640
|
+
return None
|
|
641
|
+
attempts = prior_calls(session_id, digest, agent=agent)[1]
|
|
642
|
+
if attempts < RETRY_CEILING:
|
|
643
|
+
return None
|
|
644
|
+
if str(tool or "").lower() in BASH_TOOLS \
|
|
645
|
+
and verify_command((inp or {}).get("command")) \
|
|
646
|
+
and _tree_changed_since_last_attempt(session_id, digest):
|
|
647
|
+
return None
|
|
648
|
+
return ("Retry ceiling denied: this is attempt %d of an identical call in "
|
|
649
|
+
"this session, past the ceiling of %d attempts whatever their "
|
|
650
|
+
"outcome. An unchanged repeat is not a retry - change the arguments "
|
|
651
|
+
"or the target, or stop and report what is still unknown. (The "
|
|
652
|
+
"`loop` guard is the narrower rule: the identical attempts that "
|
|
653
|
+
"FAILED, counted per user turn.)" % (attempts + 1, RETRY_CEILING))
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _tree_changed_since_last_attempt(session_id, digest):
|
|
657
|
+
"""True when any write row sits between the newest attempt of this call and
|
|
658
|
+
now: the tree the last check ran on is gone, so a re-run reads a new state.
|
|
659
|
+
|
|
660
|
+
prior_calls' scan is reused: the rows it counted attempts over are the rows
|
|
661
|
+
this reads, so there is no second ledger scan. A row with no `id` is a deny
|
|
662
|
+
or a nudge, never a real call. Any failure here fails open - the ceiling is
|
|
663
|
+
the conservative answer."""
|
|
664
|
+
try:
|
|
665
|
+
rows = events(session_id, tail=200)
|
|
666
|
+
idx = [i for i, row in enumerate(rows)
|
|
667
|
+
if row.get("id") == digest and row.get("exit") is not None]
|
|
668
|
+
# the NEWEST attempt, not the oldest: anchoring on the oldest let one
|
|
669
|
+
# early write exempt every later repeat for the rest of the session
|
|
670
|
+
start = idx[-1] if idx else len(rows)
|
|
671
|
+
return any(str(row.get("tool", "")).lower() in WRITE_TOOLS
|
|
672
|
+
or str(row.get("kind", "")) in ("edit",)
|
|
673
|
+
for row in rows[start:])
|
|
674
|
+
except Exception:
|
|
675
|
+
return False
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def secret_command(command):
|
|
679
|
+
"""A deny reason when this command would write a credential into a file.
|
|
680
|
+
|
|
681
|
+
Reading an env var or running a tool with a key in its env is the normal work
|
|
682
|
+
this must not touch, so a token only counts next to a write sink, and a sink
|
|
683
|
+
only carries the text of its own simple command. A `git add` that names a
|
|
684
|
+
credential file is refused whatever its text holds (`_secret_add`)."""
|
|
685
|
+
c = str(command or "")
|
|
686
|
+
added = _secret_add(c)
|
|
687
|
+
if added:
|
|
688
|
+
return SECRET_ADD_DENY % added
|
|
689
|
+
if not c or not SECRET_TOKEN.search(c):
|
|
690
|
+
return None
|
|
691
|
+
masked = mask(c)
|
|
692
|
+
start = 0
|
|
693
|
+
for end in [m.start() for m in SEGMENT.finditer(masked)] + [len(c)]:
|
|
694
|
+
if (SECRET_TOKEN.search(c[start:end])
|
|
695
|
+
and SECRET_SINK.search(masked[start:end])):
|
|
696
|
+
return SECRET_DENY
|
|
697
|
+
start = end
|
|
698
|
+
return None
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _secret_add(command):
|
|
702
|
+
"""The credential file a `git add` in this command names, or None."""
|
|
703
|
+
if SECRET_FILE is None or "add" not in command:
|
|
704
|
+
return None
|
|
705
|
+
for words in _shell_segments(command):
|
|
706
|
+
program, args = _program(words)
|
|
707
|
+
if program != "git":
|
|
708
|
+
continue
|
|
709
|
+
i = 0
|
|
710
|
+
while i < len(args) and args[i].startswith("-"):
|
|
711
|
+
i += 2 if args[i] in GIT_VALUE_OPTS else 1
|
|
712
|
+
if args[i:i + 1] != ["add"]:
|
|
713
|
+
continue
|
|
714
|
+
for arg in args[i + 1:]:
|
|
715
|
+
name = os.path.basename(arg.rstrip("/"))
|
|
716
|
+
if (not arg.startswith("-") and SECRET_FILE.match(name)
|
|
717
|
+
and name.lower() not in SECRET_TEMPLATES):
|
|
718
|
+
return arg
|
|
719
|
+
return None
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _placeholder(token):
|
|
723
|
+
"""True for a documentation stand-in rather than a credential."""
|
|
724
|
+
if "example" in token.lower():
|
|
725
|
+
return True
|
|
726
|
+
body = re.split(r"[-_]", token)[-1]
|
|
727
|
+
if body == token: # AKIA/ASIA/AIza: the prefix carries no separator
|
|
728
|
+
body = token[4:]
|
|
729
|
+
if len(set(body)) <= 1:
|
|
730
|
+
return True
|
|
731
|
+
return hashlib.sha256(token.encode()).hexdigest() in SECRET_PUBLISHED
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def secret_edit(inp, cwd=None):
|
|
735
|
+
"""A deny reason when a write lands a prefixed credential token the file did
|
|
736
|
+
not already carry, else None. Every write dialect is read, an apply_patch
|
|
737
|
+
file by file (tezgah_integrity.write_texts); the heredoc body a shell
|
|
738
|
+
command writes arrives here in the same `{file_path, content}` shape. The
|
|
739
|
+
baseline is the text the call replaces plus the file on disk, resolved
|
|
740
|
+
against the call's cwd, so an edit that repeats a key the file already holds
|
|
741
|
+
is not a new one; the disk is read only when the new text carries a token.
|
|
742
|
+
A template or fixture file and a documentation placeholder are exempt
|
|
743
|
+
(SECRET_FIXTURE, _placeholder)."""
|
|
744
|
+
for path, old, new in write_texts(inp):
|
|
745
|
+
fresh = {t for t in SECRET_PREFIXED.findall(new) if not _placeholder(t)}
|
|
746
|
+
name = path.replace(os.sep, "/")
|
|
747
|
+
if not fresh or (SECRET_FIXTURE.search(name)
|
|
748
|
+
or os.path.basename(name).lower() in SECRET_TEMPLATES):
|
|
749
|
+
continue
|
|
750
|
+
disk = ""
|
|
751
|
+
if path:
|
|
752
|
+
try:
|
|
753
|
+
with open(os.path.join(cwd or os.getcwd(), path),
|
|
754
|
+
encoding="utf-8") as fh:
|
|
755
|
+
disk = fh.read()
|
|
756
|
+
except (OSError, UnicodeDecodeError):
|
|
757
|
+
disk = ""
|
|
758
|
+
if fresh - set(SECRET_PREFIXED.findall((old or "") + "\n" + disk)):
|
|
759
|
+
return SECRET_EDIT_DENY
|
|
760
|
+
return None
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
# --- concurrent write: another session wrote this file minutes ago -----------
|
|
764
|
+
# What this closes: two sessions on one file - a parent and the subagent it
|
|
765
|
+
# delegated to, or two worktrees of the same repo. Each reads before the other
|
|
766
|
+
# writes, so the second edit lands on a stale read, and the first session's work
|
|
767
|
+
# is gone with nothing in either transcript saying so. That is why the collision
|
|
768
|
+
# REFUSES instead of annotating: a silent overwrite is the failure, and a note
|
|
769
|
+
# the agent may read past leaves the overwrite exactly where it was. One re-read
|
|
770
|
+
# is the whole cost of the refusal.
|
|
771
|
+
#
|
|
772
|
+
# Why ten minutes: the ledger holds no lock, so the window is the only release -
|
|
773
|
+
# a session that crashed or was closed mid-edit never says it is finished. Ten
|
|
774
|
+
# minutes outlives a slow read-edit-check cycle on one file plus a long tool call
|
|
775
|
+
# between the two writes, which is the collision this catches; a shorter window
|
|
776
|
+
# let the second write through in the minute that matters. Wider and the refusal
|
|
777
|
+
# lands on edits whose other author is long gone, which trains the agent to
|
|
778
|
+
# re-issue without reading.
|
|
779
|
+
RACE_WINDOW_MIN = 10
|
|
780
|
+
# The decision itself, named rather than left as a literal inside the check:
|
|
781
|
+
# a foreign write REFUSES (True). See above - a notice does not close a silent
|
|
782
|
+
# overwrite, so this rule cannot be a soft one.
|
|
783
|
+
RACE_REFUSE = True
|
|
784
|
+
RACE_DENY = (
|
|
785
|
+
"Concurrent write refused: session %s wrote this file within the last %d "
|
|
786
|
+
"minutes, so the copy of %s in your context may already be stale. Re-read "
|
|
787
|
+
"the file and re-apply your change to what is on disk now; if both sessions "
|
|
788
|
+
"are editing it, say so and let one of them own the file rather than "
|
|
789
|
+
"overwriting the other's work.")
|
|
790
|
+
# The file a write call names, per host dialect, plus the apply_patch headers
|
|
791
|
+
# (tezgah_integrity.PATCH_FILE) for the dialect whose paths live in the body.
|
|
792
|
+
# The race guard resolves each against the call's cwd to an absolute real path
|
|
793
|
+
# and compares it with the `target` the PostToolUse writer stored the same way
|
|
794
|
+
# (tezgah_integrity._abs_target): the raw spelling matched `README.md` in one
|
|
795
|
+
# repository against `README.md` in another (audit CHAT-03 / M-6).
|
|
796
|
+
# The harness's internal channels: a write to one of these is a message or a
|
|
797
|
+
# tool device, never a file on disk. Deliberately a list, not `scheme://`: a
|
|
798
|
+
# file reached through a URI scheme is still a file two sessions can race on.
|
|
799
|
+
URI_CHANNEL = re.compile(
|
|
800
|
+
r"^(?:agent|xd|local|artifact|proc|skill|mcp|omp|issue|pr|history|ssh|cfg)://",
|
|
801
|
+
re.I)
|
|
802
|
+
WRITE_PATH = ("file_path", "filePath", "path", "notebook_path")
|
|
803
|
+
# The write-tool name a shell write's target is handed to `capture` under. capture
|
|
804
|
+
# takes a write tool's own path field and no shell tool name, and the row it
|
|
805
|
+
# writes carries neither - so one file the shell command changes gets the same
|
|
806
|
+
# pre-state a write tool's target gets, and the ledger gains no field it did not
|
|
807
|
+
# have. See the capture call at the end of `decision`.
|
|
808
|
+
SHELL_AS_WRITE = "write"
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def write_paths(inp):
|
|
812
|
+
"""Every file this call writes: the tool's own path field, else the paths an
|
|
813
|
+
apply_patch body names per hunk, else the file a shell command's own text
|
|
814
|
+
writes through a redirect or `tee` (the shape SHELL_WRITE reads, the target
|
|
815
|
+
shell_target slices - see the shell section below).
|
|
816
|
+
|
|
817
|
+
The shell branch is the one write whose target leaves no path field behind,
|
|
818
|
+
and it is here rather than in a second reader because the two halves of that
|
|
819
|
+
write have to agree on one answer: the gate hands these paths to `capture`
|
|
820
|
+
before the call, and `tezgah_integrity._post_write` reads the same list after
|
|
821
|
+
it to hash the after-state. Raw strings, unresolved - each caller resolves
|
|
822
|
+
them against its own directory - except that a shell target's `$VAR` and
|
|
823
|
+
`~` are expanded as the shell would (`> $TMPDIR/x` is not a file named
|
|
824
|
+
`$TMPDIR` under the cwd). ponytail: a target that is a positional
|
|
825
|
+
argument (`cp`, `mv`, `sed -i`, `patch`, `git apply`) is not read off the
|
|
826
|
+
command at all; which argument of those is the target is a per-program
|
|
827
|
+
question, and the measured route is the redirect."""
|
|
828
|
+
if not isinstance(inp, dict):
|
|
829
|
+
return []
|
|
830
|
+
for key in WRITE_PATH:
|
|
831
|
+
value = inp.get(key)
|
|
832
|
+
if isinstance(value, str) and value.strip():
|
|
833
|
+
return [value.strip()]
|
|
834
|
+
paths = [m.group(1) for m in PATCH_FILE.finditer(str(inp.get("patch") or ""))]
|
|
835
|
+
if paths:
|
|
836
|
+
return paths
|
|
837
|
+
target = shell_target(inp.get("command") or inp.get("cmd") or "")
|
|
838
|
+
return [os.path.expanduser(os.path.expandvars(target))] if target else []
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def race_reason(inp, session_id, cwd=None):
|
|
842
|
+
"""A deny reason when another session wrote one of this call's files inside
|
|
843
|
+
RACE_WINDOW_MIN, else None.
|
|
844
|
+
|
|
845
|
+
`writers_elsewhere` is the ledger reader in tezgah_integrity, and its import
|
|
846
|
+
is guarded: an integrity module without it costs this rule, never the
|
|
847
|
+
session. ponytail: the PostToolUse writer records a patch's first path,
|
|
848
|
+
so a foreign patch write is visible here only for that first file while
|
|
849
|
+
this side reads every hunk's: the row is one line, and widening it means
|
|
850
|
+
the ledger row changes shape."""
|
|
851
|
+
if writers_elsewhere is None or not session_id:
|
|
852
|
+
return None
|
|
853
|
+
for path in write_paths(inp):
|
|
854
|
+
# an internal URI (`agent://Main`, `xd://<tool>`) is not a file: two
|
|
855
|
+
# observed refusals today wrote tool replies through those schemes and
|
|
856
|
+
# this guard read the scheme as a relative path. Only the harness's own
|
|
857
|
+
# channels are skipped - a `file://`, `s3://` or editor URI can name a
|
|
858
|
+
# real target two sessions both write, and stays guarded.
|
|
859
|
+
if URI_CHANNEL.match(path) or scratch_target(path, cwd):
|
|
860
|
+
continue # a temp file or a device is no one's shared work
|
|
861
|
+
others = writers_elsewhere(path, session_id, RACE_WINDOW_MIN, cwd=cwd)
|
|
862
|
+
if others:
|
|
863
|
+
return RACE_DENY % (", ".join(str(s) for s in others[:3]),
|
|
864
|
+
RACE_WINDOW_MIN, path)
|
|
865
|
+
return None
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
# --- task scope: the user's own phase and path allowlist --------------------
|
|
869
|
+
# What this closes: a session that has been told what it is working on - the
|
|
870
|
+
# user activated a plan file under .tezgah/plans/open, so the work has a phase and the
|
|
871
|
+
# files it may touch - and then writes anyway. Two shapes cost the user real
|
|
872
|
+
# work: a write in `discovery`, where the whole point of the phase is to read
|
|
873
|
+
# before deciding, and a write outside the plan's own allowlist, which is how a
|
|
874
|
+
# bounded task quietly becomes an unbounded one. Both are the user's own
|
|
875
|
+
# decisions, which is why this rule may refuse at all: the record is written by
|
|
876
|
+
# bin/tezgah-task, by hand, and never by the agent - a rule the agent could
|
|
877
|
+
# satisfy by editing its own record would be a nudge wearing a gate's clothes.
|
|
878
|
+
#
|
|
879
|
+
# The direction is fail-open wherever the record is missing, unreadable or out
|
|
880
|
+
# of phase vocabulary (tezgah_task.active returns None for those), because a
|
|
881
|
+
# refusal has to rest on an ask the user actually made: a rule that refused
|
|
882
|
+
# every write until a record appeared would be the fail-closed trap the design
|
|
883
|
+
# report names, with the whole session as its blast radius. Nothing is named
|
|
884
|
+
# that the user did not name either: an empty allowlist is no scope asked for
|
|
885
|
+
# rather than "nothing is allowed", so the phase is the whole requirement there.
|
|
886
|
+
TASK_PHASE_DENY = (
|
|
887
|
+
"Task gate: the active task %s is in phase `%s`, and this call writes. "
|
|
888
|
+
"Writes belong to %s. The phase is the user's to move: ask them, or do the "
|
|
889
|
+
"reading this phase asks for and say what the write was for.")
|
|
890
|
+
TASK_SCOPE_DENY = (
|
|
891
|
+
"Task gate: %s is outside the paths the active task %s allows (%s). The "
|
|
892
|
+
"allowlist is the user's to widen: ask them, or leave the file alone and say "
|
|
893
|
+
"why it looked in scope.")
|
|
894
|
+
# The two refusals above deliberately name no command. They used to end with the
|
|
895
|
+
# one that lifts them, and the E7 block measured what that costs: with the unlock
|
|
896
|
+
# printed in the refusal, the armed arm removed or disabled the gate in 25 of 25
|
|
897
|
+
# runs and obeyed it in none (an internal benchmark, block E7). A refusal
|
|
898
|
+
# is a boundary or it is an instruction; it cannot be both.
|
|
899
|
+
TASK_RECORD_DENY = (
|
|
900
|
+
"Task gate: %s is the active task's own record. The phase and the allowlist "
|
|
901
|
+
"live in that file, so a session that edits it is editing the boundary it is "
|
|
902
|
+
"being held to. Ask the user to change the task; the record is theirs.")
|
|
903
|
+
TASK_UNLOCK_DENY = (
|
|
904
|
+
"Task gate: this command changes the active task's own record. The phase and "
|
|
905
|
+
"the allowlist are the user's, and a session that can move them is held to "
|
|
906
|
+
"nothing. Ask the user to change the task instead.")
|
|
907
|
+
# A shell command that changes the record. Matched on the masked text, so a
|
|
908
|
+
# command that merely names the CLI - a grep over the docs, a commit message -
|
|
909
|
+
# is not refused. The token is the CLI's own basename, with or without `.py`
|
|
910
|
+
# and run through any interpreter, so any path to it counts.
|
|
911
|
+
TASK_CHANGE = re.compile(
|
|
912
|
+
r"(?:^|[|;&(]\s*|\s)(?:python3?\s+)?\S*tezgah-task(?:\.py)?\s+"
|
|
913
|
+
r"(?:start|phase|allow|stop)\b")
|
|
914
|
+
# The shapes that make a shell command a write, for the phase rule below. The
|
|
915
|
+
# tools are not the only way to change a file, and E7b measured the other one:
|
|
916
|
+
# with `edit` and `write` refused twelve and six times, the armed arm wrote the
|
|
917
|
+
# target with `cat > app/api.py <<'EOF'` instead in 3 of 25 runs - every one of
|
|
918
|
+
# them a redirect. The table is the secret rule's sink list plus the in-place
|
|
919
|
+
# editors and the copiers, because the shape reached for after the first one is
|
|
920
|
+
# refused is the next one here. Masked text is read, so a quoted `>` is not a
|
|
921
|
+
# redirect, and `> /dev/null` is not a write to a file.
|
|
922
|
+
# ponytail: a write inside a string the shell parses later (`python3 -c
|
|
923
|
+
# "open('x','w')"`) is not read - telling a read from a write there needs the
|
|
924
|
+
# mode argument, not the call - so the table holds the shapes an agent reaches
|
|
925
|
+
# for, and the phase still refuses the ones it holds. A code-execution tool is
|
|
926
|
+
# not this rule's at all, and E7c measured what that costs: one row in 25 wrote
|
|
927
|
+
# the target through `eval` after both the write tools and the shell were
|
|
928
|
+
# refused, so the residual is named here rather than closed.
|
|
929
|
+
SHELL_WRITE = re.compile(
|
|
930
|
+
r">>?(?!\s*/dev/null)(?![&=])|"
|
|
931
|
+
r"\|\s*tee\b|"
|
|
932
|
+
r"(?<![\w-])(?:sed|perl)\s+(?:-\S+\s+)*(?:-[A-Za-z]*i[A-Za-z]*)(?![A-Za-z])|"
|
|
933
|
+
r"(?<![\w-])truncate\s|"
|
|
934
|
+
r"\bdd\s+[^|;&]*\bof=|"
|
|
935
|
+
r"(?<![\w-])(?:cp|mv)\s|"
|
|
936
|
+
r"(?<![\w-])patch\s|"
|
|
937
|
+
r"(?<![\w-])git\s+(?:apply\b|restore\b|checkout\s+--)")
|
|
938
|
+
TASK_SHELL_DENY = (
|
|
939
|
+
"Task gate: the active task %s is in phase `%s`, and this command writes a "
|
|
940
|
+
"file. The shell is a write route like any other, so the phase covers it. "
|
|
941
|
+
"The phase is the user's to move: ask them, or do the reading this phase "
|
|
942
|
+
"asks for and say what the write was for.")
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
# The marker a caller passes when it has NOT read the record, so a rule reads it
|
|
946
|
+
# itself; a resolved `None` (no active task) is passed as None and is never
|
|
947
|
+
# re-read. `decision` resolves once and hands the same dict to all four rules,
|
|
948
|
+
# because each read re-lists `.tezgah/plans/open/` and re-parses the plan on a
|
|
949
|
+
# path every write of every session takes.
|
|
950
|
+
_UNRESOLVED = object()
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def _task(cwd, base, task):
|
|
954
|
+
"""The active record: the caller's when it resolved one, else resolved here.
|
|
955
|
+
A missing tezgah_task module yields None - the rule is lost, never the
|
|
956
|
+
session, the same fail-open the guarded import takes."""
|
|
957
|
+
if task is not _UNRESOLVED:
|
|
958
|
+
return task
|
|
959
|
+
if tezgah_task is None:
|
|
960
|
+
return None
|
|
961
|
+
return tezgah_task.active(cwd, base)
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def task_shell_reason(inp, cwd, base, task=_UNRESOLVED):
|
|
965
|
+
"""A deny reason when the active task's phase only reads and this shell
|
|
966
|
+
command writes a file, else None.
|
|
967
|
+
|
|
968
|
+
The route to the hole the phase rule closes, and the one E7b measured: with
|
|
969
|
+
the write tools refused, the armed arm wrote the target with a heredoc
|
|
970
|
+
redirect in 3 of 25 runs. A phase that excludes writes has to exclude them
|
|
971
|
+
however they are made, so this reads the whole SHELL_WRITE table - the
|
|
972
|
+
copiers and in-place editors whose target is a positional argument too. The
|
|
973
|
+
allowlist is not consulted here: `decision` hands a shell command's redirect
|
|
974
|
+
and `tee` targets (write_paths) to task_reason and task_record_reason like a
|
|
975
|
+
write tool's path, while a positional target is not read at all (see
|
|
976
|
+
write_paths), and the same fail-open holds: no record, no requirement."""
|
|
977
|
+
task = _task(cwd, base, task)
|
|
978
|
+
if not task or task["phase"] in tezgah_task.WRITE_PHASES:
|
|
979
|
+
return None
|
|
980
|
+
if not SHELL_WRITE.search(mask(str(inp.get("command") or ""))):
|
|
981
|
+
return None
|
|
982
|
+
return TASK_SHELL_DENY % (task["id"], task["phase"])
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def task_record_reason(inp, cwd, base, task=_UNRESOLVED):
|
|
986
|
+
"""A deny reason when this write's target IS the active task's record.
|
|
987
|
+
|
|
988
|
+
The other route to the same hole as TASK_UNLOCK_DENY, and the one a write
|
|
989
|
+
tool takes: the record is a file in the repo, so an agent that may write
|
|
990
|
+
files can retype the phase instead of running the CLI. Refused whatever the
|
|
991
|
+
phase and whatever the allowlist says - the record is not inside its own
|
|
992
|
+
scope, because a scope that can widen itself is not one."""
|
|
993
|
+
task = _task(cwd, base, task)
|
|
994
|
+
if not task:
|
|
995
|
+
return None
|
|
996
|
+
record = os.path.relpath(task["path"],
|
|
997
|
+
tezgah_task.repo_root(cwd, base)).replace(os.sep, "/")
|
|
998
|
+
for path in write_paths(inp):
|
|
999
|
+
if tezgah_task.relative(path, cwd, base) == record:
|
|
1000
|
+
return TASK_RECORD_DENY % path
|
|
1001
|
+
return None
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def task_reason(inp, cwd, base, task=_UNRESOLVED):
|
|
1005
|
+
"""A deny reason when the active task's phase or allowlist excludes this
|
|
1006
|
+
write, else None. No active task -> None: this rule never invents a
|
|
1007
|
+
requirement the user did not set.
|
|
1008
|
+
|
|
1009
|
+
The record is read through tezgah_task, whose import is guarded: a checkout
|
|
1010
|
+
without that module costs this rule, never the session. The paths are the
|
|
1011
|
+
ones write_paths already extracts, so an apply_patch body is checked hunk by
|
|
1012
|
+
hunk like every other write rule's here. A path that resolves outside the
|
|
1013
|
+
repo root is refused by `relative` before any pattern is tried - a `**`
|
|
1014
|
+
allowlist must not reach out of the repo - unless it is the session's own
|
|
1015
|
+
scratch (`scratch_target`: a temp file, a device), which is no task's work;
|
|
1016
|
+
build output inside the repo is a repo path and stays held. The refusal
|
|
1017
|
+
names the file as the call spelled it, because the agent has to recognize
|
|
1018
|
+
its own argument."""
|
|
1019
|
+
task = _task(cwd, base, task)
|
|
1020
|
+
if not task:
|
|
1021
|
+
return None
|
|
1022
|
+
if task["phase"] not in tezgah_task.WRITE_PHASES:
|
|
1023
|
+
return TASK_PHASE_DENY % (task["id"], task["phase"],
|
|
1024
|
+
" or ".join(tezgah_task.WRITE_PHASES))
|
|
1025
|
+
patterns = task["allowed_paths"]
|
|
1026
|
+
if not patterns:
|
|
1027
|
+
return None
|
|
1028
|
+
for path in write_paths(inp):
|
|
1029
|
+
if scratch_target(path, cwd):
|
|
1030
|
+
continue
|
|
1031
|
+
rel = tezgah_task.relative(path, cwd, base)
|
|
1032
|
+
if rel is None or not any(tezgah_task.match(rel, p) for p in patterns):
|
|
1033
|
+
return TASK_SCOPE_DENY % (path, task["id"], ", ".join(patterns))
|
|
1034
|
+
return None
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
# --- the implementation phase's checkpoint -----------------------------------
|
|
1038
|
+
# What this closes: a large refactor has no single restore point. The reversible
|
|
1039
|
+
# boundary the gate keeps is per file, not per work unit - hooks/tezgah_snapshot.py
|
|
1040
|
+
# captures the pre-write bytes, 200 snapshots at 2 MiB each with the oldest
|
|
1041
|
+
# evicted, and the glossary says so outright: "Not a checkpoint of the session,
|
|
1042
|
+
# only of the files a write was about to change" (docs/glossary.md:143). The plan
|
|
1043
|
+
# machine already names the exact moment the risky work starts - bin/tezgah-task
|
|
1044
|
+
# writes `phase: implementation` and this module then holds writes to the plan's
|
|
1045
|
+
# own allowlist - so that moment gets a named state too: the CLI records
|
|
1046
|
+
# `checkpoint:` as the pre-work commit's sha when the tree was clean, or as
|
|
1047
|
+
# `pending <sha>` when it was not, `<sha>` being the HEAD of that moment.
|
|
1048
|
+
#
|
|
1049
|
+
# Why the refusal is here and not only in the CLI: the CLI stands at the phase
|
|
1050
|
+
# move and is where the sha is written, but the write is the moment the un-named
|
|
1051
|
+
# state would be built on, and a plan can already be in `implementation` when
|
|
1052
|
+
# this rule lands.
|
|
1053
|
+
#
|
|
1054
|
+
# The recorded sha is what the refusal is keyed on, and the tree only says the
|
|
1055
|
+
# two states apart inside it: the sha is written at the phase move, and the commit
|
|
1056
|
+
# the refusal names moves HEAD off it, so the refusal clears itself and the phase
|
|
1057
|
+
# proceeds. Keying on the word `pending` alone did not: the commit left the record
|
|
1058
|
+
# saying `pending`, only bin/tezgah-task rewrites that record, the agent may not
|
|
1059
|
+
# run it, and the phase was then locked until the user happened to re-run the
|
|
1060
|
+
# phase command - a gate that refuses the thing it asks for. Keying on the live
|
|
1061
|
+
# tree alone would refuse the phase's own work, since the first write makes the
|
|
1062
|
+
# tree dirty and it stays that way.
|
|
1063
|
+
#
|
|
1064
|
+
# Fail-open wherever the question cannot be answered: no record, no `checkpoint:`
|
|
1065
|
+
# key, a checkpoint that is a plain sha (the boundary is already real), a `pending`
|
|
1066
|
+
# with no sha, a sha this checkout's HEAD does not name, a repo whose HEAD cannot
|
|
1067
|
+
# be read, and `task-off` above. A refusal has to rest on something a reader can
|
|
1068
|
+
# check, and a rule that cannot answer must not refuse.
|
|
1069
|
+
#
|
|
1070
|
+
# The refusal names the one command that makes the boundary real. That is not the
|
|
1071
|
+
# mistake TASK_PHASE_DENY records: the E7 lesson is a refusal that names how to
|
|
1072
|
+
# take the constraint off (the phase is the user's to move), while the checkpoint
|
|
1073
|
+
# command *satisfies* the requirement - running it is the work the rule asks for,
|
|
1074
|
+
# so naming it is naming what the rule wants rather than a way around it.
|
|
1075
|
+
CHECKPOINT_DENY = (
|
|
1076
|
+
"Checkpoint gate: the active task %s is in phase `implementation` and its "
|
|
1077
|
+
"plan records a pending checkpoint (`checkpoint: pending <sha>`), so this "
|
|
1078
|
+
"write would start the work on top of changes no commit names - and the "
|
|
1079
|
+
"per-file snapshots are not one state to return to. The command that makes "
|
|
1080
|
+
"the boundary real is %s; it moves HEAD off the recorded sha, so running it "
|
|
1081
|
+
"satisfies this rule and moves nothing.")
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def checkpoint_reason(inp, cwd, base, task=_UNRESOLVED):
|
|
1085
|
+
"""A deny reason when the active task is in `implementation`, its plan's
|
|
1086
|
+
`checkpoint:` waits for a commit (`pending <sha>`), that sha is still HEAD,
|
|
1087
|
+
and the tree still holds uncommitted work - else None.
|
|
1088
|
+
|
|
1089
|
+
The recorded sha, not the word `pending`, is the switch: the commit the
|
|
1090
|
+
refusal names moves HEAD off it, so the refusal clears by itself. The section
|
|
1091
|
+
above carries what this closes, why the record and not the tree is the
|
|
1092
|
+
switch, and every shape that fails open."""
|
|
1093
|
+
if tezgah_task is None:
|
|
1094
|
+
return None
|
|
1095
|
+
command = inp.get("command")
|
|
1096
|
+
if command is not None and not SHELL_WRITE.search(mask(str(command))):
|
|
1097
|
+
# a shell line that changes no file is not this rule's, the same read of
|
|
1098
|
+
# the same shape table task_shell_reason makes
|
|
1099
|
+
return None
|
|
1100
|
+
task = _task(cwd, base, task)
|
|
1101
|
+
if not task or task["phase"] != "implementation":
|
|
1102
|
+
return None
|
|
1103
|
+
sha = tezgah_task.checkpoint_sha(task.get("checkpoint"))
|
|
1104
|
+
if not sha:
|
|
1105
|
+
return None
|
|
1106
|
+
# HEAD read from the ref file, not a fork: this question is asked on every
|
|
1107
|
+
# write of the phase, and an unreadable HEAD fails open.
|
|
1108
|
+
root = tezgah_task.repo_root(cwd, base)
|
|
1109
|
+
if _head(root) != sha:
|
|
1110
|
+
return None
|
|
1111
|
+
import subprocess # deferred: only a plan still pending its checkpoint pays
|
|
1112
|
+
try:
|
|
1113
|
+
listed = subprocess.run(["git", "-C", root, "status", "--porcelain"],
|
|
1114
|
+
capture_output=True, text=True, timeout=5)
|
|
1115
|
+
except (OSError, subprocess.SubprocessError):
|
|
1116
|
+
return None
|
|
1117
|
+
if listed.returncode != 0 or not listed.stdout.strip():
|
|
1118
|
+
return None
|
|
1119
|
+
return CHECKPOINT_DENY % (task["id"], tezgah_task.checkpoint_command(task["path"]))
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
# --- workspace: per-project tezgah state lives under <repo>/.tezgah only ------
|
|
1123
|
+
# What this closes: sessions wrote `plans/`, `research/` and `analysis/` at the
|
|
1124
|
+
# project root (observed across several unrelated projects on one machine),
|
|
1125
|
+
# where the project's own git picks them up. A root directory the project itself
|
|
1126
|
+
# already tracks files under is the project's own and is left alone.
|
|
1127
|
+
# ponytail: `mkdir research` and a positional shell target (`cp x research/`) are
|
|
1128
|
+
# not read, the same ceiling as write_paths; the first file written through a
|
|
1129
|
+
# tool or a redirect is.
|
|
1130
|
+
WORKSPACE_KINDS = ("plans", "research", "analysis")
|
|
1131
|
+
WORKSPACE_DENY = (
|
|
1132
|
+
"Workspace gate: %s puts tezgah state in the project's own `%s/`. Per-project "
|
|
1133
|
+
"plans, research and analysis live under `.tezgah/%s/` - ignored by the "
|
|
1134
|
+
"project, committed with `git -C .tezgah ...` - so write it there.")
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def workspace_reason(inp, cwd, base):
|
|
1138
|
+
"""A deny reason when this write lands in a root `plans/`, `research/` or
|
|
1139
|
+
`analysis/` the project does not track, else None."""
|
|
1140
|
+
if tezgah_task is None:
|
|
1141
|
+
return None
|
|
1142
|
+
for path in write_paths(inp):
|
|
1143
|
+
rel = tezgah_task.relative(path, cwd, base)
|
|
1144
|
+
seg = rel.split("/", 1)[0] if rel and "/" in rel else None
|
|
1145
|
+
if seg not in WORKSPACE_KINDS:
|
|
1146
|
+
continue
|
|
1147
|
+
import subprocess # deferred: only a write under one of the three pays
|
|
1148
|
+
try:
|
|
1149
|
+
listed = subprocess.run(
|
|
1150
|
+
["git", "-C", tezgah_task.repo_root(cwd, base), "ls-files", "--", seg],
|
|
1151
|
+
capture_output=True, text=True, timeout=5)
|
|
1152
|
+
except (OSError, subprocess.SubprocessError):
|
|
1153
|
+
continue
|
|
1154
|
+
if listed.returncode == 0 and not listed.stdout.strip():
|
|
1155
|
+
return WORKSPACE_DENY % (path, seg, seg)
|
|
1156
|
+
return None
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
# --- lesson lineage: a ledger write in a turn that read untrusted text -------
|
|
1160
|
+
# What this records (security-08): any session can append a standing constraint
|
|
1161
|
+
# to `.tezgah/lessons.md`, and a line written in a turn that read a web page, an
|
|
1162
|
+
# MCP answer or a subagent's report is injected into every later session like
|
|
1163
|
+
# one the user asked for. Owner decision ADR 010: a tainted lesson costs a row,
|
|
1164
|
+
# never a refusal - so this is not a rule, has no `_deny` slug and no switch of
|
|
1165
|
+
# its own beyond `pretooluse-off`. The fold is over every row of the user turn
|
|
1166
|
+
# (`turn_rows`, scoped to the calling subagent), not `tezgah_untrusted.
|
|
1167
|
+
# turn_channel`: that is a pending state the first effect after the read spends,
|
|
1168
|
+
# and a lesson is usually written after other work.
|
|
1169
|
+
# ponytail: a shell write whose body is not a heredoc (`echo x >> lessons.md`),
|
|
1170
|
+
# or whose target is a positional argument (`cp`, `sed -i`), leaves a row with
|
|
1171
|
+
# no `key` or no row at all - the gate cannot read the line it adds - so the
|
|
1172
|
+
# context cannot label that line.
|
|
1173
|
+
def lesson_taint(tool, inp, cwd, session_id, agent=None, shell_body=None):
|
|
1174
|
+
"""Write one `lesson_tainted` row per lesson line this call leaves in a
|
|
1175
|
+
ledger that the ledger did not hold, when the user turn already holds a row
|
|
1176
|
+
carrying `source`; a single row with no `key` when the call names no new
|
|
1177
|
+
line the gate can read (an unread body, an edit whose old text is not there,
|
|
1178
|
+
a removal). A write tool's lines are read off the file as it will be once
|
|
1179
|
+
the call lands (`_after_text`), so a partial-line edit names the line it
|
|
1180
|
+
leaves, not its fragment. The keyed rows also go to the ledger's repository
|
|
1181
|
+
index (`tezgah_lessons.write_taint`), which the context labels lines from.
|
|
1182
|
+
Returns nothing: it is never a refusal."""
|
|
1183
|
+
if tezgah_lessons is None or turn_rows is None or not session_id:
|
|
1184
|
+
return
|
|
1185
|
+
t = str(tool or "").lower()
|
|
1186
|
+
|
|
1187
|
+
def norm(path):
|
|
1188
|
+
return os.path.normpath(os.path.join(cwd, os.path.expanduser(path)))
|
|
1189
|
+
|
|
1190
|
+
texts = {norm(path): new for path, _old, new in
|
|
1191
|
+
write_texts(inp if t in WRITE_TOOLS else shell_body)}
|
|
1192
|
+
targets = sorted(p for p in set(texts) | set(map(norm, write_paths(inp)))
|
|
1193
|
+
if tezgah_lessons.is_ledger(p))
|
|
1194
|
+
if not targets:
|
|
1195
|
+
return
|
|
1196
|
+
source = next((row["source"] for row in reversed(
|
|
1197
|
+
turn_rows(session_id, agent=agent)) if row.get("source")), None)
|
|
1198
|
+
if not source:
|
|
1199
|
+
return
|
|
1200
|
+
key_of = tezgah_lessons.lesson_key
|
|
1201
|
+
for path in targets:
|
|
1202
|
+
try:
|
|
1203
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
1204
|
+
current = fh.read()
|
|
1205
|
+
except OSError:
|
|
1206
|
+
current = ""
|
|
1207
|
+
have = set(map(key_of, tezgah_lessons.entries(current)))
|
|
1208
|
+
new = texts.get(path)
|
|
1209
|
+
if t in WRITE_TOOLS and new is not None:
|
|
1210
|
+
after = _after_text(inp, current)
|
|
1211
|
+
new = new if after is None else after # None: a patch's `+` lines
|
|
1212
|
+
keys = [k for k in dict.fromkeys(map(key_of, tezgah_lessons.entries(
|
|
1213
|
+
new or ""))) if k not in have] or [None]
|
|
1214
|
+
fields = dict(source=source, target=path, agent=agent,
|
|
1215
|
+
workspace=root_for(cwd), id=call_id(tool, inp))
|
|
1216
|
+
for key in keys:
|
|
1217
|
+
note(session_id, "lesson_tainted", source, key=key, **fields)
|
|
1218
|
+
if keys != [None]:
|
|
1219
|
+
write_taint(os.path.dirname(os.path.dirname(path)), have | set(keys),
|
|
1220
|
+
[{"key": k, "source": source, "ts": int(time.time())}
|
|
1221
|
+
for k in keys])
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
# --- the shell's write body: three write-tool rules reached through a heredoc -
|
|
1225
|
+
# What this closes (the route E7c measured): SKIP_TEST, ATTRIB_LINE and the
|
|
1226
|
+
# prefixed-token credential scan (secret_edit) read the text a write tool lands,
|
|
1227
|
+
# and the write tools are disjoint from BASH_TOOLS, so a heredoc that wrote a
|
|
1228
|
+
# test skip, an attribution line or a key into a file was refused by nothing -
|
|
1229
|
+
# and E7c watched the armed arm take exactly that route once the write tools
|
|
1230
|
+
# were refused (3 of 25 runs wrote the target with `cat > app/api.py <<'EOF'`).
|
|
1231
|
+
# Each of the three now also reads the body a shell command writes, and the body
|
|
1232
|
+
# is the RAW text because mask() blanks heredoc bodies by design: a command that
|
|
1233
|
+
# merely names a rule must not be denied.
|
|
1234
|
+
#
|
|
1235
|
+
# The shape test is the task rule's own SHELL_WRITE, read off the masked text, so
|
|
1236
|
+
# a quoted `>` is not a redirect; the bodies come from the raw text under it, one
|
|
1237
|
+
# per marker, with an unterminated heredoc skipped the way tezgah_integrity's own
|
|
1238
|
+
# blanker skips it (the ceiling the two share).
|
|
1239
|
+
# ponytail: only a heredoc's body is read. A write whose content is a quoted
|
|
1240
|
+
# argument (`echo "Co-Authored-By: x" > f`) is not, and cannot be: the credit
|
|
1241
|
+
# there sits inside a string no line-start anchor can see, while widening this to
|
|
1242
|
+
# the whole command text would deny a search for the form (`grep "Co-Authored-By"
|
|
1243
|
+
# x > out`). The measured route is the heredoc, and that route is closed.
|
|
1244
|
+
SHELL_TARGET = re.compile(r">>?(?![&=])\s*(\S+)|(?<![\w-])tee\s+(?:-\S+\s+)*(\S+)")
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
def shell_target(command):
|
|
1248
|
+
"""The file a shell command's own text writes, or "" - the real redirect's
|
|
1249
|
+
target, or `tee`'s argument, sliced from the raw text at the offset the masked
|
|
1250
|
+
match proved (masking keeps length), so a quoted `>` is not a redirect and
|
|
1251
|
+
`> /dev/null` is not a file.
|
|
1252
|
+
|
|
1253
|
+
The one reader of a shell write's target: `write_paths` uses it for the file
|
|
1254
|
+
a shell call changes (which is what makes the gate capture a pre-state for
|
|
1255
|
+
that write and `_post_write` hash its after-state), and `shell_write_body`
|
|
1256
|
+
uses it for the file whose body the three write-tool twins read. Empty for a
|
|
1257
|
+
command that writes nothing, and for every write whose target is a positional
|
|
1258
|
+
argument rather than a redirect - see `write_paths`.
|
|
1259
|
+
|
|
1260
|
+
ponytail: a target that is a QUOTED name (`printf a > "a;b.txt"`) is not read
|
|
1261
|
+
at all - the slice is taken by offset from the masked text, and masking blanks
|
|
1262
|
+
a quoted string, so the match never lands. Measured 2026-09-19: the call
|
|
1263
|
+
captures nothing and its write stays out of the freshness fold, the same hole
|
|
1264
|
+
the separator trim closed for an unquoted chain. Closing it means reading the
|
|
1265
|
+
target off the raw text with a quote-aware scan."""
|
|
1266
|
+
c = str(command or "")
|
|
1267
|
+
if not c:
|
|
1268
|
+
return ""
|
|
1269
|
+
masked = mask(c)
|
|
1270
|
+
if not SHELL_WRITE.search(masked):
|
|
1271
|
+
return ""
|
|
1272
|
+
m = SHELL_TARGET.search(masked)
|
|
1273
|
+
if not m:
|
|
1274
|
+
return ""
|
|
1275
|
+
start, end = m.span(1) if m.group(1) else m.span(2)
|
|
1276
|
+
raw = c[start:end]
|
|
1277
|
+
# `\S+` runs to the next whitespace, so the separator of a chained command
|
|
1278
|
+
# came with the name: `printf a > x; printf b > y` sliced `x;`, a path
|
|
1279
|
+
# nothing is ever written to - no pre-state captured, no after-state hashed,
|
|
1280
|
+
# and the write invisible to the freshness fold. A quoted target keeps its
|
|
1281
|
+
# name whole, so only an unquoted slice is trimmed.
|
|
1282
|
+
if raw[:1] in ("'", '"'):
|
|
1283
|
+
return raw.strip("'\"")
|
|
1284
|
+
return raw.strip("'\"").rstrip(";|&)")
|
|
1285
|
+
|
|
1286
|
+
|
|
1287
|
+
def shell_write_body(command, cwd=None):
|
|
1288
|
+
"""The `{file_path, content}` a shell command writes into a file, or None when
|
|
1289
|
+
this command writes no file's content of its own.
|
|
1290
|
+
|
|
1291
|
+
The shape test is SHELL_WRITE on the masked text, so a quoted `>` is not a
|
|
1292
|
+
redirect; the path is that real redirect's target, or `tee`'s argument, sliced
|
|
1293
|
+
from the raw text at the offset the masked match proved (masking keeps
|
|
1294
|
+
length), and resolved against the call's own directory - so a write to a test
|
|
1295
|
+
file is compared against the file the call named and not against whichever one
|
|
1296
|
+
the hook process happens to sit in."""
|
|
1297
|
+
c = str(command or "")
|
|
1298
|
+
if not c or not SHELL_WRITE.search(mask(c)):
|
|
1299
|
+
return None
|
|
1300
|
+
# the bodies bash reads (tezgah_integrity._heredocs): a `<<'X'` inside a
|
|
1301
|
+
# quoted string or a comment is not one (review R1)
|
|
1302
|
+
bodies = heredoc_bodies(c)
|
|
1303
|
+
if not bodies:
|
|
1304
|
+
return None
|
|
1305
|
+
path = shell_target(c)
|
|
1306
|
+
if path and not os.path.isabs(path):
|
|
1307
|
+
path = os.path.join(cwd or os.getcwd(), path)
|
|
1308
|
+
return {"file_path": path, "content": "\n".join(bodies)}
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
# --- ordering: a commit while the newest check failed -----------------------
|
|
1312
|
+
# The one rule here that asserts a relation between two actions rather than
|
|
1313
|
+
# reading one call plus a ledger tail - the shape FAVA found in 90% of real
|
|
1314
|
+
# agent-instruction projects ("do not commit before running the tests") and the
|
|
1315
|
+
# one this gate did not carry (C9). It is the contract's own claim - nothing is
|
|
1316
|
+
# reported done unless the output was seen - given a mechanical half at the one
|
|
1317
|
+
# moment a claim is written into the repository's history.
|
|
1318
|
+
#
|
|
1319
|
+
# The reader is the Stop rule's own fold over the tail (`_last_verify` on the
|
|
1320
|
+
# last ORDER_TAIL rows), and the rule is structurally incapable of firing in
|
|
1321
|
+
# either direction that would punish honest work: no check at all folds to None
|
|
1322
|
+
# and a passing newest check folds to "ok", so a commit in a session that ran no
|
|
1323
|
+
# check, and a commit after a green run, both pass. Only "fail" refuses - the
|
|
1324
|
+
# newest check in this session rejected the tree the commit would freeze - and
|
|
1325
|
+
# the refusal names that check. What lifts it is making the newest check pass
|
|
1326
|
+
# (fix what it reported and run it again), so the reason names no command, the
|
|
1327
|
+
# way the task refusals do not: a refusal is a boundary or it is an instruction.
|
|
1328
|
+
# ponytail: the state is session-wide, not turn-scoped, so a probe the user asked
|
|
1329
|
+
# to fail and a check that failed before this turn's prompt both count; the
|
|
1330
|
+
# repair is the same either way, and a turn-scoped reader would let a commit
|
|
1331
|
+
# through on a tree the failed check still describes.
|
|
1332
|
+
ORDER_TAIL = 200
|
|
1333
|
+
COMMIT_CMD = re.compile(
|
|
1334
|
+
r"(?:^|[|;&]\s*|\s)git\s+(?:-{1,2}\S+(?:\s+\S+)?\s+)*commit(?=\s|$|[|;&])")
|
|
1335
|
+
ORDER_DENY = (
|
|
1336
|
+
"Commit order denied: the newest check in this session failed, and a commit "
|
|
1337
|
+
"is a claim that the tree passed. What would be frozen is the state that "
|
|
1338
|
+
"check just rejected - the newest failing one was %s - so fix what it "
|
|
1339
|
+
"reported and run it again, commit once the newest check is green, or say "
|
|
1340
|
+
"plainly that it is failing and why the commit is wanted anyway.")
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
def commit_order_reason(command, session_id):
|
|
1344
|
+
"""A deny reason when this session's newest check failed and this command is
|
|
1345
|
+
a commit, else None.
|
|
1346
|
+
|
|
1347
|
+
Not a rule about commits: with no check in the tail the fold is None and
|
|
1348
|
+
nothing is refused, which is what keeps an ordinary commit in a session that
|
|
1349
|
+
ran nothing - a docs edit, a message-only change - out of it."""
|
|
1350
|
+
if not (session_id and _last_verify):
|
|
1351
|
+
return None
|
|
1352
|
+
if not COMMIT_CMD.search(mask(str(command or ""))):
|
|
1353
|
+
return None
|
|
1354
|
+
rows = events(session_id, tail=ORDER_TAIL)
|
|
1355
|
+
if _last_verify(rows) != "fail":
|
|
1356
|
+
return None
|
|
1357
|
+
return ORDER_DENY % _failed_check(rows)
|
|
1358
|
+
|
|
1359
|
+
|
|
1360
|
+
# --- constraint drift: a long turn loses the rules it started with ----------
|
|
1361
|
+
# The re-statement that keeps the rules alive rides the user prompt
|
|
1362
|
+
# (tezgah_context.context_for writes PROMPT_REMINDER on every turn), so the
|
|
1363
|
+
# stretch nothing covers is one turn that runs long: by step 25 the prompt that
|
|
1364
|
+
# armed the rules is dozens of tool results back and has stopped steering. The
|
|
1365
|
+
# threshold counts work rows in the CURRENT user turn - the same rows counters()
|
|
1366
|
+
# counts, a run/an edit/a check - which a normal turn reaches a handful of, and
|
|
1367
|
+
# the mark is per turn: one re-statement in a turn that drifted is useful, the
|
|
1368
|
+
# same one in every short turn is noise the agent learns to skip.
|
|
1369
|
+
#
|
|
1370
|
+
# It is delivered as a refusal - the gate's reason string - and nowhere else: no
|
|
1371
|
+
# host's result side carries it. An internal plan measured the result-channel delivery
|
|
1372
|
+
# and its pre-registered counter-metric sent it back here.
|
|
1373
|
+
DRIFT_STEPS = 25
|
|
1374
|
+
# How far back the count reads: the same 200-row bound the repeat guards read, so
|
|
1375
|
+
# one gated call parses at most that many rows. Past that bound the window holds
|
|
1376
|
+
# neither the turn's own marker nor the one the notice wrote, so the read falls
|
|
1377
|
+
# back to the turn's own start (the module's own turn-scoped reader).
|
|
1378
|
+
DRIFT_TAIL = 200
|
|
1379
|
+
DRIFT_DENY = (
|
|
1380
|
+
"Long turn (%d work rows in, past the %d this notice waits for): the "
|
|
1381
|
+
"constraints this session was armed with are still in force. %s This is a "
|
|
1382
|
+
"re-statement, not a violation report: re-issue this call unchanged and "
|
|
1383
|
+
"carry on.")
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
def drift_text(cwd, session_id):
|
|
1387
|
+
"""The body of the drift notice: the delta since this turn began where the
|
|
1388
|
+
context module can produce one, else the standing constraints themselves.
|
|
1389
|
+
|
|
1390
|
+
A long turn's actual failure is often that a value moved under it - HEAD, a
|
|
1391
|
+
lessons count, the plan set - and a re-statement cannot say that: it repeats
|
|
1392
|
+
what the session was armed with rather than what changed since. So the newer
|
|
1393
|
+
half wins where it is available: `tezgah_context.constraint_notice` folds the
|
|
1394
|
+
per-turn stamp the prompt path writes into one line ("state since your last
|
|
1395
|
+
turn: ..."), and it hands back this module's own `constraints_line` verbatim
|
|
1396
|
+
when it has no stamp for this session, so a session it cannot speak about
|
|
1397
|
+
reads exactly as before. Imported inside the call (only the call that crosses
|
|
1398
|
+
DRIFT_STEPS pays for it) and guarded twice over: a checkout without the
|
|
1399
|
+
helper, or a stamp it cannot read, falls back to the re-statement, because a
|
|
1400
|
+
crash here would cost the host's whole envelope (tezgah_guard.safe wraps the
|
|
1401
|
+
adapter, and this is the one place a cheap fallback buys more than it costs)."""
|
|
1402
|
+
try:
|
|
1403
|
+
from tezgah_context import constraint_notice
|
|
1404
|
+
return constraint_notice(cwd, session_id)
|
|
1405
|
+
except Exception:
|
|
1406
|
+
return constraints_line(cwd)
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
def constraints_line(cwd):
|
|
1410
|
+
"""The standing constraints as one line, from tezgah_policy's own text.
|
|
1411
|
+
|
|
1412
|
+
`subagent_core` is the tested short form of every always-on rule (its bold
|
|
1413
|
+
label plus its opening clause, under the same kill-switch filtering), and its
|
|
1414
|
+
header already carries the on-demand rules' pointer, so the notice can
|
|
1415
|
+
neither name a rule that is off nor miss one that is on: it is a
|
|
1416
|
+
re-statement, never a second copy of the contract. POINTERS is not appended
|
|
1417
|
+
again - it printed the On-demand paragraph twice (audit CHAT-06). Imported
|
|
1418
|
+
inside the call because only the call that crosses DRIFT_STEPS pays for it."""
|
|
1419
|
+
from tezgah_context import core_for, render, subagent_core
|
|
1420
|
+
return " ".join(render(subagent_core(core_for(cwd)[0])).split())
|
|
1421
|
+
|
|
1422
|
+
|
|
1423
|
+
def drift_reason(tool, inp, cwd, session_id):
|
|
1424
|
+
"""A one-shot re-statement of the standing constraints when this user turn
|
|
1425
|
+
has run past DRIFT_STEPS work rows and this call is an effect, else None.
|
|
1426
|
+
|
|
1427
|
+
What this deliberately is NOT: a detector of the rule the user meant. A
|
|
1428
|
+
PreToolUse payload carries the tool call, not the prompt - no host hook sees
|
|
1429
|
+
the text the user typed - so "which rule is being forgotten" would be a guess
|
|
1430
|
+
about intent wearing a check's clothes. Re-stating the standing constraints,
|
|
1431
|
+
or the delta since the turn began where the context module has one, is the
|
|
1432
|
+
honest half, and it is the whole of what this does (see drift_text). The host
|
|
1433
|
+
gives a PreToolUse hook no non-blocking way to reach the model either (the
|
|
1434
|
+
reason string is the only channel), so the notice arrives as a refusal whose
|
|
1435
|
+
reason is the re-statement, and the mark is written here - before the deny -
|
|
1436
|
+
so the identical call passes on the next attempt."""
|
|
1437
|
+
if off("reminder-off") or not effectful(tool, inp or {}):
|
|
1438
|
+
return None
|
|
1439
|
+
if not session_id:
|
|
1440
|
+
return None
|
|
1441
|
+
rows = events(session_id, tail=DRIFT_TAIL)
|
|
1442
|
+
start = _turn_start(rows)
|
|
1443
|
+
rows = rows[start:] if start else turn_rows(session_id)
|
|
1444
|
+
if any(row.get("kind") == "drift" for row in rows):
|
|
1445
|
+
return None
|
|
1446
|
+
steps = sum(1 for row in rows if row.get("kind") in STEP_KINDS)
|
|
1447
|
+
if steps < DRIFT_STEPS:
|
|
1448
|
+
return None
|
|
1449
|
+
note(session_id, "drift", str(steps), workspace=root_for(cwd))
|
|
1450
|
+
return DRIFT_DENY % (steps, DRIFT_STEPS, drift_text(cwd, session_id))
|
|
1451
|
+
|
|
1452
|
+
|
|
1453
|
+
def effectful(tool, inp):
|
|
1454
|
+
"""True when this call is one the constraints are about: a write tool, a
|
|
1455
|
+
git/gh command that lands an artifact, or an MCP effect
|
|
1456
|
+
(`tezgah_integrity.mcp_class`, which reads the name as the host matcher does,
|
|
1457
|
+
case-sensitive). A read changes nothing, so the notice spent on it would be
|
|
1458
|
+
spent where no rule applies."""
|
|
1459
|
+
t = str(tool or "").lower()
|
|
1460
|
+
if t in WRITE_TOOLS:
|
|
1461
|
+
return True
|
|
1462
|
+
if t in BASH_TOOLS:
|
|
1463
|
+
return bool(WRITE_CMD.search(mask(str(inp.get("command") or ""))))
|
|
1464
|
+
return mcp_class(tool) is not None
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
# --- plan required: work that spans files on main has no plan ----------------
|
|
1468
|
+
# What this closes: a turn that wrote four product files - a hook, its tests, its
|
|
1469
|
+
# docs and the changelog - straight onto `main`, with the plan opened afterwards.
|
|
1470
|
+
# The task rule constrains a write only through a record the user made, so with
|
|
1471
|
+
# no active task every product write passes and "work for a plan happens on its
|
|
1472
|
+
# branch" is advice rather than a mechanism. Here the checkout is the record: on
|
|
1473
|
+
# `main`/`master` the third distinct product file a single turn writes is
|
|
1474
|
+
# refused, while a one- or two-file fix stays free - a plan is what makes work
|
|
1475
|
+
# that spans files reviewable and closeable.
|
|
1476
|
+
#
|
|
1477
|
+
# The count is a fold over the turn's own write rows (`turn_rows`), so it needs
|
|
1478
|
+
# no state of its own, and the call in hand contributes the files it names before
|
|
1479
|
+
# the comparison: an `apply_patch` naming three product files is the third file by
|
|
1480
|
+
# itself.
|
|
1481
|
+
#
|
|
1482
|
+
# Fail-open wherever the question cannot be answered: no session, no ledger, a
|
|
1483
|
+
# checkout that is not a git repository, a detached HEAD (no branch to name), and
|
|
1484
|
+
# a repo carrying the `.no-plan-gate` mark all pass - the direction every rule
|
|
1485
|
+
# here takes, because a refusal has to rest on something a reader can check.
|
|
1486
|
+
#
|
|
1487
|
+
# ponytail: a PRIOR write recorded through the shell is a `run` row whose detail
|
|
1488
|
+
# is the command, so it is not counted; only the call in hand contributes a shell
|
|
1489
|
+
# target (`write_paths` reads the redirect). The turn this rule answers is the
|
|
1490
|
+
# write-tool turn, and reading a shell command's target off a stored command line
|
|
1491
|
+
# would be a second reader of the same table, not a new fact.
|
|
1492
|
+
PLAN_BRANCHES = ("main", "master")
|
|
1493
|
+
# The product paths the count reads, relative to the checkout holding the write:
|
|
1494
|
+
# the code and docs a plan exists to scope. `.tezgah/` - and every other
|
|
1495
|
+
# directory - is not one of them, and neither is a path outside any git checkout
|
|
1496
|
+
# or a checkout outside every configured root (a scratch fixture repo).
|
|
1497
|
+
PLAN_DIRS = ("hooks", "tests", "bin", "skills", "docs")
|
|
1498
|
+
PLAN_FILES = ("statusline.py", "MANIFEST")
|
|
1499
|
+
# The third file: two writes in a turn are a fix, three are work that spans files.
|
|
1500
|
+
PLAN_WRITES = 3
|
|
1501
|
+
PLAN_DENY = (
|
|
1502
|
+
"This turn has written %d distinct product files while the checkout is on "
|
|
1503
|
+
"`%s`. Work that spans files on `main`/`master` has no plan to review or "
|
|
1504
|
+
"close, so the third product file in one turn is refused: open a plan with "
|
|
1505
|
+
"the `plan-add` skill (`/tezgah:plan-add <description>`) and do the work on "
|
|
1506
|
+
"the `plan/NNN-slug` branch it creates. A one- or two-file fix stays free. "
|
|
1507
|
+
"Only `hooks/`, `tests/`, `bin/`, `skills/`, `docs/`, `statusline.py` and "
|
|
1508
|
+
"`MANIFEST` are counted; a `.tezgah/` path never is.")
|
|
1509
|
+
|
|
1510
|
+
|
|
1511
|
+
def _gitdir(root):
|
|
1512
|
+
"""The `.git` directory the checkout at `root` keeps its refs in, or "" when
|
|
1513
|
+
`root` does not name one: no `.git` entry, or a `.git` file that is not the
|
|
1514
|
+
`gitdir:` pointer a worktree writes."""
|
|
1515
|
+
dot = os.path.join(root, ".git")
|
|
1516
|
+
if not os.path.isfile(dot):
|
|
1517
|
+
return dot
|
|
1518
|
+
try:
|
|
1519
|
+
with open(dot, encoding="utf-8", errors="replace") as fh:
|
|
1520
|
+
line = fh.readline().strip()
|
|
1521
|
+
except OSError:
|
|
1522
|
+
return ""
|
|
1523
|
+
if not line.startswith("gitdir:"):
|
|
1524
|
+
return ""
|
|
1525
|
+
target = line[len("gitdir:"):].strip()
|
|
1526
|
+
return target if os.path.isabs(target) else os.path.join(root, target)
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
def _branch(root):
|
|
1530
|
+
"""The branch the checkout at `root` is on, or "" when there is none to name:
|
|
1531
|
+
not a git repository (no `.git`), an unreadable HEAD, or a detached HEAD.
|
|
1532
|
+
|
|
1533
|
+
Read from `.git/HEAD` rather than a `git` fork: the gate runs this on every
|
|
1534
|
+
product write, and one file read is the whole cost. A worktree's `.git` is a
|
|
1535
|
+
file naming the real gitdir, which is followed."""
|
|
1536
|
+
gitdir = _gitdir(root)
|
|
1537
|
+
if not gitdir:
|
|
1538
|
+
return ""
|
|
1539
|
+
try:
|
|
1540
|
+
with open(os.path.join(gitdir, "HEAD"), encoding="utf-8",
|
|
1541
|
+
errors="replace") as fh:
|
|
1542
|
+
ref = fh.readline().strip()
|
|
1543
|
+
except OSError:
|
|
1544
|
+
return ""
|
|
1545
|
+
head = "ref: refs/heads/"
|
|
1546
|
+
return ref[len(head):] if ref.startswith(head) else ""
|
|
1547
|
+
|
|
1548
|
+
|
|
1549
|
+
# A commit id as git writes it: 40 hex for the default object store, 64 for
|
|
1550
|
+
# sha256. Both are accepted so the reader does not fail open on a repo whose
|
|
1551
|
+
# object format the module never had to know.
|
|
1552
|
+
_SHA = re.compile(r"[0-9a-fA-F]{40,64}")
|
|
1553
|
+
|
|
1554
|
+
|
|
1555
|
+
def _packed_sha(gitdir, ref):
|
|
1556
|
+
"""The sha `gitdir/packed-refs` names for `ref`, or "": a ref `git gc` folded
|
|
1557
|
+
out of its loose file still resolves, which is what keeps the reader from
|
|
1558
|
+
failing open on a repacked repository. The header line and a tag's `^`-peeled
|
|
1559
|
+
line carry no `ref` match, so only the ref's own line can answer."""
|
|
1560
|
+
try:
|
|
1561
|
+
with open(os.path.join(gitdir, "packed-refs"), encoding="utf-8",
|
|
1562
|
+
errors="replace") as fh:
|
|
1563
|
+
for line in fh:
|
|
1564
|
+
sha, _, name = line.strip().partition(" ")
|
|
1565
|
+
if name.strip() == ref and _SHA.fullmatch(sha):
|
|
1566
|
+
return sha
|
|
1567
|
+
except OSError:
|
|
1568
|
+
pass
|
|
1569
|
+
return ""
|
|
1570
|
+
|
|
1571
|
+
|
|
1572
|
+
def _head(root):
|
|
1573
|
+
"""The sha `HEAD` names for the checkout at `root`, or "" when the question
|
|
1574
|
+
cannot be answered: not a git repository, an unreadable HEAD, a ref with
|
|
1575
|
+
neither a loose file nor a packed-refs line, or a detached HEAD holding
|
|
1576
|
+
something that is not a sha.
|
|
1577
|
+
|
|
1578
|
+
Read from `.git/HEAD` - the ref file, or the packed-refs line beside it -
|
|
1579
|
+
rather than a `git` fork, the way `_branch` reads the branch: the checkpoint
|
|
1580
|
+
rule asks this on every write of an `implementation` phase, and an
|
|
1581
|
+
unanswerable question must pass. ponytail: a worktree's packed refs live in
|
|
1582
|
+
the common dir, not in the worktree gitdir this reads, so a repacked ref in a
|
|
1583
|
+
linked worktree falls back to "" - the fail-open direction."""
|
|
1584
|
+
gitdir = _gitdir(root)
|
|
1585
|
+
if not gitdir:
|
|
1586
|
+
return ""
|
|
1587
|
+
try:
|
|
1588
|
+
with open(os.path.join(gitdir, "HEAD"), encoding="utf-8",
|
|
1589
|
+
errors="replace") as fh:
|
|
1590
|
+
ref = fh.readline().strip()
|
|
1591
|
+
except OSError:
|
|
1592
|
+
return ""
|
|
1593
|
+
if not ref.startswith("ref: "):
|
|
1594
|
+
return ref if _SHA.fullmatch(ref) else ""
|
|
1595
|
+
name = ref[len("ref: "):].strip()
|
|
1596
|
+
try:
|
|
1597
|
+
with open(os.path.join(gitdir, name), encoding="utf-8",
|
|
1598
|
+
errors="replace") as fh:
|
|
1599
|
+
sha = fh.readline().strip()
|
|
1600
|
+
except OSError:
|
|
1601
|
+
sha = ""
|
|
1602
|
+
return sha if _SHA.fullmatch(sha) else _packed_sha(gitdir, name)
|
|
1603
|
+
|
|
1604
|
+
|
|
1605
|
+
def _checkout(path, cwd):
|
|
1606
|
+
"""`(top, rel)` for a write target: the nearest git checkout holding it and
|
|
1607
|
+
its path inside that checkout, or None when no checkout holds it or the path
|
|
1608
|
+
lies under a `.tezgah/` (which keeps a repository of its own and is never a
|
|
1609
|
+
product path).
|
|
1610
|
+
|
|
1611
|
+
The branch question belongs to this checkout, not to the session's cwd: a
|
|
1612
|
+
session on `main` writing into a linked worktree on a plan branch is plan
|
|
1613
|
+
work, and one on a plan branch writing into a `main` checkout is not. A
|
|
1614
|
+
relative path is resolved against the call's own `cwd`."""
|
|
1615
|
+
text = str(path or "").strip()
|
|
1616
|
+
if not text or not cwd and not os.path.isabs(text):
|
|
1617
|
+
return None
|
|
1618
|
+
full = os.path.realpath(os.path.join(cwd or "", text))
|
|
1619
|
+
if ".tezgah" in full.split(os.sep):
|
|
1620
|
+
return None
|
|
1621
|
+
cur = os.path.dirname(full)
|
|
1622
|
+
while not os.path.lexists(os.path.join(cur, ".git")):
|
|
1623
|
+
parent = os.path.dirname(cur)
|
|
1624
|
+
if parent == cur:
|
|
1625
|
+
return None
|
|
1626
|
+
cur = parent
|
|
1627
|
+
return cur, os.path.relpath(full, cur)
|
|
1628
|
+
|
|
1629
|
+
|
|
1630
|
+
def _product(rel):
|
|
1631
|
+
"""True when a checkout-relative path is one a plan exists to scope."""
|
|
1632
|
+
parts = rel.split(os.sep)
|
|
1633
|
+
return parts[0] in PLAN_DIRS or len(parts) == 1 and parts[0] in PLAN_FILES
|
|
1634
|
+
|
|
1635
|
+
|
|
1636
|
+
def plan_reason(inp, cwd, session_id):
|
|
1637
|
+
"""A deny reason when this write would be a turn's third distinct product
|
|
1638
|
+
file in a checkout on `main`/`master`, else None. The section above carries
|
|
1639
|
+
the trigger, the counted paths and the fail-open direction."""
|
|
1640
|
+
if not session_id or turn_rows is None:
|
|
1641
|
+
return None
|
|
1642
|
+
targets = {}
|
|
1643
|
+
for path in write_paths(inp):
|
|
1644
|
+
hit = _checkout(path, cwd)
|
|
1645
|
+
if hit and _product(hit[1]) and root_for(hit[0]):
|
|
1646
|
+
targets.setdefault(hit[0], set()).add(hit[1])
|
|
1647
|
+
if not targets:
|
|
1648
|
+
return None
|
|
1649
|
+
rows = None
|
|
1650
|
+
for top, written in targets.items():
|
|
1651
|
+
branch = _branch(top)
|
|
1652
|
+
if branch not in PLAN_BRANCHES \
|
|
1653
|
+
or os.path.exists(os.path.join(top, ".no-plan-gate")):
|
|
1654
|
+
continue
|
|
1655
|
+
if rows is None:
|
|
1656
|
+
# a row's `target` is already absolute against the cwd it was
|
|
1657
|
+
# recorded from; an older row without one resolves against this cwd
|
|
1658
|
+
rows = [_checkout(row.get("target") or row.get("detail"), cwd)
|
|
1659
|
+
for row in turn_rows(session_id) if row.get("kind") == "edit"]
|
|
1660
|
+
written |= {rel for hit in rows if hit and hit[0] == top
|
|
1661
|
+
for rel in (hit[1],) if _product(rel)}
|
|
1662
|
+
if len(written) >= PLAN_WRITES:
|
|
1663
|
+
return PLAN_DENY % (len(written), branch)
|
|
1664
|
+
return None
|
|
1665
|
+
|
|
1666
|
+
|
|
1667
|
+
# ---------------------------------------------------------------------------
|
|
1668
|
+
# Control plane. Every other rule here is held by a file the session can write:
|
|
1669
|
+
# a kill switch is a file under OFF_DIRS, the Stop rule reads a ledger in the
|
|
1670
|
+
# cache, the hooks are wired by files in each host's config. The REPORT measured
|
|
1671
|
+
# 12 of 12 tamper shapes passing this gate (`touch ~/.config/tezgah/verify-off`,
|
|
1672
|
+
# a Write to the same file, `rm .git/hooks/pre-commit`, an `echo` into the
|
|
1673
|
+
# ledger, `git add -f .tezgah/x`, ...). The threat model is a cooperative but
|
|
1674
|
+
# fallible agent (SECURITY.md): one that reaches for a switch it was shown,
|
|
1675
|
+
# never one that writes a Python one-liner to get around the gate - that route,
|
|
1676
|
+
# and any other interpreter, is named as a residual rather than closed.
|
|
1677
|
+
#
|
|
1678
|
+
# The deny names no command: with the unlock in the refusal the E7 block
|
|
1679
|
+
# measured 25 of 25 armed runs disabling the gate. What the user runs to change
|
|
1680
|
+
# the state is theirs to know.
|
|
1681
|
+
CONTROL_DENY = (
|
|
1682
|
+
"Control plane: this call changes %s. That state is the user's, not the "
|
|
1683
|
+
"session's: they change it in their own terminal. Say what you wanted "
|
|
1684
|
+
"changed and why, and carry on without it.")
|
|
1685
|
+
# The two targets plan 050's replay of three weeks of real ledgers (2026-10-07,
|
|
1686
|
+
# rows 1, 3, 5 and 8) found refused without the user's say-so more than once a
|
|
1687
|
+
# week: its falsifier (REPORT.md 5430, ADR 018) keeps the tamper evidence and
|
|
1688
|
+
# drops the refusal. A call that changes one of these and nothing else the rule
|
|
1689
|
+
# protects passes and leaves a `disarm` row (`decision`); a refusing target in
|
|
1690
|
+
# the same call still refuses. Only a path strictly under the open plans counts:
|
|
1691
|
+
# their directory or an ancestor of it takes every plan at once (HELD).
|
|
1692
|
+
OPEN_PLAN = "an open plan"
|
|
1693
|
+
FORCED_ADD = "a private `.tezgah/` path forced into the project's history"
|
|
1694
|
+
CONTROL_EVIDENCE = frozenset((OPEN_PLAN, FORCED_ADD))
|
|
1695
|
+
HELD = "a directory that holds tezgah's control state"
|
|
1696
|
+
# A word the shell expands into names (a glob or a brace): directly under the
|
|
1697
|
+
# open plans it names every plan, not one.
|
|
1698
|
+
GLOB_CHARS = re.compile(r"[*?\[{]")
|
|
1699
|
+
|
|
1700
|
+
|
|
1701
|
+
def _refusing(label):
|
|
1702
|
+
"""True for a control label that refuses rather than leaves evidence."""
|
|
1703
|
+
return bool(label) and label not in CONTROL_EVIDENCE
|
|
1704
|
+
|
|
1705
|
+
|
|
1706
|
+
# The CLIs that change control state, by basename with or without `.py`, and the
|
|
1707
|
+
# first argument that makes a call a change: None is any call, "" any argument.
|
|
1708
|
+
# `tezgah-gate decide` writes a genuine `began` row with `check=1`; the capture
|
|
1709
|
+
# CLI writes snapshot rows; pony and adhd write CONFIG_DIR; `tezgah-context
|
|
1710
|
+
# attest` re-attests a session, and a clean result clears its drift mark. The
|
|
1711
|
+
# task CLI is not here: it is the task rule's (TASK_CHANGE), which `task-off`
|
|
1712
|
+
# removes, and that switch is one of the files this rule protects.
|
|
1713
|
+
# `tezgah-context user_prompt` is a prompt event, and a prompt naming a switch
|
|
1714
|
+
# writes the `authorized` row the switch latch honors (tezgah_paths.off).
|
|
1715
|
+
CONTROL_CLIS = {"tezgah-gate": ("decide",), "tezgah-capture": "",
|
|
1716
|
+
"tezgah-pony": "", "tezgah-adhd": ("on", "off"),
|
|
1717
|
+
"tezgah-context": ("attest", "user_prompt")}
|
|
1718
|
+
# The hook entries a host runs, by basename without `.py`: run from a tool call
|
|
1719
|
+
# with a forged payload on stdin, any of them writes genuine rows - a prompt's
|
|
1720
|
+
# `authorized` row, a `began` row, a Stop verdict. A host adapter is `hook.py`
|
|
1721
|
+
# under `hosts/<host>/` (HOOK_HOSTS); its launchers carry their own names.
|
|
1722
|
+
HOOK_ENTRIES = frozenset(("projects-auto-init", "projects-pretooluse",
|
|
1723
|
+
"projects-posttooluse", "projects-stop",
|
|
1724
|
+
"tezgah-codex-hook", "tezgah-cursor-hook"))
|
|
1725
|
+
HOOK_HOSTS = frozenset(("codex", "cursor", "omp"))
|
|
1726
|
+
# Programs that change a path named in their arguments. Every positional is a
|
|
1727
|
+
# target, except for the copiers, whose last one is (`cp switch /tmp` reads it).
|
|
1728
|
+
# `sed`/`perl` count only with an in-place flag.
|
|
1729
|
+
CONTROL_PROGRAMS = frozenset(("touch", "rm", "rmdir", "unlink", "shred",
|
|
1730
|
+
"truncate", "chmod", "chown", "chgrp", "mv",
|
|
1731
|
+
"mkdir", "tee", "cp", "ln", "install", "rsync",
|
|
1732
|
+
"dd", "sed", "perl"))
|
|
1733
|
+
CONTROL_COPIERS = frozenset(("cp", "mv", "ln", "install", "rsync"))
|
|
1734
|
+
# The programs that remove, disable or move a path. A directory that holds
|
|
1735
|
+
# protected state, a repository's husky hooks and the open plans (evidence) are
|
|
1736
|
+
# judged for these only: husky's own setup writes `.husky/pre-commit` with a
|
|
1737
|
+
# redirect.
|
|
1738
|
+
CONTROL_REMOVERS = frozenset(("rm", "rmdir", "unlink", "shred", "truncate",
|
|
1739
|
+
"chmod", "chown", "chgrp", "mv"))
|
|
1740
|
+
# Options whose value is a write target (`cp -t dir`, `curl -o file`).
|
|
1741
|
+
_INTO = ("-t", "--target-directory")
|
|
1742
|
+
OPTION_TARGETS = {"cp": _INTO, "mv": _INTO, "ln": _INTO, "install": _INTO,
|
|
1743
|
+
"curl": ("-o", "--output", "--output-dir"),
|
|
1744
|
+
"wget": ("-O", "--output-document"), "unzip": ("-d",)}
|
|
1745
|
+
INTERPRETERS = frozenset(("python", "python3", "sh", "bash", "zsh", "dash"))
|
|
1746
|
+
SHELL_NAMES = frozenset(("sh", "bash", "zsh", "dash", "ksh"))
|
|
1747
|
+
# Words that open a compound command: the command after one is read as if it
|
|
1748
|
+
# were not there (`if touch x; then`, `! rm x`, `do touch x`). `[`/`]` are not
|
|
1749
|
+
# here: the test command is skipped in `_program`, so the command after it in
|
|
1750
|
+
# an `if` line is read.
|
|
1751
|
+
SHELL_KEYWORDS = frozenset(("if", "then", "elif", "else", "do", "while",
|
|
1752
|
+
"until", "!", "{", "time"))
|
|
1753
|
+
SHELL_OPS = re.compile(r"^[;&|()<>]+$")
|
|
1754
|
+
# The cache subtrees whose rows the rules read: the evidence ledger, the session
|
|
1755
|
+
# store, the switch baseline (tezgah_context.disarmed), the gate mark, and the
|
|
1756
|
+
# marks the status line reads for a drifted hook entry
|
|
1757
|
+
# (tezgah_attest.drift_mark) and a core that failed to import
|
|
1758
|
+
# (tezgah_guard.import_crash_mark).
|
|
1759
|
+
CONTROL_CACHE = ("evidence", "sessions", "switches", "gate-inactive",
|
|
1760
|
+
"harness-drift", "import-crash", "workspace-index.json")
|
|
1761
|
+
# Top-level keys tezgah writes into Claude's settings (bin/tezgah-setup
|
|
1762
|
+
# wire_claude_statusline, wire_claude_attribution), and the one that turns every
|
|
1763
|
+
# hook off at once; any other key or entry is tezgah's only when it names tezgah.
|
|
1764
|
+
CLAUDE_OWNED = ("statusLine", "attribution", "disableAllHooks")
|
|
1765
|
+
PATCH_DELETE = re.compile(r"(?m)^\*\*\* Delete File: (\S.*?)\s*$")
|
|
1766
|
+
|
|
1767
|
+
|
|
1768
|
+
def _real(path, cwd):
|
|
1769
|
+
"""`path` as an absolute path: `~`, `$HOME` and the XDG variables expanded
|
|
1770
|
+
the way the shell would, relative to `cwd`, its directory resolved and its
|
|
1771
|
+
last component kept. A hook file the installer symlinked into a host's
|
|
1772
|
+
config is still that host's file, and the checkout it points at is not."""
|
|
1773
|
+
path = os.path.expandvars(os.path.expanduser(str(path)))
|
|
1774
|
+
parent, name = os.path.split(os.path.normpath(os.path.join(cwd or os.sep, path)))
|
|
1775
|
+
return os.path.join(os.path.realpath(parent), name)
|
|
1776
|
+
|
|
1777
|
+
|
|
1778
|
+
def _full(path, cwd):
|
|
1779
|
+
"""`path` with every link resolved, the last component's too: a write
|
|
1780
|
+
through a link lands on what it points at."""
|
|
1781
|
+
path = os.path.expandvars(os.path.expanduser(str(path)))
|
|
1782
|
+
return os.path.realpath(os.path.join(cwd or os.sep, path))
|
|
1783
|
+
|
|
1784
|
+
|
|
1785
|
+
def _under(path, root):
|
|
1786
|
+
return path == root or path.startswith(root.rstrip(os.sep) + os.sep)
|
|
1787
|
+
|
|
1788
|
+
|
|
1789
|
+
def _install_trees(cwd):
|
|
1790
|
+
"""The trees tezgah's hooks run from: the install prefix, the Claude plugin
|
|
1791
|
+
copies (`_registration`), and the tree this module was loaded from - unless
|
|
1792
|
+
the session works inside that tree, which is then a checkout of tezgah
|
|
1793
|
+
being developed, not an install."""
|
|
1794
|
+
prefix = os.environ.get("TEZGAH_PREFIX") or os.path.join(
|
|
1795
|
+
os.environ.get("XDG_DATA_HOME") or os.path.join(
|
|
1796
|
+
os.path.expanduser("~"), ".local", "share"), "tezgah")
|
|
1797
|
+
trees = [os.path.realpath(prefix)]
|
|
1798
|
+
plugin = os.path.realpath(PLUGIN_ROOT)
|
|
1799
|
+
if not _under(os.path.realpath(cwd), plugin):
|
|
1800
|
+
trees.append(plugin)
|
|
1801
|
+
return trees
|
|
1802
|
+
|
|
1803
|
+
|
|
1804
|
+
def _tops(cwd):
|
|
1805
|
+
"""The checkouts the session works in: the nearest `.git`-holding ancestor
|
|
1806
|
+
of `cwd`, and the main checkout when that is a linked worktree (its hooks
|
|
1807
|
+
live there). A clone elsewhere is not one of them."""
|
|
1808
|
+
out, here = [], os.path.realpath(cwd or os.sep)
|
|
1809
|
+
while True:
|
|
1810
|
+
if os.path.exists(os.path.join(here, ".git")):
|
|
1811
|
+
out.append(here)
|
|
1812
|
+
break
|
|
1813
|
+
if os.path.dirname(here) == here:
|
|
1814
|
+
break
|
|
1815
|
+
here = os.path.dirname(here)
|
|
1816
|
+
main = linked_main(cwd) if cwd else None
|
|
1817
|
+
return out + ([os.path.realpath(main)] if main else [])
|
|
1818
|
+
|
|
1819
|
+
|
|
1820
|
+
def _held(origin):
|
|
1821
|
+
"""The protected paths a remove or move of a directory holding one takes
|
|
1822
|
+
with it: tezgah's configuration, the switches that exist, the cache state,
|
|
1823
|
+
the install trees, and the session checkout's git hooks and open plans. A
|
|
1824
|
+
single plan under the open plans is evidence (OPEN_PLAN); the directory
|
|
1825
|
+
and its ancestors take every plan at once and stay refused."""
|
|
1826
|
+
out = [os.path.realpath(CONFIG_DIR)]
|
|
1827
|
+
out += [p for d in OFF_DIRS for p in (os.path.join(os.path.realpath(d), n)
|
|
1828
|
+
for n in SWITCHES) if os.path.exists(p)]
|
|
1829
|
+
out += [os.path.join(c, d) for c in {os.path.realpath(CACHE),
|
|
1830
|
+
os.path.realpath(fallback_cache())}
|
|
1831
|
+
for d in CONTROL_CACHE]
|
|
1832
|
+
out += _install_trees(origin)
|
|
1833
|
+
for top in _tops(origin):
|
|
1834
|
+
out += [os.path.join(top, ".git", "hooks"),
|
|
1835
|
+
os.path.join(top, ".tezgah", "plans", "open")]
|
|
1836
|
+
return out
|
|
1837
|
+
|
|
1838
|
+
|
|
1839
|
+
def _registration(real):
|
|
1840
|
+
"""'shared' for a file tezgah writes entries into beside the user's,
|
|
1841
|
+
'owned' for a hook file that is wholly tezgah's, else None. A Claude
|
|
1842
|
+
`settings*.json` counts in the user's own `~/.claude` or in a project under
|
|
1843
|
+
a root, not in a fixture under /tmp or another temp HOME."""
|
|
1844
|
+
claude = HOST_DIRS["claude"]
|
|
1845
|
+
name = os.path.basename(real)
|
|
1846
|
+
shared = (os.path.join(HOST_DIRS["codex"], "hooks.json"),
|
|
1847
|
+
os.path.join(HOST_DIRS["codex"], "config.toml"),
|
|
1848
|
+
os.path.join(HOST_DIRS["cursor"], "hooks.json"),
|
|
1849
|
+
os.path.join(claude, "plugins", "installed_plugins.json"),
|
|
1850
|
+
os.path.join(claude, "plugins", "known_marketplaces.json"))
|
|
1851
|
+
if (name in ("settings.json", "settings.local.json")
|
|
1852
|
+
and ".claude" in real.split(os.sep)
|
|
1853
|
+
and (os.path.dirname(real) == os.path.realpath(claude)
|
|
1854
|
+
or root_for(real))) \
|
|
1855
|
+
or real in {_real(p, None) for p in shared}:
|
|
1856
|
+
return "shared"
|
|
1857
|
+
owned = [os.path.join(HOST_DIRS["opencode"], d, "tezgah.js")
|
|
1858
|
+
for d in ("plugins", "plugin")]
|
|
1859
|
+
owned.append(os.path.join(HOST_DIRS["omp"], "agent", "hooks", "pre",
|
|
1860
|
+
"tezgah-hook.ts"))
|
|
1861
|
+
cache = os.path.realpath(os.path.join(claude, "plugins", "cache"))
|
|
1862
|
+
if real in {_real(p, None) for p in owned} or (
|
|
1863
|
+
_under(real, cache) and "tezgah" in real[len(cache):].lower()):
|
|
1864
|
+
return "owned"
|
|
1865
|
+
return None
|
|
1866
|
+
|
|
1867
|
+
|
|
1868
|
+
def _target_label(real, remove, origin):
|
|
1869
|
+
"""`control_target` for one spelling of the path."""
|
|
1870
|
+
name, parent = os.path.basename(real), os.path.dirname(real)
|
|
1871
|
+
if name in SWITCHES and parent in {os.path.realpath(d) for d in OFF_DIRS}:
|
|
1872
|
+
return "a kill switch (`%s`)" % name
|
|
1873
|
+
if _under(real, os.path.realpath(CONFIG_DIR)):
|
|
1874
|
+
return "tezgah's own configuration"
|
|
1875
|
+
for cache in {os.path.realpath(CACHE), os.path.realpath(fallback_cache())}:
|
|
1876
|
+
if any(_under(real, os.path.join(cache, d)) for d in CONTROL_CACHE):
|
|
1877
|
+
return "tezgah's evidence ledger or session state"
|
|
1878
|
+
if name in REPO_MARKS and root_for(parent):
|
|
1879
|
+
return "a repository opt-out mark (`%s`)" % name
|
|
1880
|
+
kind = _registration(real)
|
|
1881
|
+
if kind == "shared":
|
|
1882
|
+
return real
|
|
1883
|
+
# an install tree's own `.tezgah/` is the private workspace of a checkout
|
|
1884
|
+
# used as the install, not code the hooks run
|
|
1885
|
+
if kind or any(_under(real, tree) and not _under(real, os.path.join(tree, ".tezgah"))
|
|
1886
|
+
for tree in _install_trees(origin)):
|
|
1887
|
+
return "tezgah's hook wiring"
|
|
1888
|
+
if not name.endswith(".sample") and any(
|
|
1889
|
+
_under(real, os.path.join(top, ".git", "hooks")) for top in _tops(origin)):
|
|
1890
|
+
return "the repository's git hooks"
|
|
1891
|
+
if remove and ".husky" in real.split(os.sep):
|
|
1892
|
+
return "the repository's git hooks"
|
|
1893
|
+
opened = "%s.tezgah%splans%sopen%s" % ((os.sep,) * 4)
|
|
1894
|
+
if remove and opened in real + os.sep:
|
|
1895
|
+
# strictly under it is one named plan; the directory itself, or a glob
|
|
1896
|
+
# or brace that names its entries (`open/*`, `open/{a,b}`), is all
|
|
1897
|
+
rest = real.split(opened, 1)[1] if opened in real else ""
|
|
1898
|
+
plan = rest.split(os.sep, 1)[0]
|
|
1899
|
+
return OPEN_PLAN if plan and not GLOB_CHARS.search(plan) else HELD
|
|
1900
|
+
return None
|
|
1901
|
+
|
|
1902
|
+
|
|
1903
|
+
def control_target(path, cwd, remove=False, origin=None):
|
|
1904
|
+
"""What `path` is in tezgah's control plane, or None.
|
|
1905
|
+
|
|
1906
|
+
`remove` is a delete, chmod or move: a directory that holds protected state
|
|
1907
|
+
(`_held`), a repository's husky hooks and the open plans are protected only
|
|
1908
|
+
from those. `origin` is the session's own directory (the checkout whose git
|
|
1909
|
+
hooks count), `cwd` the one a `cd` in the line moved to. The path is judged
|
|
1910
|
+
as spelled and with every link resolved. A shared registration file comes
|
|
1911
|
+
back as its own real path, for the caller to judge by key: an edit of a key
|
|
1912
|
+
that is not tezgah's is the user's business. An evidence label
|
|
1913
|
+
(CONTROL_EVIDENCE) comes back only when no spelling refuses."""
|
|
1914
|
+
origin = origin or cwd
|
|
1915
|
+
real = _real(path, cwd)
|
|
1916
|
+
noted = None
|
|
1917
|
+
for spelling in dict.fromkeys((real, _full(path, cwd))):
|
|
1918
|
+
label = _target_label(spelling, remove, origin)
|
|
1919
|
+
if _refusing(label):
|
|
1920
|
+
return label
|
|
1921
|
+
noted = noted or label
|
|
1922
|
+
# a delete through a link (`rm -rf link/`) takes what it points at: judge
|
|
1923
|
+
# the resolved target too
|
|
1924
|
+
if remove and any(_under(held, spelling) for held in _held(origin)
|
|
1925
|
+
for spelling in (real, _full(path, cwd))):
|
|
1926
|
+
return HELD
|
|
1927
|
+
return noted
|
|
1928
|
+
|
|
1929
|
+
|
|
1930
|
+
def _names_tezgah(value):
|
|
1931
|
+
return "tezgah" in json.dumps(value, sort_keys=True).lower()
|
|
1932
|
+
|
|
1933
|
+
|
|
1934
|
+
def _project(node):
|
|
1935
|
+
"""`node` with every list cut to its items that name tezgah: a matcher group
|
|
1936
|
+
that holds tezgah's hook and the user's keeps the group's own fields and
|
|
1937
|
+
tezgah's entry, so the user's entry beside it is theirs to edit."""
|
|
1938
|
+
if isinstance(node, dict):
|
|
1939
|
+
return {k: _project(v) for k, v in node.items()}
|
|
1940
|
+
if isinstance(node, list):
|
|
1941
|
+
return [_project(v) for v in node if _names_tezgah(v)]
|
|
1942
|
+
return node
|
|
1943
|
+
|
|
1944
|
+
|
|
1945
|
+
def _owned(node, path=()):
|
|
1946
|
+
"""The tezgah-owned entries of a parsed JSON document, position-free: the
|
|
1947
|
+
CLAUDE_OWNED top-level keys, any key naming tezgah, and each list item that
|
|
1948
|
+
names tezgah, projected (`_project`)."""
|
|
1949
|
+
def dump(value):
|
|
1950
|
+
return json.dumps(value, sort_keys=True)
|
|
1951
|
+
if isinstance(node, dict):
|
|
1952
|
+
out = []
|
|
1953
|
+
for key, value in node.items():
|
|
1954
|
+
if (not path and key in CLAUDE_OWNED) or "tezgah" in key.lower():
|
|
1955
|
+
out.append((path + (key,), dump(value)))
|
|
1956
|
+
else:
|
|
1957
|
+
out += _owned(value, path + (key,))
|
|
1958
|
+
return out
|
|
1959
|
+
if isinstance(node, list):
|
|
1960
|
+
return [(path, dump(_project(v))) for v in node if _names_tezgah(v)]
|
|
1961
|
+
return [(path, dump(node))] if "tezgah" in dump(node).lower() else []
|
|
1962
|
+
|
|
1963
|
+
|
|
1964
|
+
def _toml_owned(text):
|
|
1965
|
+
"""The lines of a Codex config.toml that arm hooks: every `[hooks...]`
|
|
1966
|
+
section (the trust entries live under `[hooks.state]`), the `codex_hooks`
|
|
1967
|
+
feature and any line naming tezgah. ponytail: which trust entry is
|
|
1968
|
+
tezgah's is a hash codex computes, so all of them count."""
|
|
1969
|
+
out, keep = [], False
|
|
1970
|
+
for line in (text or "").splitlines():
|
|
1971
|
+
stripped = line.strip()
|
|
1972
|
+
if stripped.startswith("["):
|
|
1973
|
+
keep = stripped.lstrip("[").startswith("hooks")
|
|
1974
|
+
if keep or "tezgah" in line.lower() or re.match(r"\s*codex_hooks\s*=", line):
|
|
1975
|
+
out.append(stripped)
|
|
1976
|
+
return sorted(out)
|
|
1977
|
+
|
|
1978
|
+
|
|
1979
|
+
def _parsed(text):
|
|
1980
|
+
try:
|
|
1981
|
+
return json.loads(text)
|
|
1982
|
+
except (TypeError, ValueError):
|
|
1983
|
+
return None
|
|
1984
|
+
|
|
1985
|
+
|
|
1986
|
+
def _after_text(inp, current):
|
|
1987
|
+
"""The file's text once this write tool call lands, or None for a shape
|
|
1988
|
+
this does not model (a patch): the caller then treats it as a change."""
|
|
1989
|
+
for key in ("content", "file_text", "contents", "text"):
|
|
1990
|
+
if isinstance(inp.get(key), str):
|
|
1991
|
+
return inp[key]
|
|
1992
|
+
edits = inp.get("edits") if isinstance(inp.get("edits"), list) else [inp]
|
|
1993
|
+
text = current
|
|
1994
|
+
for edit in edits:
|
|
1995
|
+
if not isinstance(edit, dict):
|
|
1996
|
+
return None
|
|
1997
|
+
old = next((edit[k] for k in ("old_string", "oldString", "old_str")
|
|
1998
|
+
if isinstance(edit.get(k), str)), None)
|
|
1999
|
+
new = next((edit[k] for k in ("new_string", "newString", "new_str")
|
|
2000
|
+
if isinstance(edit.get(k), str)), None)
|
|
2001
|
+
if old is None or new is None:
|
|
2002
|
+
return None
|
|
2003
|
+
text = text.replace(old, new, -1 if edit.get("replace_all") else 1)
|
|
2004
|
+
return text
|
|
2005
|
+
|
|
2006
|
+
|
|
2007
|
+
def _shared_change(real, inp):
|
|
2008
|
+
"""True when this write changes a tezgah-owned entry of a shared file."""
|
|
2009
|
+
try:
|
|
2010
|
+
with open(real, encoding="utf-8") as fh:
|
|
2011
|
+
current = fh.read()
|
|
2012
|
+
except OSError:
|
|
2013
|
+
current = ""
|
|
2014
|
+
after = _after_text(inp, current)
|
|
2015
|
+
if after is None:
|
|
2016
|
+
return True
|
|
2017
|
+
if real.endswith(".toml"):
|
|
2018
|
+
return _toml_owned(current) != _toml_owned(after)
|
|
2019
|
+
return sorted(_owned(_parsed(current))) != sorted(_owned(_parsed(after)))
|
|
2020
|
+
|
|
2021
|
+
|
|
2022
|
+
def _shell_label(label):
|
|
2023
|
+
"""A label for the deny text; a shared file is named with why a shell write
|
|
2024
|
+
to it is refused whole."""
|
|
2025
|
+
if not os.path.isabs(label):
|
|
2026
|
+
return label
|
|
2027
|
+
return ("%s, which holds tezgah's hook wiring. A shell write is not read "
|
|
2028
|
+
"key by key, so it is refused whole; an edit that leaves tezgah's "
|
|
2029
|
+
"entries as they are is not" % label)
|
|
2030
|
+
|
|
2031
|
+
|
|
2032
|
+
def _unquote(word):
|
|
2033
|
+
try:
|
|
2034
|
+
return "".join(shlex.split(word))
|
|
2035
|
+
except ValueError:
|
|
2036
|
+
return word.strip("'\"")
|
|
2037
|
+
|
|
2038
|
+
|
|
2039
|
+
# An escaped `"`, `'` or `\` outside single quotes, as one private-use character
|
|
2040
|
+
# each while shlex reads the line: shlex's non-posix mode knows no escape, so
|
|
2041
|
+
# `"a \"b\" c"` ended its word at the `\"`.
|
|
2042
|
+
_ESCAPES = {'\\"': "\ue000", "\\'": "\ue001", "\\\\": "\ue002"}
|
|
2043
|
+
_UNESCAPE = str.maketrans({v: k for k, v in _ESCAPES.items()})
|
|
2044
|
+
|
|
2045
|
+
|
|
2046
|
+
def _hide_escapes(line):
|
|
2047
|
+
out, quote, i = [], None, 0
|
|
2048
|
+
while i < len(line):
|
|
2049
|
+
pair = line[i:i + 2]
|
|
2050
|
+
if quote != "'" and pair in _ESCAPES:
|
|
2051
|
+
out.append(_ESCAPES[pair])
|
|
2052
|
+
i += 2
|
|
2053
|
+
continue
|
|
2054
|
+
if quote and line[i] == quote:
|
|
2055
|
+
quote = None
|
|
2056
|
+
elif not quote and line[i] in "'\"":
|
|
2057
|
+
quote = line[i]
|
|
2058
|
+
out.append(line[i])
|
|
2059
|
+
i += 1
|
|
2060
|
+
return "".join(out)
|
|
2061
|
+
|
|
2062
|
+
|
|
2063
|
+
def _blank_script_heredocs(text):
|
|
2064
|
+
"""`text` with the body of each heredoc fed to a non-shell interpreter
|
|
2065
|
+
(`python3 - <<PY`) blanked even when its tag is unquoted, length and
|
|
2066
|
+
newlines kept: that body is the interpreter's script, data to this rule
|
|
2067
|
+
(SECURITY.md's interpreter residual). Only when bash would expand nothing
|
|
2068
|
+
in it - no `$` and no backtick in the body - and nothing on the operator's
|
|
2069
|
+
line before it opens a context (`HEREDOC_RISK`) or follows it on its line
|
|
2070
|
+
(`PLAIN_TAIL`, and no quote there); a body handed to a shell stays
|
|
2071
|
+
commands. The consumer is the last simple command before the operator,
|
|
2072
|
+
read by a plain split, so a quote or a backslash before the operator on
|
|
2073
|
+
its line, or a continued line above it, keeps the body visible: `bash -s
|
|
2074
|
+
\\| python3 <<EOF` and `bash -s "x | python3 " <<EOF` hand it to bash. Its
|
|
2075
|
+
word must be the bare interpreter name - no path, no env assignment - and
|
|
2076
|
+
nothing earlier in the call may define a function or alias of that name
|
|
2077
|
+
(`python3(){ bash; }`, `alias python3=bash`)."""
|
|
2078
|
+
out = list(text)
|
|
2079
|
+
for start, stop, _tag, _quoted, body, term, safe in _heredocs(text):
|
|
2080
|
+
line = text.rfind("\n", 0, start) + 1
|
|
2081
|
+
head = re.split(r"[;&|(]", text[line:start])[-1].split()
|
|
2082
|
+
name = re.escape(head[0]) if head else ""
|
|
2083
|
+
tail = PLAIN_TAIL.match(text, stop).end()
|
|
2084
|
+
end = text.find("\n", stop)
|
|
2085
|
+
if (safe or not term or not head
|
|
2086
|
+
or head[0] not in INTERPRETERS - SHELL_NAMES
|
|
2087
|
+
or re.search(r"[$`]", text[body:term[0]])
|
|
2088
|
+
or re.search(r"[\\'\"]", text[line:start])
|
|
2089
|
+
or re.search(r"['\"]", text[stop:end if end >= 0 else len(text)])
|
|
2090
|
+
or re.search(r"\balias\b|\bfunction\s+%s\b|\b%s\s*\(\s*\)"
|
|
2091
|
+
% (name, name), text[:start])
|
|
2092
|
+
or text[max(0, line - 2):line] == "\\\n"
|
|
2093
|
+
or HEREDOC_RISK.search(text, line, start)
|
|
2094
|
+
or tail < len(text) and text[tail] != "\n"):
|
|
2095
|
+
continue
|
|
2096
|
+
for k in range(body, term[0]):
|
|
2097
|
+
if out[k] != "\n":
|
|
2098
|
+
out[k] = " "
|
|
2099
|
+
return "".join(out)
|
|
2100
|
+
|
|
2101
|
+
|
|
2102
|
+
def _shell_words(command):
|
|
2103
|
+
"""The line as (word, operator?) pairs with quotes kept on each word, so a
|
|
2104
|
+
quoted `>` stays data: shlex in non-posix mode, heredoc bodies blanked, an
|
|
2105
|
+
unquoted backtick read as `;`, a continued line joined and every command
|
|
2106
|
+
line ended by `;` - the way `_shell_segments` reads a line, minus dropping
|
|
2107
|
+
redirects. A newline inside quotes does not end a line (`_shell_lines`), so
|
|
2108
|
+
a multi-line `python3 -c '...'` is one word, not lines of commands. A line
|
|
2109
|
+
shlex cannot read is read roughly rather than dropped."""
|
|
2110
|
+
text = _unquoted_backticks(_blank_heredocs(_blank_script_heredocs(
|
|
2111
|
+
str(command or ""))).replace("\\\n", " "))
|
|
2112
|
+
out = []
|
|
2113
|
+
for line in map(_hide_escapes, _shell_lines(text)):
|
|
2114
|
+
try:
|
|
2115
|
+
lex = shlex.shlex(line, posix=False, punctuation_chars=";&|()<>")
|
|
2116
|
+
lex.whitespace_split = True
|
|
2117
|
+
lex.commenters = ""
|
|
2118
|
+
words = list(lex)
|
|
2119
|
+
except ValueError:
|
|
2120
|
+
words = re.findall(r"[;&|()<>]+|[^\s;&|()<>]+", line)
|
|
2121
|
+
for word in words:
|
|
2122
|
+
if word.startswith("#"):
|
|
2123
|
+
break # bash: a comment starts a word
|
|
2124
|
+
out.append((word.translate(_UNESCAPE), bool(SHELL_OPS.match(word))))
|
|
2125
|
+
out.append((";", True))
|
|
2126
|
+
return out
|
|
2127
|
+
|
|
2128
|
+
|
|
2129
|
+
# The wrappers `_program` reads past, by basename, with the options that take
|
|
2130
|
+
# the next word as their value; `timeout` also takes its duration.
|
|
2131
|
+
WRAPPER_VALUE_OPTS = {"sudo": ("-u", "-g", "-h", "-p", "-C", "-D", "-r", "-t", "-U"),
|
|
2132
|
+
"env": ("-u", "--unset", "-C", "--chdir"),
|
|
2133
|
+
"nice": ("-n", "--adjustment"), "nohup": (), "exec": ("-a",),
|
|
2134
|
+
"time": ("-f", "-o"), "command": (),
|
|
2135
|
+
"timeout": ("-s", "--signal", "-k", "--kill-after"),
|
|
2136
|
+
"stdbuf": ("-i", "-o", "-e")}
|
|
2137
|
+
# An interpreter's options that take the next word as their value, and the ones
|
|
2138
|
+
# that make the program its own text rather than a script (`python3 -c`, `-m`).
|
|
2139
|
+
INTERPRETER_VALUE_OPTS = ("-X", "-W", "-o", "+o", "-O")
|
|
2140
|
+
|
|
2141
|
+
|
|
2142
|
+
def _program(words):
|
|
2143
|
+
"""(program basename without `.py`, its arguments) for one simple command,
|
|
2144
|
+
past compound keywords, env assignments, the wrappers (WRAPPER_VALUE_OPTS,
|
|
2145
|
+
by basename, with their options) and an interpreter's options before the
|
|
2146
|
+
script it runs."""
|
|
2147
|
+
i = 0
|
|
2148
|
+
while i < len(words):
|
|
2149
|
+
word = words[i]
|
|
2150
|
+
wrapper = os.path.basename(word)
|
|
2151
|
+
if ENV_WORD.match(word) or word in SHELL_KEYWORDS:
|
|
2152
|
+
i += 1
|
|
2153
|
+
continue
|
|
2154
|
+
if wrapper not in WRAPPER_VALUE_OPTS:
|
|
2155
|
+
break
|
|
2156
|
+
i += 1
|
|
2157
|
+
while i < len(words) and words[i].startswith("-"):
|
|
2158
|
+
i += 2 if words[i] in WRAPPER_VALUE_OPTS[wrapper] else 1
|
|
2159
|
+
if wrapper == "timeout" and i < len(words):
|
|
2160
|
+
i += 1 # the duration
|
|
2161
|
+
words = words[i:]
|
|
2162
|
+
if words and words[0] == "[":
|
|
2163
|
+
# the test command: its operands are not a program, and the command
|
|
2164
|
+
# after the `]` is (`if [ -d x ]; then touch y; fi`)
|
|
2165
|
+
close = words.index("]") if "]" in words else len(words)
|
|
2166
|
+
words = words[close + 1:]
|
|
2167
|
+
if not words:
|
|
2168
|
+
return "", []
|
|
2169
|
+
name = os.path.basename(words[0])
|
|
2170
|
+
if name in INTERPRETERS or re.fullmatch(r"python[\d.]+", name):
|
|
2171
|
+
j = 1
|
|
2172
|
+
while j < len(words) and words[j].startswith(("-", "+")) and words[j] != "-":
|
|
2173
|
+
flag = words[j]
|
|
2174
|
+
if re.fullmatch(r"-[A-Za-z]*[cm]" if name.startswith("python")
|
|
2175
|
+
else r"-[A-Za-z]*c[A-Za-z]*", flag):
|
|
2176
|
+
j = len(words) + 1 # the program is its own text: no script word
|
|
2177
|
+
break
|
|
2178
|
+
j += 2 if flag in INTERPRETER_VALUE_OPTS else 1
|
|
2179
|
+
if j < len(words):
|
|
2180
|
+
words = words[j:]
|
|
2181
|
+
if not words:
|
|
2182
|
+
return "", []
|
|
2183
|
+
name = os.path.basename(words[0])
|
|
2184
|
+
return (name[:-3] if name.endswith(".py") else name), words[1:]
|
|
2185
|
+
|
|
2186
|
+
|
|
2187
|
+
def _option_values(args, opts):
|
|
2188
|
+
"""The values `args` gives the options in `opts`: `-o v`, `--output=v`, a
|
|
2189
|
+
short option closing a cluster (`-xzf v`) and one with its value attached
|
|
2190
|
+
(`-ov`)."""
|
|
2191
|
+
out = []
|
|
2192
|
+
for i, arg in enumerate(args):
|
|
2193
|
+
for opt in opts:
|
|
2194
|
+
if opt.startswith("--") and arg.startswith(opt + "="):
|
|
2195
|
+
out.append(arg[len(opt) + 1:])
|
|
2196
|
+
elif (arg == opt or not opt.startswith("--") and re.fullmatch(
|
|
2197
|
+
r"-[A-Za-z]*" + re.escape(opt[1:]), arg)) and i + 1 < len(args):
|
|
2198
|
+
out.append(args[i + 1])
|
|
2199
|
+
elif not opt.startswith("--") and arg.startswith(opt) and len(arg) > 2:
|
|
2200
|
+
out.append(arg[2:])
|
|
2201
|
+
return out
|
|
2202
|
+
|
|
2203
|
+
|
|
2204
|
+
def _chmod_removes(args):
|
|
2205
|
+
"""True when a chmod mode can take a permission away (`-x`, `a=r`, `644`)."""
|
|
2206
|
+
mode = next((a for a in args if not a.startswith("-") or re.fullmatch(
|
|
2207
|
+
r"-[rwxXst]+", a)), "")
|
|
2208
|
+
if re.fullmatch(r"[0-7]{3,4}", mode):
|
|
2209
|
+
return not int(mode[-3]) & 1
|
|
2210
|
+
return "-" in mode or "=" in mode and "x" not in mode.split("=", 1)[1]
|
|
2211
|
+
|
|
2212
|
+
|
|
2213
|
+
def _git_change(args, cwd, origin):
|
|
2214
|
+
"""The control-plane label for a `git` command's own change, or None: a
|
|
2215
|
+
forced add of a `.tezgah/` path, an `rm`/`mv` of protected state that is not
|
|
2216
|
+
`--cached` (plan-sync records a move with `git -C .tezgah rm -q --cached`),
|
|
2217
|
+
or a `core.hooksPath` that is set or unset rather than read."""
|
|
2218
|
+
i, where = 0, cwd
|
|
2219
|
+
while i < len(args) and args[i].startswith("-"):
|
|
2220
|
+
if args[i] == "-C" and i + 1 < len(args):
|
|
2221
|
+
where = _real(args[i + 1], where)
|
|
2222
|
+
i += 2 if args[i] in GIT_VALUE_OPTS else 1
|
|
2223
|
+
if i >= len(args):
|
|
2224
|
+
return None
|
|
2225
|
+
sub, rest = args[i], args[i + 1:]
|
|
2226
|
+
paths = [a for a in rest if not a.startswith("-")]
|
|
2227
|
+
if sub == "add" and any(a == "--force" or re.match(r"-[A-Za-z]*f", a)
|
|
2228
|
+
for a in rest):
|
|
2229
|
+
if any(".tezgah" in a.replace("\\", "/").split("/") for a in paths):
|
|
2230
|
+
return FORCED_ADD
|
|
2231
|
+
if sub in ("rm", "mv") and "--cached" not in rest:
|
|
2232
|
+
noted = None
|
|
2233
|
+
for path in paths:
|
|
2234
|
+
label = control_target(path, where, remove=True, origin=origin)
|
|
2235
|
+
if _refusing(label):
|
|
2236
|
+
return _shell_label(label)
|
|
2237
|
+
noted = noted or label
|
|
2238
|
+
return noted
|
|
2239
|
+
if sub == "config" and HOOKS_KEY in (a.lower() for a in rest):
|
|
2240
|
+
head = rest[:rest.index("--")] if "--" in rest else rest
|
|
2241
|
+
positional = [a for a in head if not a.startswith("-")]
|
|
2242
|
+
if any(a.startswith(("--get", "--list")) or a == "-l" for a in head) \
|
|
2243
|
+
or positional[:1] in (["get"], ["list"]):
|
|
2244
|
+
return None
|
|
2245
|
+
# the session's own checkout, or every repository at once
|
|
2246
|
+
mine = any(a in ("--global", "--system") for a in head) or any(
|
|
2247
|
+
_under(os.path.realpath(where), top) for top in _tops(origin))
|
|
2248
|
+
if mine and (len(positional) > 1 or any(a.startswith("--unset") for a in head)):
|
|
2249
|
+
return "the repository's git hooks"
|
|
2250
|
+
return None
|
|
2251
|
+
|
|
2252
|
+
|
|
2253
|
+
def _host_hook(words, cwd):
|
|
2254
|
+
"""True when the `hook.py` this command runs sits under `hosts/<host>/`
|
|
2255
|
+
(HOOK_HOSTS), as written or with its links resolved."""
|
|
2256
|
+
word = next((w for w in words if os.path.basename(w) == "hook.py"), "")
|
|
2257
|
+
written = os.path.join(cwd or os.sep, os.path.expanduser(word))
|
|
2258
|
+
return any(os.path.basename(os.path.dirname(p)) in HOOK_HOSTS
|
|
2259
|
+
and os.path.basename(os.path.dirname(os.path.dirname(p))) == "hosts"
|
|
2260
|
+
for p in (os.path.normpath(written), _full(word, cwd)))
|
|
2261
|
+
|
|
2262
|
+
|
|
2263
|
+
def _command_change(words, cwd, depth, origin):
|
|
2264
|
+
"""The control-plane label one simple command changes, or None."""
|
|
2265
|
+
program, args = _program(words)
|
|
2266
|
+
if program == "eval" and depth < 3:
|
|
2267
|
+
return shell_control(" ".join(args), cwd, depth + 1, origin)
|
|
2268
|
+
if program in SHELL_NAMES and depth < 3:
|
|
2269
|
+
# `-c`, or any flag cluster holding it (`-lc`, `-ec`); a `--` after it
|
|
2270
|
+
# ends the options and the script is the word after that
|
|
2271
|
+
at = next((k for k, a in enumerate(args)
|
|
2272
|
+
if re.fullmatch(r"-[A-Za-z]*c[A-Za-z]*", a)), None)
|
|
2273
|
+
script = args[at + 1:] if at is not None else []
|
|
2274
|
+
script = script[1:] if script[:1] == ["--"] else script
|
|
2275
|
+
if script:
|
|
2276
|
+
return shell_control(script[0], cwd, depth + 1, origin)
|
|
2277
|
+
if program in HOOK_ENTRIES or (program == "hook" and _host_hook(words, cwd)):
|
|
2278
|
+
return "tezgah's hook state through one of its hook entries"
|
|
2279
|
+
if program in CONTROL_CLIS:
|
|
2280
|
+
verbs = CONTROL_CLIS[program]
|
|
2281
|
+
positional = [a for a in args if not a.startswith("-")]
|
|
2282
|
+
if verbs is None or (positional and (verbs == "" or
|
|
2283
|
+
positional[0].lower() in verbs)):
|
|
2284
|
+
return "tezgah's control state through one of its own CLIs"
|
|
2285
|
+
return None
|
|
2286
|
+
if program == "git":
|
|
2287
|
+
return _git_change(args, cwd, origin)
|
|
2288
|
+
checks = [(v, False) for v in _option_values(args, OPTION_TARGETS.get(program, ()))]
|
|
2289
|
+
if program == "tar":
|
|
2290
|
+
if any(a in ("--extract", "--get") or re.fullmatch(r"-?[A-Za-z]*x[A-Za-z]*", a)
|
|
2291
|
+
for a in args[:1] + [a for a in args if a.startswith("-")]):
|
|
2292
|
+
checks += [(v, False) for v in _option_values(args, ("-C", "--directory"))]
|
|
2293
|
+
checks += [(v, False) for v in _option_values(args, ("-f", "--file"))]
|
|
2294
|
+
if program in CONTROL_PROGRAMS and not (program in ("sed", "perl") and not any(
|
|
2295
|
+
re.match(r"-[A-Za-z]*i", a) for a in args)):
|
|
2296
|
+
remove = program in CONTROL_REMOVERS and (
|
|
2297
|
+
program != "chmod" or _chmod_removes(args))
|
|
2298
|
+
if program == "dd":
|
|
2299
|
+
checks += [(a[3:], False) for a in args if a.startswith("of=")]
|
|
2300
|
+
else:
|
|
2301
|
+
targets = [a for a in args if not a.startswith("-")]
|
|
2302
|
+
if program in CONTROL_COPIERS and targets:
|
|
2303
|
+
checks += [(t, remove) for t in targets[:-1]] if program == "mv" else []
|
|
2304
|
+
# `rsync --delete` empties the destination of what the source lacks
|
|
2305
|
+
targets, remove = targets[-1:], program == "rsync" and any(
|
|
2306
|
+
a.startswith("--delete") for a in args)
|
|
2307
|
+
checks += [(t, remove) for t in targets]
|
|
2308
|
+
noted = None
|
|
2309
|
+
for target, remove in checks:
|
|
2310
|
+
label = control_target(target, cwd, remove=remove, origin=origin)
|
|
2311
|
+
if _refusing(label):
|
|
2312
|
+
return _shell_label(label)
|
|
2313
|
+
noted = noted or label
|
|
2314
|
+
return noted
|
|
2315
|
+
|
|
2316
|
+
|
|
2317
|
+
def shell_control(command, cwd, depth=0, origin=None):
|
|
2318
|
+
"""The control-plane label a shell command changes, or None.
|
|
2319
|
+
|
|
2320
|
+
CONTROL_WRITE in the plan's words, read rather than matched, one simple
|
|
2321
|
+
command at a time and in order: a `cd` (or `pushd`) moves the directory the
|
|
2322
|
+
words after it resolve against; a redirect's target, the arguments of a
|
|
2323
|
+
program that changes a path (CONTROL_PROGRAMS, OPTION_TARGETS, `tar`) and a
|
|
2324
|
+
CLI that changes tezgah's state (CONTROL_CLIS) are judged, each expanded the
|
|
2325
|
+
way the shell would (`~`, `$HOME`, the XDG variables). Compound keywords
|
|
2326
|
+
(`if`, `do`, `!`) are read past, and `eval` and `bash -c` (any flag cluster
|
|
2327
|
+
holding `c`) are opened. A quoted word is a word and not a command, so a
|
|
2328
|
+
grep or a commit message that names a switch is not one. SHELL_WRITE is not
|
|
2329
|
+
widened for this: it feeds the task phase rule and the JS mirror.
|
|
2330
|
+
ponytail: an interpreter (`python3 -c "open(...)"`), `find -delete`,
|
|
2331
|
+
`xargs`, a glob or brace in a directory, a link made earlier in the same
|
|
2332
|
+
line and a path assembled at run time are not read; SECURITY.md names them
|
|
2333
|
+
as the residual routes."""
|
|
2334
|
+
origin = origin or cwd
|
|
2335
|
+
where, words, pending, noted = cwd, [], None, None
|
|
2336
|
+
for word, is_op in _shell_words(command):
|
|
2337
|
+
if pending:
|
|
2338
|
+
judged, pending = pending, None
|
|
2339
|
+
if not is_op:
|
|
2340
|
+
target = _unquote(word)
|
|
2341
|
+
if judged == ">" and not (target.isdigit() or target == "-"):
|
|
2342
|
+
label = control_target(target, where, origin=origin)
|
|
2343
|
+
if _refusing(label):
|
|
2344
|
+
return _shell_label(label)
|
|
2345
|
+
noted = noted or label
|
|
2346
|
+
continue
|
|
2347
|
+
if is_op and ("<" in word or ">" in word):
|
|
2348
|
+
if words and words[-1].isdigit():
|
|
2349
|
+
words.pop() # the descriptor before `2>`
|
|
2350
|
+
pending = ">" if ">" in word else "<"
|
|
2351
|
+
continue
|
|
2352
|
+
if not is_op:
|
|
2353
|
+
words.append(word)
|
|
2354
|
+
continue
|
|
2355
|
+
plain = [_unquote(w) for w in words]
|
|
2356
|
+
words = []
|
|
2357
|
+
# `[`/`]` are left to `_program: the test command is skipped there, so
|
|
2358
|
+
# the command after it in an `if` line is read
|
|
2359
|
+
while plain and plain[0] in SHELL_KEYWORDS:
|
|
2360
|
+
plain = plain[1:]
|
|
2361
|
+
if plain and plain[0] in ("cd", "pushd"):
|
|
2362
|
+
dest = [a for a in plain[1:] if not a.startswith("-") or a == "-"]
|
|
2363
|
+
if not dest or dest[0] != "-":
|
|
2364
|
+
where = _full(dest[0] if dest else "~", where)
|
|
2365
|
+
continue
|
|
2366
|
+
label = plain and _command_change(plain, where, depth, origin)
|
|
2367
|
+
if _refusing(label):
|
|
2368
|
+
return label
|
|
2369
|
+
noted = noted or label or None
|
|
2370
|
+
# bash runs the `$( )` and backtick substitutions of an unquoted-tag
|
|
2371
|
+
# heredoc body, whatever the consumer and whatever quotes the body holds,
|
|
2372
|
+
# and the line reader above sees them as data (blanked or quoted)
|
|
2373
|
+
text = str(command or "")
|
|
2374
|
+
if "<<" in text and depth < 3:
|
|
2375
|
+
from tezgah_context import _substitutions
|
|
2376
|
+
for h in _heredocs(text):
|
|
2377
|
+
if h[3]:
|
|
2378
|
+
continue
|
|
2379
|
+
for a, b in _substitutions(text, h[4], h[5][0] if h[5] else len(text)):
|
|
2380
|
+
inner = text[a + (2 if text[a] == "$" else 1):b - 1]
|
|
2381
|
+
for base in dict.fromkeys((cwd, where)):
|
|
2382
|
+
label = shell_control(inner, base, depth + 1, origin)
|
|
2383
|
+
if _refusing(label):
|
|
2384
|
+
return label
|
|
2385
|
+
noted = noted or label
|
|
2386
|
+
return noted
|
|
2387
|
+
|
|
2388
|
+
|
|
2389
|
+
def control_reason(t, inp, cwd):
|
|
2390
|
+
"""The control-plane label this call changes, or None (see CONTROL_DENY).
|
|
2391
|
+
A CONTROL_EVIDENCE label comes back only when nothing in the call refuses."""
|
|
2392
|
+
if t in BASH_TOOLS:
|
|
2393
|
+
return shell_control(inp.get("command") or inp.get("cmd"), cwd)
|
|
2394
|
+
deletes = set(PATCH_DELETE.findall(str(inp.get("patch") or "")))
|
|
2395
|
+
noted = None
|
|
2396
|
+
for path in write_paths(inp):
|
|
2397
|
+
label = control_target(path, cwd, remove=path in deletes)
|
|
2398
|
+
if label and not os.path.isabs(label):
|
|
2399
|
+
if _refusing(label):
|
|
2400
|
+
return label
|
|
2401
|
+
noted = noted or label
|
|
2402
|
+
elif label and _shared_change(label, inp):
|
|
2403
|
+
return "tezgah's hook wiring"
|
|
2404
|
+
return noted
|
|
2405
|
+
|
|
2406
|
+
|
|
2407
|
+
def _deny(session_id, rule, reason, tool=None, inp=None, workspace=None,
|
|
2408
|
+
extra=None):
|
|
2409
|
+
"""Record a refusal before returning it: a deny nobody counts is a rule
|
|
2410
|
+
whose effect can never be argued about (hooks/tezgah_integrity.counters).
|
|
2411
|
+
|
|
2412
|
+
The row carries the refused call's id and workspace, so the ledger says
|
|
2413
|
+
which action was stopped and where - the same two facts a PostToolUse row
|
|
2414
|
+
carries. `extra` appends a fact the row must keep beyond the 80 characters
|
|
2415
|
+
of the reason it truncates."""
|
|
2416
|
+
detail = "%s: %s" % (rule, cut(reason, 80))
|
|
2417
|
+
if extra:
|
|
2418
|
+
detail = "%s; %s" % (detail, extra)
|
|
2419
|
+
note(session_id, "deny", detail, id=call_id(tool, inp), workspace=workspace)
|
|
2420
|
+
return reason
|
|
2421
|
+
|
|
2422
|
+
|
|
2423
|
+
def decision(tool, inp, cwd, session_id=None, record=True, agent=None):
|
|
2424
|
+
"""A deny reason for this call, or None to let it pass.
|
|
2425
|
+
|
|
2426
|
+
`record=False` is the dry run `tezgah-gate check` and the MCP
|
|
2427
|
+
`tezgah_gate_check` promise ("without running it"): the same answer, with
|
|
2428
|
+
no deny/drift/nudge row, no nudge mark and no snapshot. A dry run against a
|
|
2429
|
+
live session id wrote refusal rows into that session's counters (audit
|
|
2430
|
+
CHAT-07 / L-14b).
|
|
2431
|
+
|
|
2432
|
+
`agent` is the host's subagent id when the call came from one (Claude's
|
|
2433
|
+
`agent_id`): the repeat ceilings count that agent's own attempts, so one
|
|
2434
|
+
sibling's failures are not spent as another's."""
|
|
2435
|
+
bind_session(session_id) # the switch latch: off() below answers for this session
|
|
2436
|
+
if not record:
|
|
2437
|
+
return _dry_decision(tool, inp, cwd, session_id, agent)
|
|
2438
|
+
if off("pretooluse-off"):
|
|
2439
|
+
return None
|
|
2440
|
+
base = root_for(cwd)
|
|
2441
|
+
if not base:
|
|
2442
|
+
return None
|
|
2443
|
+
t = str(tool or "").lower()
|
|
2444
|
+
# A write or shell call's first row (tezgah_integrity.BEGAN_KIND), written
|
|
2445
|
+
# before any rule runs: a host that kills a slow gate still runs the call,
|
|
2446
|
+
# and a pass with no `began` reads as an orphan (`_pair`). The PostToolUse
|
|
2447
|
+
# hook writes the second row; a refusal below answers it with its `deny`.
|
|
2448
|
+
# Through `note`, so the dry run writes none. `check` is decided here, from
|
|
2449
|
+
# the whole command: the stored detail is cut at DETAIL_MAX.
|
|
2450
|
+
if t in WRITE_TOOLS or t in BASH_TOOLS:
|
|
2451
|
+
digest = call_id(tool, inp)
|
|
2452
|
+
if digest:
|
|
2453
|
+
command = str(inp.get("command") or inp.get("cmd") or "")
|
|
2454
|
+
note(session_id, BEGAN_KIND, command or (write_paths(inp) or [""])[0],
|
|
2455
|
+
id=digest, tool=str(tool or "").strip() or None, workspace=base,
|
|
2456
|
+
check=1 if t in BASH_TOOLS and verify_command(command) else None)
|
|
2457
|
+
# Claude's Task names the agent `subagent_type`; omp's task names it `agent`,
|
|
2458
|
+
# on the call (flat shape) or on each `tasks[]` item (batch shape).
|
|
2459
|
+
subs = [inp.get("subagent_type"),
|
|
2460
|
+
(inp.get("args") or {}).get("subagent_type"), inp.get("agent")]
|
|
2461
|
+
subs += [item.get("agent") for item in inp.get("tasks") or ()
|
|
2462
|
+
if isinstance(item, dict)]
|
|
2463
|
+
# The body a shell call writes, read once: the three write-tool-only rules
|
|
2464
|
+
# below reach it through the shell (see the section above). None for every
|
|
2465
|
+
# tool that is not a shell, and for a shell line that writes no body.
|
|
2466
|
+
shell_body = (shell_write_body(inp.get("command"), cwd)
|
|
2467
|
+
if t in BASH_TOOLS else None)
|
|
2468
|
+
# Control plane, first after the root check: every rule below is held by a
|
|
2469
|
+
# file this one protects (control_reason). No switch but `pretooluse-off`
|
|
2470
|
+
# removes it, and that one is among the files. A CONTROL_EVIDENCE target is
|
|
2471
|
+
# not refused: the call goes on to the rules below, and a call they let
|
|
2472
|
+
# through leaves a `disarm` row beside its `began` row.
|
|
2473
|
+
evidence = None
|
|
2474
|
+
if t in WRITE_TOOLS + BASH_TOOLS:
|
|
2475
|
+
what = control_reason(t, inp, cwd)
|
|
2476
|
+
if _refusing(what):
|
|
2477
|
+
return _deny(session_id, "control", CONTROL_DENY % what, tool, inp,
|
|
2478
|
+
base)
|
|
2479
|
+
evidence = what
|
|
2480
|
+
if t in ("agent", "task", "subagent") and any(explored(s) for s in subs):
|
|
2481
|
+
return _deny(session_id, "explorer", EXPLORE_DENY, tool, inp, base)
|
|
2482
|
+
# anti-shortcut: a check neutered so it cannot fail, or a test disabled so a
|
|
2483
|
+
# failure disappears. This is the mechanical half of the integrity rule; the
|
|
2484
|
+
# reply-level half is the Stop hook in hooks/projects-stop.py (Claude),
|
|
2485
|
+
# hosts/codex/hook.py, hosts/cursor/hook.py and hosts/omp/hook.py. `verify-off`
|
|
2486
|
+
# removes that rule, so it drops this half too; `pretooluse-off` above still
|
|
2487
|
+
# drops the whole gate, and attribution/explore are other rules and stay
|
|
2488
|
+
# armed. The shell half of the write rule reads the body a heredoc writes,
|
|
2489
|
+
# which is the route E7c measured once the write tools were refused.
|
|
2490
|
+
if not off("verify-off"):
|
|
2491
|
+
if t in BASH_TOOLS:
|
|
2492
|
+
reason = shortcut_command(inp.get("command"))
|
|
2493
|
+
if reason:
|
|
2494
|
+
return _deny(session_id, "shortcut", reason, tool, inp, base)
|
|
2495
|
+
reason = piped_check and piped_check(inp.get("command"))
|
|
2496
|
+
if reason:
|
|
2497
|
+
return _deny(session_id, "piped", reason, tool, inp, base)
|
|
2498
|
+
if shell_body:
|
|
2499
|
+
reason = shortcut_edit(shell_body)
|
|
2500
|
+
if reason:
|
|
2501
|
+
return _deny(session_id, "shortcut", reason, tool, inp, base)
|
|
2502
|
+
if t in WRITE_TOOLS:
|
|
2503
|
+
reason = shortcut_edit(inp)
|
|
2504
|
+
if reason:
|
|
2505
|
+
return _deny(session_id, "shortcut", reason, tool, inp, base)
|
|
2506
|
+
if t in BASH_TOOLS and attribution(inp.get("command")):
|
|
2507
|
+
return _deny(session_id, "attribution", ATTRIB_DENY, tool, inp, base)
|
|
2508
|
+
if shell_body and attribution_edit(shell_body):
|
|
2509
|
+
return _deny(session_id, "attribution", ATTRIB_DENY, tool, inp, base)
|
|
2510
|
+
if t in WRITE_TOOLS and attribution_edit(inp):
|
|
2511
|
+
return _deny(session_id, "attribution", ATTRIB_DENY, tool, inp, base)
|
|
2512
|
+
# Language: the identifiers this command would create. Beside the
|
|
2513
|
+
# attribution rule because both read the command's own text for a form it
|
|
2514
|
+
# carries, and both are about what the artifact will say for good. The rule
|
|
2515
|
+
# has its own switch, and the whole-gate one above drops it with everything
|
|
2516
|
+
# else: a heuristic that refuses a word the user chose has to be one the
|
|
2517
|
+
# user can take off (see tezgah_lang's docstring for what it cannot decide).
|
|
2518
|
+
if not off("lang-off") and t in BASH_TOOLS:
|
|
2519
|
+
reason = lang_reason(inp.get("command"), cwd)
|
|
2520
|
+
if reason:
|
|
2521
|
+
return _deny(session_id, "lang", reason, tool, inp, base)
|
|
2522
|
+
# Concurrent write: another session wrote one of this call's files inside
|
|
2523
|
+
# RACE_WINDOW_MIN (the constant carries why it refuses). Ahead of the repeat
|
|
2524
|
+
# guards, so a colliding write is counted as this rule and not as a repeat of
|
|
2525
|
+
# one. A shell command that writes a file through a redirect or `tee` is a
|
|
2526
|
+
# write here too: the rules below read its target from `write_paths`, the
|
|
2527
|
+
# reader `capture` and the PostToolUse row already share - unless all it
|
|
2528
|
+
# writes is its own scratch (`scratch_target`), such as a `> /tmp/check.log`.
|
|
2529
|
+
writes = t in WRITE_TOOLS or (t in BASH_TOOLS and any(
|
|
2530
|
+
not scratch_target(p, cwd) for p in write_paths(inp)))
|
|
2531
|
+
if writes and RACE_REFUSE:
|
|
2532
|
+
reason = race_reason(inp, session_id, cwd)
|
|
2533
|
+
if reason:
|
|
2534
|
+
return _deny(session_id, "race", reason, tool, inp, base)
|
|
2535
|
+
# Task scope: the user's own record for this work - its phase and the files
|
|
2536
|
+
# its allowlist names (see task_reason). The
|
|
2537
|
+
# record itself is refused first of all, and a shell that would change it
|
|
2538
|
+
# through the CLI is refused with it: a boundary the agent can move is not a
|
|
2539
|
+
# boundary, and both routes to moving this one were measured open (E7).
|
|
2540
|
+
if not off("task-off"):
|
|
2541
|
+
# The record is read once and handed to every rule below: each read lists
|
|
2542
|
+
# `.tezgah/plans/open/` and parses a plan, and this path is every write of
|
|
2543
|
+
# every session. Reads and non-write tools pay nothing.
|
|
2544
|
+
task = _UNRESOLVED
|
|
2545
|
+
if tezgah_task is not None and t in WRITE_TOOLS + BASH_TOOLS:
|
|
2546
|
+
task = tezgah_task.active(cwd, base)
|
|
2547
|
+
if writes:
|
|
2548
|
+
reason = task_record_reason(inp, cwd, base, task)
|
|
2549
|
+
if reason:
|
|
2550
|
+
return _deny(session_id, "task", reason, tool, inp, base)
|
|
2551
|
+
if t in BASH_TOOLS and TASK_CHANGE.search(
|
|
2552
|
+
mask(str(inp.get("command") or ""))):
|
|
2553
|
+
return _deny(session_id, "task", TASK_UNLOCK_DENY, tool, inp, base)
|
|
2554
|
+
if t in BASH_TOOLS:
|
|
2555
|
+
reason = task_shell_reason(inp, cwd, base, task)
|
|
2556
|
+
if reason:
|
|
2557
|
+
return _deny(session_id, "task", reason, tool, inp, base)
|
|
2558
|
+
if writes:
|
|
2559
|
+
reason = task_reason(inp, cwd, base, task)
|
|
2560
|
+
if reason:
|
|
2561
|
+
return _deny(session_id, "task", reason, tool, inp, base)
|
|
2562
|
+
# The checkpoint: the same phase, the state it starts from. After the
|
|
2563
|
+
# scope rule, because a path outside the allowlist is the more specific
|
|
2564
|
+
# fault in the same call (see checkpoint_reason for why the rule is here
|
|
2565
|
+
# at all and not only at the phase move).
|
|
2566
|
+
if t in WRITE_TOOLS + BASH_TOOLS:
|
|
2567
|
+
reason = checkpoint_reason(inp, cwd, base, task)
|
|
2568
|
+
if reason:
|
|
2569
|
+
return _deny(session_id, "task", reason, tool, inp, base)
|
|
2570
|
+
# Workspace: tezgah state belongs under .tezgah/, never a root plans/,
|
|
2571
|
+
# research/ or analysis/ the project does not own (see workspace_reason).
|
|
2572
|
+
if not off("workspace-off") and t in WRITE_TOOLS + BASH_TOOLS:
|
|
2573
|
+
reason = workspace_reason(inp, cwd, base)
|
|
2574
|
+
if reason:
|
|
2575
|
+
return _deny(session_id, "workspace", reason, tool, inp, base)
|
|
2576
|
+
# MCP: a server's effect tool lands text the way a write tool or a `gh`
|
|
2577
|
+
# write does, so its payload meets the same content rules. The verb class
|
|
2578
|
+
# (tezgah_integrity.MCP_VERBS) only picks which rules read it - it never
|
|
2579
|
+
# refuses or asks by itself - and the payload is read through the bounded
|
|
2580
|
+
# walk (mcp_text). The credential half reads vendor token shapes only
|
|
2581
|
+
# (MCP_TOKEN), never the shell's name=value shape.
|
|
2582
|
+
verb = mcp_class(tool)
|
|
2583
|
+
if verb in ("write", "publish"):
|
|
2584
|
+
text = mcp_text(inp)
|
|
2585
|
+
if verb == "write" and not off("verify-off"):
|
|
2586
|
+
reason = shortcut_edit({"file_path": (write_paths(inp) or [""])[0],
|
|
2587
|
+
"content": text})
|
|
2588
|
+
if reason:
|
|
2589
|
+
return _deny(session_id, "shortcut", reason, tool, inp, base)
|
|
2590
|
+
if ATTRIB_LINE.search(text):
|
|
2591
|
+
return _deny(session_id, "attribution", ATTRIB_DENY, tool, inp, base)
|
|
2592
|
+
if MCP_TOKEN.search(text):
|
|
2593
|
+
return _deny(session_id, "secret", MCP_SECRET_DENY, tool, inp, base)
|
|
2594
|
+
# A credential on its way into a file. No escape hatch: the deny text
|
|
2595
|
+
# names the rephrase (a name, a length, a fingerprint), so the write can
|
|
2596
|
+
# be replaced rather than repeated. The shell's own text is read for the
|
|
2597
|
+
# name=value and bearer shapes next to a sink; the text a write lands - a
|
|
2598
|
+
# write tool's content, or the body a heredoc writes, which mask() blanks -
|
|
2599
|
+
# for the prefixed token families (see SECRET_EDIT_DENY for why the two
|
|
2600
|
+
# differ), so a Write and a heredoc landing the same text agree.
|
|
2601
|
+
if t in BASH_TOOLS:
|
|
2602
|
+
reason = secret_command(inp.get("command"))
|
|
2603
|
+
if reason:
|
|
2604
|
+
return _deny(session_id, "secret", reason, tool, inp, base)
|
|
2605
|
+
body = inp if t in WRITE_TOOLS else shell_body
|
|
2606
|
+
if body and secret_edit(body, cwd):
|
|
2607
|
+
return _deny(session_id, "secret", SECRET_EDIT_DENY, tool, inp, base)
|
|
2608
|
+
# Plan required: the turn's third product file while the checkout is on
|
|
2609
|
+
# main/master (see plan_reason). Below the task, workspace and secret rules,
|
|
2610
|
+
# because each names a more specific fault in the same write, and above the
|
|
2611
|
+
# repeat guards, because it is the call that is refused and not a repeat of
|
|
2612
|
+
# one. Standing: only changing the tree's state changes it.
|
|
2613
|
+
if t in WRITE_TOOLS + BASH_TOOLS:
|
|
2614
|
+
reason = plan_reason(inp, cwd, session_id)
|
|
2615
|
+
if reason:
|
|
2616
|
+
return _deny(session_id, "plan", reason, tool, inp, base)
|
|
2617
|
+
# Ordering: a commit asserted over a check that just failed (see
|
|
2618
|
+
# commit_order_reason). An argument-shaped rule, so it sits with them and
|
|
2619
|
+
# above the repeat guards; it rides `verify-off`, the switch that governs the
|
|
2620
|
+
# rest of the integrity rule, because this is that rule's claim read at the
|
|
2621
|
+
# one moment it lands in the repository's history.
|
|
2622
|
+
if not off("verify-off") and t in BASH_TOOLS:
|
|
2623
|
+
reason = commit_order_reason(inp.get("command"), session_id)
|
|
2624
|
+
if reason:
|
|
2625
|
+
return _deny(session_id, "order", reason, tool, inp, base)
|
|
2626
|
+
# Repeat guards, under the same kill switch as the other integrity denials
|
|
2627
|
+
# and after every argument-shaped rule: a call another rule would have
|
|
2628
|
+
# refused has to be counted as that rule, not as a repeat. `loop` is the
|
|
2629
|
+
# failure-scoped half (identical attempts that failed, per user turn), `retry`
|
|
2630
|
+
# the session-wide ceiling above it (every attempt of the call, whatever it
|
|
2631
|
+
# returned). Both read the ledger tail, which is the only file I/O this path
|
|
2632
|
+
# is allowed.
|
|
2633
|
+
if not off("verify-off") and t in BASH_TOOLS + WRITE_TOOLS:
|
|
2634
|
+
reason = loop_reason(tool, inp, session_id, agent)
|
|
2635
|
+
if reason:
|
|
2636
|
+
return _deny(session_id, "loop", reason, tool, inp, base)
|
|
2637
|
+
reason = retry_reason(tool, inp, session_id, agent)
|
|
2638
|
+
if reason:
|
|
2639
|
+
return _deny(session_id, "retry", reason, tool, inp, base)
|
|
2640
|
+
if symbol := searched_identifier(tool, inp):
|
|
2641
|
+
slug = index_slug(cwd, base)
|
|
2642
|
+
if slug and first_nudge(session_id):
|
|
2643
|
+
note(session_id, "nudge", slug, id=call_id(tool, inp),
|
|
2644
|
+
workspace=base)
|
|
2645
|
+
return nudge_reason(symbol)
|
|
2646
|
+
# Constraint drift, last: a re-statement yields to every refusal above (a
|
|
2647
|
+
# refusal's reason is the same channel), and `verify-off` does not drop it -
|
|
2648
|
+
# the rules it re-states are not the verify rule. The switch that governs it
|
|
2649
|
+
# is the per-turn reminder's own (checked in drift_reason), because this is
|
|
2650
|
+
# that reminder's mid-turn half.
|
|
2651
|
+
reason = drift_reason(tool, inp, cwd, session_id)
|
|
2652
|
+
if reason:
|
|
2653
|
+
return _deny(session_id, "drift", reason, tool, inp, base)
|
|
2654
|
+
# Lesson lineage: a ledger write about to land in a turn that read untrusted
|
|
2655
|
+
# text leaves its rows and is let through (see lesson_taint; ADR 010). After
|
|
2656
|
+
# every refusal, so a refused write that will be re-issued is counted once;
|
|
2657
|
+
# a failure costs the rows, never the write.
|
|
2658
|
+
if t in WRITE_TOOLS + BASH_TOOLS:
|
|
2659
|
+
try:
|
|
2660
|
+
lesson_taint(tool, inp, cwd, session_id, agent, shell_body)
|
|
2661
|
+
except Exception:
|
|
2662
|
+
pass
|
|
2663
|
+
# Nothing refused this call, so a write is about to land: keep the bytes it
|
|
2664
|
+
# is about to change, which is what bin/tezgah-rollback restores by hand.
|
|
2665
|
+
# Nothing automatic undoes work here - see tezgah_snapshot's own note on why.
|
|
2666
|
+
# A refused write changes no file, so it is never captured, and this is the
|
|
2667
|
+
# only place the gate writes anything the deny path does not. capture returns
|
|
2668
|
+
# None rather than raising, but no host wraps decision(), so the outer try is
|
|
2669
|
+
# cheap insurance in the one path where a failure would block an edit.
|
|
2670
|
+
#
|
|
2671
|
+
# A shell call that writes a file is the same call for this purpose (see
|
|
2672
|
+
# write_paths): without the pre-state, the after-state alone cannot tell a
|
|
2673
|
+
# write that landed from one that was a no-op, and the freshness rule would
|
|
2674
|
+
# count every redirect as a change. `capture` reads a write tool's own path
|
|
2675
|
+
# field and takes no shell tool name, so the target goes in the shape it
|
|
2676
|
+
# reads - SHELL_AS_WRITE - and the row it writes carries no tool name, so
|
|
2677
|
+
# nothing on the ledger claims a write tool ran.
|
|
2678
|
+
#
|
|
2679
|
+
# A control-plane change the rules let through (CONTROL_EVIDENCE) leaves
|
|
2680
|
+
# its `disarm` row here, on the allow path. The call's `began` row is
|
|
2681
|
+
# written before the rules (see the top of this function).
|
|
2682
|
+
if evidence and (t in WRITE_TOOLS or t in BASH_TOOLS):
|
|
2683
|
+
note(session_id, "disarm", "control: %s" % evidence,
|
|
2684
|
+
id=call_id(tool, inp), workspace=base)
|
|
2685
|
+
if capture and t in WRITE_TOOLS:
|
|
2686
|
+
try:
|
|
2687
|
+
capture(tool, inp, cwd, session_id)
|
|
2688
|
+
except Exception:
|
|
2689
|
+
pass
|
|
2690
|
+
elif capture and t in BASH_TOOLS:
|
|
2691
|
+
try:
|
|
2692
|
+
for path in write_paths(inp):
|
|
2693
|
+
capture(SHELL_AS_WRITE, {"file_path": path}, cwd, session_id)
|
|
2694
|
+
except Exception:
|
|
2695
|
+
pass
|
|
2696
|
+
return None
|
|
2697
|
+
|
|
2698
|
+
|
|
2699
|
+
def _dry_decision(tool, inp, cwd, session_id, agent=None):
|
|
2700
|
+
"""`decision` with every write it makes swapped out for the call.
|
|
2701
|
+
|
|
2702
|
+
The writers are module names `decision` reaches through (`note` for the
|
|
2703
|
+
deny, drift, nudge and lesson_tainted rows, `write_taint` for the lesson
|
|
2704
|
+
taint index, `first_nudge` for the once-per-session mark, `capture` for the
|
|
2705
|
+
snapshot), so one swap covers every rule and a new rule
|
|
2706
|
+
that records through them is dry here without being told. Safe because
|
|
2707
|
+
the dry run is its own process (bin/tezgah-gate check): nothing else in it
|
|
2708
|
+
reads these names while they are swapped, and the finally puts them back.
|
|
2709
|
+
The nudge is still answered as the live path would answer it - spent when
|
|
2710
|
+
its mark exists - only the mark is not written."""
|
|
2711
|
+
g = globals()
|
|
2712
|
+
saved = {name: g[name] for name in ("note", "write_taint", "first_nudge",
|
|
2713
|
+
"capture")}
|
|
2714
|
+
|
|
2715
|
+
g.update(note=lambda *a, **k: None, write_taint=lambda *a, **k: None,
|
|
2716
|
+
capture=None,
|
|
2717
|
+
first_nudge=lambda sid: not os.path.exists(nudge_mark(sid)))
|
|
2718
|
+
try:
|
|
2719
|
+
return decision(tool, inp, cwd, session_id, agent=agent)
|
|
2720
|
+
finally:
|
|
2721
|
+
g.update(saved)
|
|
2722
|
+
|
|
2723
|
+
|
|
2724
|
+
|
|
2725
|
+
# Every rule slug `decision` records a refusal under (`_deny`'s second argument),
|
|
2726
|
+
# with the kill switch that disarms it beside `pretooluse-off`, which disarms
|
|
2727
|
+
# them all; None means only that one. A lesson the ledger retires with
|
|
2728
|
+
# `|| enforced_by: <slug>` (tezgah_lessons.lines) leaves the injected
|
|
2729
|
+
# block only while its rule is armed, so `piped` brings its lesson back under
|
|
2730
|
+
# `verify-off`. `tests/test_lessons.py` holds this map to the `_deny` literals.
|
|
2731
|
+
DENY_RULES = {"control": None, "explorer": None, "shortcut": "verify-off",
|
|
2732
|
+
"piped": "verify-off",
|
|
2733
|
+
"attribution": None, "lang": "lang-off", "race": None,
|
|
2734
|
+
"task": "task-off", "workspace": "workspace-off", "secret": None,
|
|
2735
|
+
"plan": None, "order": "verify-off", "loop": "verify-off",
|
|
2736
|
+
"retry": "verify-off", "drift": "reminder-off"}
|