pudicus 0.1.1__tar.gz
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.
- pudicus-0.1.1/PKG-INFO +145 -0
- pudicus-0.1.1/README.md +136 -0
- pudicus-0.1.1/pudicus/__init__.py +0 -0
- pudicus-0.1.1/pudicus/cli.py +305 -0
- pudicus-0.1.1/pudicus/core.py +108 -0
- pudicus-0.1.1/pudicus.egg-info/PKG-INFO +145 -0
- pudicus-0.1.1/pudicus.egg-info/SOURCES.txt +11 -0
- pudicus-0.1.1/pudicus.egg-info/dependency_links.txt +1 -0
- pudicus-0.1.1/pudicus.egg-info/entry_points.txt +2 -0
- pudicus-0.1.1/pudicus.egg-info/requires.txt +2 -0
- pudicus-0.1.1/pudicus.egg-info/top_level.txt +1 -0
- pudicus-0.1.1/pyproject.toml +27 -0
- pudicus-0.1.1/setup.cfg +4 -0
pudicus-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pudicus
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A pluggable inspection gate for Git commits
|
|
5
|
+
Requires-Python: >=3.8
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: PyYAML
|
|
8
|
+
Requires-Dist: behave
|
|
9
|
+
|
|
10
|
+
# Pudicus
|
|
11
|
+
|
|
12
|
+
Pudicus ("modest, chaste, keeping pure") is a standalone, pluggable inspection gate for Git commits.
|
|
13
|
+
|
|
14
|
+
It was built to **shift left** the problem of secrets and confidential information leaking into source code. While it is technically possible to rewrite git history later (e.g., using `git filter-repo`) to scrub leaked keys, it is vastly better to catch the information *before* it ever gets committed to the local repository in the first place.
|
|
15
|
+
|
|
16
|
+
In the era of agentic coding, a specific problem arises: **How do we ensure that autonomous AI agents (or hurried humans) actually run the necessary security checks before committing code?**
|
|
17
|
+
|
|
18
|
+
Pudicus bridges the gap between agent automation and deployment safety by acting like an **agricultural inspector's produce sticker**. It intercepts and blocks secrets at the staging area, generating a cryptographically verifiable receipt proving the code was scanned. A downstream deploy gate then prevents the truck from unloading if the stickers are missing.
|
|
19
|
+
|
|
20
|
+
```mermaid
|
|
21
|
+
flowchart LR
|
|
22
|
+
Agent["Agent / Developer"] -->|Writes Code| Hook["Pudicus Git Hook"]
|
|
23
|
+
|
|
24
|
+
subgraph Pudicus Validation
|
|
25
|
+
Hook -->|Runs| Scanners["Scanners<br/>(Gitleaks, Tactus, etc.)"]
|
|
26
|
+
Scanners -->|Clean| Sig["Cryptographic Signature<br/>(HMAC-SHA256)"]
|
|
27
|
+
Scanners -->|Issues Found| Block["Commit Blocked"]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
Sig --> Commit["Signed Commit"]
|
|
31
|
+
|
|
32
|
+
Agent -.->|Bypasses hook| Unsigned["Unsigned Commit"]
|
|
33
|
+
|
|
34
|
+
Commit --> Gate{"Deploy Gate<br/>(CI/CD)"}
|
|
35
|
+
Unsigned -.-> Gate
|
|
36
|
+
|
|
37
|
+
Gate -->|Valid Signature| Prod[("(Production)")]
|
|
38
|
+
Gate -->|No Signature| Reject["Deploy Rejected"]
|
|
39
|
+
|
|
40
|
+
style Prod fill:#ccffcc,stroke:#00aa00
|
|
41
|
+
style Reject fill:#ffcccc,stroke:#ff0000
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Because the agent does not have access to the cryptographic secret required to mint the signature, the absolute easiest path for an agent to get its code deployed is to simply let the hook run the scanners.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Quick Start
|
|
49
|
+
|
|
50
|
+
Pudicus is built in Python and requires `git` on the host machine.
|
|
51
|
+
|
|
52
|
+
**1. Install the CLI:**
|
|
53
|
+
```bash
|
|
54
|
+
pip install pudicus
|
|
55
|
+
```
|
|
56
|
+
*(Note: Until published to PyPI, use `pip install git+ssh://git@github.com/AnthusAI/Pudicus.git`)*
|
|
57
|
+
|
|
58
|
+
**2. Initialize a repository:**
|
|
59
|
+
Run this in your target repository. It generates the shared secret and installs the `.git/hooks/commit-msg` hook.
|
|
60
|
+
```bash
|
|
61
|
+
cd my-repo
|
|
62
|
+
pudicus install
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**3. Configure your scanners:**
|
|
66
|
+
Create a `.pudicus.yml` file in the root of your repository to define what must pass before a commit is signed:
|
|
67
|
+
```yaml
|
|
68
|
+
version: 1
|
|
69
|
+
checkers:
|
|
70
|
+
- name: gitleaks
|
|
71
|
+
type: command
|
|
72
|
+
command: gitleaks protect --staged
|
|
73
|
+
success_codes: [0]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**4. Add the deploy gate to CI/CD:**
|
|
77
|
+
In your deployment pipeline (e.g., GitHub Actions), verify the incoming commits:
|
|
78
|
+
```bash
|
|
79
|
+
pudicus verify HEAD~5..HEAD
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Custom Agent-Based Scanning (Tactus)
|
|
85
|
+
|
|
86
|
+
While standard tools like Gitleaks are great for finding AWS IAM keys, they struggle with subtle, context-dependent leaks—like mentioning a confidential client's name or proprietary business logic.
|
|
87
|
+
|
|
88
|
+
Pudicus natively supports **Tactus procedures** for agent-based code review.
|
|
89
|
+
|
|
90
|
+
### Example: Protecting Confidential Client Names
|
|
91
|
+
Imagine you have a list of highly confidential clients that should never be mentioned in your repository's source code.
|
|
92
|
+
|
|
93
|
+
1. Create a `.gitignored` file named `confidential_clients.txt`.
|
|
94
|
+
2. Write a Tactus procedure (e.g., `check_clients`) that reads `confidential_clients.txt` and scans the staged files to ensure none of those names appear.
|
|
95
|
+
3. Add the procedure to your `.pudicus.yml`:
|
|
96
|
+
|
|
97
|
+
```yaml
|
|
98
|
+
version: 1
|
|
99
|
+
checkers:
|
|
100
|
+
- name: tactus-confidentiality-scan
|
|
101
|
+
type: tactus
|
|
102
|
+
procedure: check_clients
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
When a commit is made, Pudicus will invoke Tactus. If Tactus finds a leaked client name, it exits with an error, Pudicus blocks the commit, and prompts a human for an override password. If the code is clean, the signature is minted and the commit proceeds.
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Deep Dive: How it works
|
|
110
|
+
|
|
111
|
+
### 1. The Commit Hook
|
|
112
|
+
Instead of forcing complex asymmetric cryptography on developers, Pudicus uses a simple shared HMAC secret.
|
|
113
|
+
|
|
114
|
+
When a commit is created, Pudicus intercepts it:
|
|
115
|
+
1. It computes the hash of the git tree (the actual code).
|
|
116
|
+
2. It runs the scanners against that tree.
|
|
117
|
+
3. If clean, it generates an HMAC signature and appends it to the commit message as Git trailers.
|
|
118
|
+
|
|
119
|
+
```text
|
|
120
|
+
Inspected-by: pudicus-v1
|
|
121
|
+
Inspection-tree: f18588e0c5f5b0a5ba281b5a78242127919d2388
|
|
122
|
+
Inspection-result: clean
|
|
123
|
+
Inspection-at: 2026-08-31T17:01:34Z
|
|
124
|
+
Inspection-sig: hmac-sha256:8cce6e3714782d025eb4ec4aec755...
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### 2. Retroactive Approval (Signature Pooling)
|
|
128
|
+
Because Git commit hashes change if you modify a commit message, you cannot simply add signatures to old commits without rewriting history (e.g., `git rebase`).
|
|
129
|
+
|
|
130
|
+
To solve this, Pudicus ties the signature to the **Tree Hash** (the codebase state) rather than the **Commit Hash**.
|
|
131
|
+
|
|
132
|
+
If an agent bypasses the hook with `--no-verify`, or if you are onboarding an older project, you can run `pudicus approve`. This creates an empty "paperwork" commit at the tip of your branch that holds the signatures for the older commits.
|
|
133
|
+
|
|
134
|
+
```mermaid
|
|
135
|
+
flowchart LR
|
|
136
|
+
C1["Commit 1<br/>Tree: A<br/>Signed: Yes"] --> C2["Commit 2<br/>Tree: B<br/>Signed: No"]
|
|
137
|
+
C2 --> C3["Commit 3<br/>Tree: C<br/>Signed: No"]
|
|
138
|
+
C3 --> AC["Approval Commit<br/>Signs Trees: B, C"]
|
|
139
|
+
|
|
140
|
+
AC --> Gate{"Deploy Gate<br/>pudicus verify"}
|
|
141
|
+
Gate -->|Pools valid signatures| Check["Are Trees A, B, and C in the pool?"]
|
|
142
|
+
Check -->|Yes| Deploy["Deploy Success"]
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
This provides a clean escape hatch that achieves full compliance without destroying Git history.
|
pudicus-0.1.1/README.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Pudicus
|
|
2
|
+
|
|
3
|
+
Pudicus ("modest, chaste, keeping pure") is a standalone, pluggable inspection gate for Git commits.
|
|
4
|
+
|
|
5
|
+
It was built to **shift left** the problem of secrets and confidential information leaking into source code. While it is technically possible to rewrite git history later (e.g., using `git filter-repo`) to scrub leaked keys, it is vastly better to catch the information *before* it ever gets committed to the local repository in the first place.
|
|
6
|
+
|
|
7
|
+
In the era of agentic coding, a specific problem arises: **How do we ensure that autonomous AI agents (or hurried humans) actually run the necessary security checks before committing code?**
|
|
8
|
+
|
|
9
|
+
Pudicus bridges the gap between agent automation and deployment safety by acting like an **agricultural inspector's produce sticker**. It intercepts and blocks secrets at the staging area, generating a cryptographically verifiable receipt proving the code was scanned. A downstream deploy gate then prevents the truck from unloading if the stickers are missing.
|
|
10
|
+
|
|
11
|
+
```mermaid
|
|
12
|
+
flowchart LR
|
|
13
|
+
Agent["Agent / Developer"] -->|Writes Code| Hook["Pudicus Git Hook"]
|
|
14
|
+
|
|
15
|
+
subgraph Pudicus Validation
|
|
16
|
+
Hook -->|Runs| Scanners["Scanners<br/>(Gitleaks, Tactus, etc.)"]
|
|
17
|
+
Scanners -->|Clean| Sig["Cryptographic Signature<br/>(HMAC-SHA256)"]
|
|
18
|
+
Scanners -->|Issues Found| Block["Commit Blocked"]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
Sig --> Commit["Signed Commit"]
|
|
22
|
+
|
|
23
|
+
Agent -.->|Bypasses hook| Unsigned["Unsigned Commit"]
|
|
24
|
+
|
|
25
|
+
Commit --> Gate{"Deploy Gate<br/>(CI/CD)"}
|
|
26
|
+
Unsigned -.-> Gate
|
|
27
|
+
|
|
28
|
+
Gate -->|Valid Signature| Prod[("(Production)")]
|
|
29
|
+
Gate -->|No Signature| Reject["Deploy Rejected"]
|
|
30
|
+
|
|
31
|
+
style Prod fill:#ccffcc,stroke:#00aa00
|
|
32
|
+
style Reject fill:#ffcccc,stroke:#ff0000
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Because the agent does not have access to the cryptographic secret required to mint the signature, the absolute easiest path for an agent to get its code deployed is to simply let the hook run the scanners.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Quick Start
|
|
40
|
+
|
|
41
|
+
Pudicus is built in Python and requires `git` on the host machine.
|
|
42
|
+
|
|
43
|
+
**1. Install the CLI:**
|
|
44
|
+
```bash
|
|
45
|
+
pip install pudicus
|
|
46
|
+
```
|
|
47
|
+
*(Note: Until published to PyPI, use `pip install git+ssh://git@github.com/AnthusAI/Pudicus.git`)*
|
|
48
|
+
|
|
49
|
+
**2. Initialize a repository:**
|
|
50
|
+
Run this in your target repository. It generates the shared secret and installs the `.git/hooks/commit-msg` hook.
|
|
51
|
+
```bash
|
|
52
|
+
cd my-repo
|
|
53
|
+
pudicus install
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**3. Configure your scanners:**
|
|
57
|
+
Create a `.pudicus.yml` file in the root of your repository to define what must pass before a commit is signed:
|
|
58
|
+
```yaml
|
|
59
|
+
version: 1
|
|
60
|
+
checkers:
|
|
61
|
+
- name: gitleaks
|
|
62
|
+
type: command
|
|
63
|
+
command: gitleaks protect --staged
|
|
64
|
+
success_codes: [0]
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**4. Add the deploy gate to CI/CD:**
|
|
68
|
+
In your deployment pipeline (e.g., GitHub Actions), verify the incoming commits:
|
|
69
|
+
```bash
|
|
70
|
+
pudicus verify HEAD~5..HEAD
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Custom Agent-Based Scanning (Tactus)
|
|
76
|
+
|
|
77
|
+
While standard tools like Gitleaks are great for finding AWS IAM keys, they struggle with subtle, context-dependent leaks—like mentioning a confidential client's name or proprietary business logic.
|
|
78
|
+
|
|
79
|
+
Pudicus natively supports **Tactus procedures** for agent-based code review.
|
|
80
|
+
|
|
81
|
+
### Example: Protecting Confidential Client Names
|
|
82
|
+
Imagine you have a list of highly confidential clients that should never be mentioned in your repository's source code.
|
|
83
|
+
|
|
84
|
+
1. Create a `.gitignored` file named `confidential_clients.txt`.
|
|
85
|
+
2. Write a Tactus procedure (e.g., `check_clients`) that reads `confidential_clients.txt` and scans the staged files to ensure none of those names appear.
|
|
86
|
+
3. Add the procedure to your `.pudicus.yml`:
|
|
87
|
+
|
|
88
|
+
```yaml
|
|
89
|
+
version: 1
|
|
90
|
+
checkers:
|
|
91
|
+
- name: tactus-confidentiality-scan
|
|
92
|
+
type: tactus
|
|
93
|
+
procedure: check_clients
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
When a commit is made, Pudicus will invoke Tactus. If Tactus finds a leaked client name, it exits with an error, Pudicus blocks the commit, and prompts a human for an override password. If the code is clean, the signature is minted and the commit proceeds.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Deep Dive: How it works
|
|
101
|
+
|
|
102
|
+
### 1. The Commit Hook
|
|
103
|
+
Instead of forcing complex asymmetric cryptography on developers, Pudicus uses a simple shared HMAC secret.
|
|
104
|
+
|
|
105
|
+
When a commit is created, Pudicus intercepts it:
|
|
106
|
+
1. It computes the hash of the git tree (the actual code).
|
|
107
|
+
2. It runs the scanners against that tree.
|
|
108
|
+
3. If clean, it generates an HMAC signature and appends it to the commit message as Git trailers.
|
|
109
|
+
|
|
110
|
+
```text
|
|
111
|
+
Inspected-by: pudicus-v1
|
|
112
|
+
Inspection-tree: f18588e0c5f5b0a5ba281b5a78242127919d2388
|
|
113
|
+
Inspection-result: clean
|
|
114
|
+
Inspection-at: 2026-08-31T17:01:34Z
|
|
115
|
+
Inspection-sig: hmac-sha256:8cce6e3714782d025eb4ec4aec755...
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### 2. Retroactive Approval (Signature Pooling)
|
|
119
|
+
Because Git commit hashes change if you modify a commit message, you cannot simply add signatures to old commits without rewriting history (e.g., `git rebase`).
|
|
120
|
+
|
|
121
|
+
To solve this, Pudicus ties the signature to the **Tree Hash** (the codebase state) rather than the **Commit Hash**.
|
|
122
|
+
|
|
123
|
+
If an agent bypasses the hook with `--no-verify`, or if you are onboarding an older project, you can run `pudicus approve`. This creates an empty "paperwork" commit at the tip of your branch that holds the signatures for the older commits.
|
|
124
|
+
|
|
125
|
+
```mermaid
|
|
126
|
+
flowchart LR
|
|
127
|
+
C1["Commit 1<br/>Tree: A<br/>Signed: Yes"] --> C2["Commit 2<br/>Tree: B<br/>Signed: No"]
|
|
128
|
+
C2 --> C3["Commit 3<br/>Tree: C<br/>Signed: No"]
|
|
129
|
+
C3 --> AC["Approval Commit<br/>Signs Trees: B, C"]
|
|
130
|
+
|
|
131
|
+
AC --> Gate{"Deploy Gate<br/>pudicus verify"}
|
|
132
|
+
Gate -->|Pools valid signatures| Check["Are Trees A, B, and C in the pool?"]
|
|
133
|
+
Check -->|Yes| Deploy["Deploy Success"]
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
This provides a clean escape hatch that achieves full compliance without destroying Git history.
|
|
File without changes
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
import subprocess
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pudicus.core import (
|
|
8
|
+
get_secret, compute_hmac, get_tree_hash, get_commit_tree_hash,
|
|
9
|
+
load_config, execute_checkers, get_trailer, run_cmd, SECRET_FILE_DEFAULT
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
def print_info(msg):
|
|
13
|
+
print(f"\033[1;34m[pudicus]\033[0m {msg}", file=sys.stderr)
|
|
14
|
+
|
|
15
|
+
def print_warn(msg):
|
|
16
|
+
print(f"\033[1;33m[pudicus]\033[0m {msg}", file=sys.stderr)
|
|
17
|
+
|
|
18
|
+
def print_err(msg):
|
|
19
|
+
print(f"\033[1;31m[pudicus ERROR]\033[0m {msg}", file=sys.stderr)
|
|
20
|
+
|
|
21
|
+
def cmd_install(args):
|
|
22
|
+
"""Setup the git hook and secret."""
|
|
23
|
+
repo_root = run_cmd(["git", "rev-parse", "--show-toplevel"]).stdout.strip()
|
|
24
|
+
hook_path = os.path.join(repo_root, ".git", "hooks", "commit-msg")
|
|
25
|
+
|
|
26
|
+
if os.path.exists(hook_path):
|
|
27
|
+
print_warn(f"Hook already exists at {hook_path}. Overwrite? [y/N]")
|
|
28
|
+
if input().lower() != 'y':
|
|
29
|
+
print_info("Skipped hook installation.")
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
# Install the wrapper that calls pudicus hook
|
|
33
|
+
hook_script = f"""#!/usr/bin/env bash
|
|
34
|
+
# Pudicus commit-msg hook
|
|
35
|
+
pudicus hook "$1"
|
|
36
|
+
"""
|
|
37
|
+
with open(hook_path, 'w') as f:
|
|
38
|
+
f.write(hook_script)
|
|
39
|
+
os.chmod(hook_path, 0o755)
|
|
40
|
+
print_info(f"Installed git hook at {hook_path}")
|
|
41
|
+
|
|
42
|
+
# Generate secret if needed
|
|
43
|
+
secret_dir = os.path.dirname(SECRET_FILE_DEFAULT)
|
|
44
|
+
if not os.path.exists(SECRET_FILE_DEFAULT):
|
|
45
|
+
os.makedirs(secret_dir, exist_ok=True)
|
|
46
|
+
secret = os.urandom(16).hex()
|
|
47
|
+
with open(SECRET_FILE_DEFAULT, 'w') as f:
|
|
48
|
+
f.write(secret)
|
|
49
|
+
os.chmod(SECRET_FILE_DEFAULT, 0o600)
|
|
50
|
+
print_info(f"Generated new shared secret at {SECRET_FILE_DEFAULT}")
|
|
51
|
+
else:
|
|
52
|
+
print_info(f"Shared secret already exists at {SECRET_FILE_DEFAULT}")
|
|
53
|
+
|
|
54
|
+
def cmd_hook(args):
|
|
55
|
+
"""Run checkers on staged files and sign the commit."""
|
|
56
|
+
repo_root = run_cmd(["git", "rev-parse", "--show-toplevel"]).stdout.strip()
|
|
57
|
+
config = load_config(repo_root)
|
|
58
|
+
|
|
59
|
+
if not config or 'checkers' not in config:
|
|
60
|
+
print_warn("No .pudicus.yml found or no checkers defined. Skipping inspection.")
|
|
61
|
+
sys.exit(0)
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
secret = get_secret()
|
|
65
|
+
except FileNotFoundError:
|
|
66
|
+
print_err("Shared secret not found. Run 'pudicus install' first.")
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
|
|
69
|
+
tree_hash = get_tree_hash()
|
|
70
|
+
print_info(f"Scanning tree {tree_hash}...")
|
|
71
|
+
|
|
72
|
+
results = execute_checkers(config['checkers'])
|
|
73
|
+
|
|
74
|
+
all_clean = True
|
|
75
|
+
failed_checkers = []
|
|
76
|
+
for res in results:
|
|
77
|
+
if not res.clean:
|
|
78
|
+
all_clean = False
|
|
79
|
+
failed_checkers.append(res)
|
|
80
|
+
|
|
81
|
+
validator_str = f"pudicus-v{config.get('version', '1')}"
|
|
82
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
83
|
+
|
|
84
|
+
if all_clean:
|
|
85
|
+
result_str = "clean"
|
|
86
|
+
print_info("✓ All checks passed.")
|
|
87
|
+
else:
|
|
88
|
+
print_warn(f"✗ Found issues in {len(failed_checkers)} checker(s):")
|
|
89
|
+
for res in failed_checkers:
|
|
90
|
+
print_warn(f" - {res.name}:")
|
|
91
|
+
if isinstance(res.findings, list):
|
|
92
|
+
for f in res.findings:
|
|
93
|
+
desc = f.get('Description', 'Unknown')
|
|
94
|
+
path = f.get('File', 'Unknown')
|
|
95
|
+
print(f" File: {path} -> {desc}", file=sys.stderr)
|
|
96
|
+
else:
|
|
97
|
+
print(f" {res.findings}", file=sys.stderr)
|
|
98
|
+
|
|
99
|
+
if not sys.stdin.isatty():
|
|
100
|
+
print_err("Findings require human approval but no interactive terminal is available. Commit blocked.")
|
|
101
|
+
sys.exit(1)
|
|
102
|
+
|
|
103
|
+
print_warn("Commit will be blocked unless you approve these findings.")
|
|
104
|
+
password = input(f"\033[1m[pudicus]\033[0m Enter approval passphrase to override: ").strip()
|
|
105
|
+
|
|
106
|
+
if password != secret:
|
|
107
|
+
print_err("Incorrect passphrase. Commit blocked.")
|
|
108
|
+
sys.exit(1)
|
|
109
|
+
|
|
110
|
+
result_str = f"override:{len(failed_checkers)}-checkers"
|
|
111
|
+
print_info("✓ Override approved.")
|
|
112
|
+
|
|
113
|
+
hmac_sig = compute_hmac(tree_hash, validator_str, result_str, timestamp, secret)
|
|
114
|
+
|
|
115
|
+
# Append trailers
|
|
116
|
+
msg_file = args.commit_msg_file
|
|
117
|
+
with open(msg_file, 'r') as f:
|
|
118
|
+
msg_content = f.read()
|
|
119
|
+
|
|
120
|
+
proc = subprocess.run(
|
|
121
|
+
["git", "interpret-trailers",
|
|
122
|
+
f"--trailer=Inspected-by: {validator_str}",
|
|
123
|
+
f"--trailer=Inspection-tree: {tree_hash}",
|
|
124
|
+
f"--trailer=Inspection-result: {result_str}",
|
|
125
|
+
f"--trailer=Inspection-at: {timestamp}",
|
|
126
|
+
f"--trailer=Inspection-sig: hmac-sha256:{hmac_sig}"],
|
|
127
|
+
input=msg_content, text=True, capture_output=True, check=True
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
with open(msg_file, 'w') as f:
|
|
131
|
+
f.write(proc.stdout)
|
|
132
|
+
|
|
133
|
+
print_info(f"✓ Commit signed: hmac-sha256:{hmac_sig[:16]}...")
|
|
134
|
+
|
|
135
|
+
def cmd_approve(args):
|
|
136
|
+
"""Retroactively approve commits by generating a paperwork commit."""
|
|
137
|
+
repo_root = run_cmd(["git", "rev-parse", "--show-toplevel"]).stdout.strip()
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
secret = get_secret()
|
|
141
|
+
except FileNotFoundError:
|
|
142
|
+
print_err("Shared secret not found. Run 'pudicus install'.")
|
|
143
|
+
sys.exit(1)
|
|
144
|
+
|
|
145
|
+
range_str = args.range
|
|
146
|
+
if ".." in range_str:
|
|
147
|
+
commits = run_cmd(["git", "rev-list", "--reverse", range_str]).stdout.splitlines()
|
|
148
|
+
else:
|
|
149
|
+
commits = [run_cmd(["git", "rev-parse", range_str]).stdout.strip()]
|
|
150
|
+
|
|
151
|
+
if not commits:
|
|
152
|
+
print_info("No commits found in range.")
|
|
153
|
+
sys.exit(0)
|
|
154
|
+
|
|
155
|
+
config = load_config(repo_root)
|
|
156
|
+
if not config or 'checkers' not in config:
|
|
157
|
+
print_err("No .pudicus.yml found.")
|
|
158
|
+
sys.exit(1)
|
|
159
|
+
|
|
160
|
+
validator_str = f"pudicus-v{config.get('version', '1')}"
|
|
161
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
162
|
+
|
|
163
|
+
trailers = []
|
|
164
|
+
|
|
165
|
+
for sha in commits:
|
|
166
|
+
short = sha[:7]
|
|
167
|
+
print_info(f"Checking commit {short}...")
|
|
168
|
+
actual_tree = get_commit_tree_hash(sha)
|
|
169
|
+
|
|
170
|
+
# Set up a worktree for the commit
|
|
171
|
+
import tempfile
|
|
172
|
+
wt_dir = tempfile.mkdtemp()
|
|
173
|
+
run_cmd(["git", "worktree", "add", "--detach", wt_dir, sha])
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
# We want to scan the diff introduced by this commit, so we soft reset to its parent.
|
|
177
|
+
parent = run_cmd(["git", "rev-parse", f"{sha}~1"], check=False).stdout.strip()
|
|
178
|
+
if not parent:
|
|
179
|
+
# Root commit
|
|
180
|
+
empty_tree = run_cmd(["git", "hash-object", "-t", "tree", "/dev/null"]).stdout.strip()
|
|
181
|
+
run_cmd(["git", "reset", "--soft", empty_tree], cwd=wt_dir)
|
|
182
|
+
else:
|
|
183
|
+
run_cmd(["git", "reset", "--soft", parent], cwd=wt_dir)
|
|
184
|
+
|
|
185
|
+
results = execute_checkers(config['checkers'], cwd=wt_dir)
|
|
186
|
+
finally:
|
|
187
|
+
run_cmd(["git", "worktree", "remove", "-f", wt_dir])
|
|
188
|
+
|
|
189
|
+
all_clean = True
|
|
190
|
+
failed_checkers = []
|
|
191
|
+
for res in results:
|
|
192
|
+
if not res.clean:
|
|
193
|
+
all_clean = False
|
|
194
|
+
failed_checkers.append(res)
|
|
195
|
+
|
|
196
|
+
if all_clean:
|
|
197
|
+
result_str = "clean"
|
|
198
|
+
else:
|
|
199
|
+
print_warn(f"✗ Found issues in {short} for {len(failed_checkers)} checker(s).")
|
|
200
|
+
if not sys.stdin.isatty():
|
|
201
|
+
print_err("Findings require human approval but no interactive terminal is available. Approval blocked.")
|
|
202
|
+
sys.exit(1)
|
|
203
|
+
|
|
204
|
+
password = input(f"\033[1m[pudicus]\033[0m Enter approval passphrase to override for {short}: ").strip()
|
|
205
|
+
if password != secret:
|
|
206
|
+
print_err("Incorrect passphrase. Approval blocked.")
|
|
207
|
+
sys.exit(1)
|
|
208
|
+
result_str = f"override:{len(failed_checkers)}-checkers"
|
|
209
|
+
|
|
210
|
+
hmac_sig = compute_hmac(actual_tree, validator_str, result_str, timestamp, secret)
|
|
211
|
+
|
|
212
|
+
trailers.append(f"--trailer=Inspected-by: {validator_str}")
|
|
213
|
+
trailers.append(f"--trailer=Inspection-tree: {actual_tree}")
|
|
214
|
+
trailers.append(f"--trailer=Inspection-result: {result_str}")
|
|
215
|
+
trailers.append(f"--trailer=Inspection-at: {timestamp}")
|
|
216
|
+
trailers.append(f"--trailer=Inspection-sig: hmac-sha256:{hmac_sig}")
|
|
217
|
+
|
|
218
|
+
# Create the approval commit
|
|
219
|
+
commit_msg = "chore: retroactive pudicus approval\n"
|
|
220
|
+
cmd = ["git", "commit", "--allow-empty", "-F", "-"] + trailers
|
|
221
|
+
proc = subprocess.run(cmd, input=commit_msg, text=True, capture_output=True, check=True)
|
|
222
|
+
print_info(f"Created approval commit:\n{proc.stdout.strip()}")
|
|
223
|
+
|
|
224
|
+
def cmd_verify(args):
|
|
225
|
+
"""Verify signatures for a commit range."""
|
|
226
|
+
try:
|
|
227
|
+
secret = get_secret()
|
|
228
|
+
except FileNotFoundError:
|
|
229
|
+
print_err("Shared secret not found. Run 'pudicus install'.")
|
|
230
|
+
sys.exit(1)
|
|
231
|
+
|
|
232
|
+
range_str = args.range
|
|
233
|
+
if ".." in range_str:
|
|
234
|
+
commits = run_cmd(["git", "rev-list", range_str]).stdout.splitlines()
|
|
235
|
+
else:
|
|
236
|
+
commits = [run_cmd(["git", "rev-parse", range_str]).stdout.strip()]
|
|
237
|
+
|
|
238
|
+
# Pool all valid signatures found in the range
|
|
239
|
+
valid_trees = set()
|
|
240
|
+
|
|
241
|
+
for sha in commits:
|
|
242
|
+
# Get all trailers from the commit message
|
|
243
|
+
msg = run_cmd(["git", "log", "-1", sha, "--format=%B"]).stdout
|
|
244
|
+
# Basic parsing of trailers. git log format for multiple identical keys is tricky,
|
|
245
|
+
# so we parse the raw message.
|
|
246
|
+
import re
|
|
247
|
+
trees = re.findall(r"^Inspection-tree:\s*(.+)$", msg, re.MULTILINE)
|
|
248
|
+
sigs = re.findall(r"^Inspection-sig:\s*hmac-sha256:(.+)$", msg, re.MULTILINE)
|
|
249
|
+
validators = re.findall(r"^Inspected-by:\s*(.+)$", msg, re.MULTILINE)
|
|
250
|
+
results = re.findall(r"^Inspection-result:\s*(.+)$", msg, re.MULTILINE)
|
|
251
|
+
timestamps = re.findall(r"^Inspection-at:\s*(.+)$", msg, re.MULTILINE)
|
|
252
|
+
|
|
253
|
+
# Zip them up (assuming they appear in blocks)
|
|
254
|
+
for t, s, v, r, ts in zip(trees, sigs, validators, results, timestamps):
|
|
255
|
+
expected = compute_hmac(t, v, r, ts, secret)
|
|
256
|
+
if s == expected:
|
|
257
|
+
valid_trees.add(t)
|
|
258
|
+
|
|
259
|
+
failures = 0
|
|
260
|
+
for sha in commits:
|
|
261
|
+
short = sha[:7]
|
|
262
|
+
actual_tree = get_commit_tree_hash(sha)
|
|
263
|
+
|
|
264
|
+
if actual_tree not in valid_trees:
|
|
265
|
+
print_err(f"{short}: No valid signature found for tree {actual_tree[:12]}.")
|
|
266
|
+
failures += 1
|
|
267
|
+
continue
|
|
268
|
+
|
|
269
|
+
subject = run_cmd(["git", "log", "-1", sha, "--format=%s"]).stdout.strip()[:50]
|
|
270
|
+
print(f"\033[1;32mPASS:\033[0m {short}: {subject} [verified]")
|
|
271
|
+
|
|
272
|
+
if failures > 0:
|
|
273
|
+
print_err(f"Verification failed: {failures}/{len(commits)} commit(s) invalid.")
|
|
274
|
+
sys.exit(1)
|
|
275
|
+
else:
|
|
276
|
+
print(f"\033[1;32mAll {len(commits)} commit(s) verified.\033[0m")
|
|
277
|
+
|
|
278
|
+
def main():
|
|
279
|
+
parser = argparse.ArgumentParser(description="Pudicus - A pluggable inspection gate for Git commits")
|
|
280
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
281
|
+
|
|
282
|
+
install_p = subparsers.add_parser("install", help="Install the commit-msg hook and setup secrets")
|
|
283
|
+
|
|
284
|
+
hook_p = subparsers.add_parser("hook", help="Run checkers (used internally by git hook)")
|
|
285
|
+
hook_p.add_argument("commit_msg_file", help="Path to the commit message file")
|
|
286
|
+
|
|
287
|
+
verify_p = subparsers.add_parser("verify", help="Verify commits")
|
|
288
|
+
verify_p.add_argument("range", nargs="?", default="HEAD", help="Commit range (e.g. HEAD~3..HEAD)")
|
|
289
|
+
|
|
290
|
+
approve_p = subparsers.add_parser("approve", help="Retroactively approve commits")
|
|
291
|
+
approve_p.add_argument("range", help="Commit range to approve")
|
|
292
|
+
|
|
293
|
+
args = parser.parse_args()
|
|
294
|
+
|
|
295
|
+
if args.command == "install":
|
|
296
|
+
cmd_install(args)
|
|
297
|
+
elif args.command == "hook":
|
|
298
|
+
cmd_hook(args)
|
|
299
|
+
elif args.command == "verify":
|
|
300
|
+
cmd_verify(args)
|
|
301
|
+
elif args.command == "approve":
|
|
302
|
+
cmd_approve(args)
|
|
303
|
+
|
|
304
|
+
if __name__ == "__main__":
|
|
305
|
+
main()
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import subprocess
|
|
4
|
+
import hmac
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import List, Dict, Any, Tuple
|
|
10
|
+
import tempfile
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
SECRET_FILE_DEFAULT = os.environ.get("PUDICUS_SECRET_PATH", os.path.expanduser("~/.config/pudicus/secret"))
|
|
14
|
+
|
|
15
|
+
def get_secret(secret_path: str = SECRET_FILE_DEFAULT) -> str:
|
|
16
|
+
if not os.path.exists(secret_path):
|
|
17
|
+
raise FileNotFoundError(f"Secret file not found at {secret_path}")
|
|
18
|
+
with open(secret_path, 'r') as f:
|
|
19
|
+
return f.read().strip()
|
|
20
|
+
|
|
21
|
+
def compute_hmac(tree_hash: str, validator: str, result: str, timestamp: str, secret: str) -> str:
|
|
22
|
+
msg = f"{tree_hash}|{validator}|{result}|{timestamp}".encode('utf-8')
|
|
23
|
+
return hmac.new(secret.encode('utf-8'), msg, hashlib.sha256).hexdigest()
|
|
24
|
+
|
|
25
|
+
def run_cmd(cmd: List[str], check: bool = True, capture_output: bool = True, **kwargs) -> subprocess.CompletedProcess:
|
|
26
|
+
return subprocess.run(cmd, check=check, capture_output=capture_output, text=True, **kwargs)
|
|
27
|
+
|
|
28
|
+
def get_tree_hash() -> str:
|
|
29
|
+
return run_cmd(["git", "write-tree"]).stdout.strip()
|
|
30
|
+
|
|
31
|
+
def get_commit_tree_hash(commit_sha: str) -> str:
|
|
32
|
+
output = run_cmd(["git", "cat-file", "-p", commit_sha]).stdout
|
|
33
|
+
for line in output.splitlines():
|
|
34
|
+
if line.startswith("tree "):
|
|
35
|
+
return line.split(" ")[1].strip()
|
|
36
|
+
return ""
|
|
37
|
+
|
|
38
|
+
def load_config(repo_root: str) -> Dict[str, Any]:
|
|
39
|
+
config_path = os.path.join(repo_root, ".pudicus.yml")
|
|
40
|
+
if not os.path.exists(config_path):
|
|
41
|
+
return {}
|
|
42
|
+
with open(config_path, 'r') as f:
|
|
43
|
+
return yaml.safe_load(f) or {}
|
|
44
|
+
|
|
45
|
+
class CheckerResult:
|
|
46
|
+
def __init__(self, name: str, clean: bool, findings: Any = None):
|
|
47
|
+
self.name = name
|
|
48
|
+
self.clean = clean
|
|
49
|
+
self.findings = findings
|
|
50
|
+
|
|
51
|
+
def run_command_checker(checker: Dict[str, Any], cwd: str = None) -> CheckerResult:
|
|
52
|
+
name = checker.get('name', 'unknown')
|
|
53
|
+
cmd = checker.get('command', '')
|
|
54
|
+
success_codes = checker.get('success_codes', [0])
|
|
55
|
+
|
|
56
|
+
# Replace {report_file} if present
|
|
57
|
+
report_file = None
|
|
58
|
+
if "{report_file}" in cmd:
|
|
59
|
+
fd, report_file = tempfile.mkstemp(suffix=".json")
|
|
60
|
+
os.close(fd)
|
|
61
|
+
cmd = cmd.replace("{report_file}", report_file)
|
|
62
|
+
|
|
63
|
+
import shlex
|
|
64
|
+
try:
|
|
65
|
+
proc = run_cmd(shlex.split(cmd), check=False, capture_output=True, cwd=cwd)
|
|
66
|
+
is_clean = proc.returncode in success_codes
|
|
67
|
+
|
|
68
|
+
findings = None
|
|
69
|
+
if not is_clean and report_file and os.path.exists(report_file):
|
|
70
|
+
try:
|
|
71
|
+
with open(report_file, 'r') as f:
|
|
72
|
+
findings = json.load(f)
|
|
73
|
+
except Exception:
|
|
74
|
+
findings = f"Exit code {proc.returncode}\nStdout: {proc.stdout}\nStderr: {proc.stderr}"
|
|
75
|
+
elif not is_clean:
|
|
76
|
+
findings = f"Exit code {proc.returncode}\nStdout: {proc.stdout}\nStderr: {proc.stderr}"
|
|
77
|
+
|
|
78
|
+
return CheckerResult(name, is_clean, findings)
|
|
79
|
+
finally:
|
|
80
|
+
if report_file and os.path.exists(report_file):
|
|
81
|
+
os.remove(report_file)
|
|
82
|
+
|
|
83
|
+
def run_tactus_checker(checker: Dict[str, Any], cwd: str = None) -> CheckerResult:
|
|
84
|
+
name = checker.get('name', 'tactus-scan')
|
|
85
|
+
procedure = checker.get('procedure')
|
|
86
|
+
if not procedure:
|
|
87
|
+
return CheckerResult(name, False, "No procedure specified for tactus checker")
|
|
88
|
+
|
|
89
|
+
# Mocking tactus invocation based on common patterns
|
|
90
|
+
cmd = ["tactus", "run", procedure]
|
|
91
|
+
proc = run_cmd(cmd, check=False, capture_output=True, cwd=cwd)
|
|
92
|
+
is_clean = proc.returncode == 0
|
|
93
|
+
return CheckerResult(name, is_clean, proc.stdout if not is_clean else None)
|
|
94
|
+
|
|
95
|
+
def execute_checkers(checkers: List[Dict[str, Any]], cwd: str = None) -> List[CheckerResult]:
|
|
96
|
+
results = []
|
|
97
|
+
for checker in checkers:
|
|
98
|
+
ctype = checker.get('type', 'command')
|
|
99
|
+
if ctype == 'command':
|
|
100
|
+
results.append(run_command_checker(checker, cwd=cwd))
|
|
101
|
+
elif ctype == 'tactus':
|
|
102
|
+
results.append(run_tactus_checker(checker, cwd=cwd))
|
|
103
|
+
else:
|
|
104
|
+
results.append(CheckerResult(checker.get('name', 'unknown'), False, f"Unknown checker type: {ctype}"))
|
|
105
|
+
return results
|
|
106
|
+
|
|
107
|
+
def get_trailer(commit_sha: str, key: str) -> str:
|
|
108
|
+
return run_cmd(["git", "log", "-1", commit_sha, f"--format=%(trailers:key={key},valueonly)"]).stdout.strip()
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pudicus
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A pluggable inspection gate for Git commits
|
|
5
|
+
Requires-Python: >=3.8
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: PyYAML
|
|
8
|
+
Requires-Dist: behave
|
|
9
|
+
|
|
10
|
+
# Pudicus
|
|
11
|
+
|
|
12
|
+
Pudicus ("modest, chaste, keeping pure") is a standalone, pluggable inspection gate for Git commits.
|
|
13
|
+
|
|
14
|
+
It was built to **shift left** the problem of secrets and confidential information leaking into source code. While it is technically possible to rewrite git history later (e.g., using `git filter-repo`) to scrub leaked keys, it is vastly better to catch the information *before* it ever gets committed to the local repository in the first place.
|
|
15
|
+
|
|
16
|
+
In the era of agentic coding, a specific problem arises: **How do we ensure that autonomous AI agents (or hurried humans) actually run the necessary security checks before committing code?**
|
|
17
|
+
|
|
18
|
+
Pudicus bridges the gap between agent automation and deployment safety by acting like an **agricultural inspector's produce sticker**. It intercepts and blocks secrets at the staging area, generating a cryptographically verifiable receipt proving the code was scanned. A downstream deploy gate then prevents the truck from unloading if the stickers are missing.
|
|
19
|
+
|
|
20
|
+
```mermaid
|
|
21
|
+
flowchart LR
|
|
22
|
+
Agent["Agent / Developer"] -->|Writes Code| Hook["Pudicus Git Hook"]
|
|
23
|
+
|
|
24
|
+
subgraph Pudicus Validation
|
|
25
|
+
Hook -->|Runs| Scanners["Scanners<br/>(Gitleaks, Tactus, etc.)"]
|
|
26
|
+
Scanners -->|Clean| Sig["Cryptographic Signature<br/>(HMAC-SHA256)"]
|
|
27
|
+
Scanners -->|Issues Found| Block["Commit Blocked"]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
Sig --> Commit["Signed Commit"]
|
|
31
|
+
|
|
32
|
+
Agent -.->|Bypasses hook| Unsigned["Unsigned Commit"]
|
|
33
|
+
|
|
34
|
+
Commit --> Gate{"Deploy Gate<br/>(CI/CD)"}
|
|
35
|
+
Unsigned -.-> Gate
|
|
36
|
+
|
|
37
|
+
Gate -->|Valid Signature| Prod[("(Production)")]
|
|
38
|
+
Gate -->|No Signature| Reject["Deploy Rejected"]
|
|
39
|
+
|
|
40
|
+
style Prod fill:#ccffcc,stroke:#00aa00
|
|
41
|
+
style Reject fill:#ffcccc,stroke:#ff0000
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Because the agent does not have access to the cryptographic secret required to mint the signature, the absolute easiest path for an agent to get its code deployed is to simply let the hook run the scanners.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Quick Start
|
|
49
|
+
|
|
50
|
+
Pudicus is built in Python and requires `git` on the host machine.
|
|
51
|
+
|
|
52
|
+
**1. Install the CLI:**
|
|
53
|
+
```bash
|
|
54
|
+
pip install pudicus
|
|
55
|
+
```
|
|
56
|
+
*(Note: Until published to PyPI, use `pip install git+ssh://git@github.com/AnthusAI/Pudicus.git`)*
|
|
57
|
+
|
|
58
|
+
**2. Initialize a repository:**
|
|
59
|
+
Run this in your target repository. It generates the shared secret and installs the `.git/hooks/commit-msg` hook.
|
|
60
|
+
```bash
|
|
61
|
+
cd my-repo
|
|
62
|
+
pudicus install
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**3. Configure your scanners:**
|
|
66
|
+
Create a `.pudicus.yml` file in the root of your repository to define what must pass before a commit is signed:
|
|
67
|
+
```yaml
|
|
68
|
+
version: 1
|
|
69
|
+
checkers:
|
|
70
|
+
- name: gitleaks
|
|
71
|
+
type: command
|
|
72
|
+
command: gitleaks protect --staged
|
|
73
|
+
success_codes: [0]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**4. Add the deploy gate to CI/CD:**
|
|
77
|
+
In your deployment pipeline (e.g., GitHub Actions), verify the incoming commits:
|
|
78
|
+
```bash
|
|
79
|
+
pudicus verify HEAD~5..HEAD
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Custom Agent-Based Scanning (Tactus)
|
|
85
|
+
|
|
86
|
+
While standard tools like Gitleaks are great for finding AWS IAM keys, they struggle with subtle, context-dependent leaks—like mentioning a confidential client's name or proprietary business logic.
|
|
87
|
+
|
|
88
|
+
Pudicus natively supports **Tactus procedures** for agent-based code review.
|
|
89
|
+
|
|
90
|
+
### Example: Protecting Confidential Client Names
|
|
91
|
+
Imagine you have a list of highly confidential clients that should never be mentioned in your repository's source code.
|
|
92
|
+
|
|
93
|
+
1. Create a `.gitignored` file named `confidential_clients.txt`.
|
|
94
|
+
2. Write a Tactus procedure (e.g., `check_clients`) that reads `confidential_clients.txt` and scans the staged files to ensure none of those names appear.
|
|
95
|
+
3. Add the procedure to your `.pudicus.yml`:
|
|
96
|
+
|
|
97
|
+
```yaml
|
|
98
|
+
version: 1
|
|
99
|
+
checkers:
|
|
100
|
+
- name: tactus-confidentiality-scan
|
|
101
|
+
type: tactus
|
|
102
|
+
procedure: check_clients
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
When a commit is made, Pudicus will invoke Tactus. If Tactus finds a leaked client name, it exits with an error, Pudicus blocks the commit, and prompts a human for an override password. If the code is clean, the signature is minted and the commit proceeds.
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Deep Dive: How it works
|
|
110
|
+
|
|
111
|
+
### 1. The Commit Hook
|
|
112
|
+
Instead of forcing complex asymmetric cryptography on developers, Pudicus uses a simple shared HMAC secret.
|
|
113
|
+
|
|
114
|
+
When a commit is created, Pudicus intercepts it:
|
|
115
|
+
1. It computes the hash of the git tree (the actual code).
|
|
116
|
+
2. It runs the scanners against that tree.
|
|
117
|
+
3. If clean, it generates an HMAC signature and appends it to the commit message as Git trailers.
|
|
118
|
+
|
|
119
|
+
```text
|
|
120
|
+
Inspected-by: pudicus-v1
|
|
121
|
+
Inspection-tree: f18588e0c5f5b0a5ba281b5a78242127919d2388
|
|
122
|
+
Inspection-result: clean
|
|
123
|
+
Inspection-at: 2026-08-31T17:01:34Z
|
|
124
|
+
Inspection-sig: hmac-sha256:8cce6e3714782d025eb4ec4aec755...
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### 2. Retroactive Approval (Signature Pooling)
|
|
128
|
+
Because Git commit hashes change if you modify a commit message, you cannot simply add signatures to old commits without rewriting history (e.g., `git rebase`).
|
|
129
|
+
|
|
130
|
+
To solve this, Pudicus ties the signature to the **Tree Hash** (the codebase state) rather than the **Commit Hash**.
|
|
131
|
+
|
|
132
|
+
If an agent bypasses the hook with `--no-verify`, or if you are onboarding an older project, you can run `pudicus approve`. This creates an empty "paperwork" commit at the tip of your branch that holds the signatures for the older commits.
|
|
133
|
+
|
|
134
|
+
```mermaid
|
|
135
|
+
flowchart LR
|
|
136
|
+
C1["Commit 1<br/>Tree: A<br/>Signed: Yes"] --> C2["Commit 2<br/>Tree: B<br/>Signed: No"]
|
|
137
|
+
C2 --> C3["Commit 3<br/>Tree: C<br/>Signed: No"]
|
|
138
|
+
C3 --> AC["Approval Commit<br/>Signs Trees: B, C"]
|
|
139
|
+
|
|
140
|
+
AC --> Gate{"Deploy Gate<br/>pudicus verify"}
|
|
141
|
+
Gate -->|Pools valid signatures| Check["Are Trees A, B, and C in the pool?"]
|
|
142
|
+
Check -->|Yes| Deploy["Deploy Success"]
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
This provides a clean escape hatch that achieves full compliance without destroying Git history.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
pudicus/__init__.py
|
|
4
|
+
pudicus/cli.py
|
|
5
|
+
pudicus/core.py
|
|
6
|
+
pudicus.egg-info/PKG-INFO
|
|
7
|
+
pudicus.egg-info/SOURCES.txt
|
|
8
|
+
pudicus.egg-info/dependency_links.txt
|
|
9
|
+
pudicus.egg-info/entry_points.txt
|
|
10
|
+
pudicus.egg-info/requires.txt
|
|
11
|
+
pudicus.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pudicus
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=42"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pudicus"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "A pluggable inspection gate for Git commits"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"PyYAML",
|
|
13
|
+
"behave",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[tool.setuptools]
|
|
17
|
+
packages = ["pudicus"]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
pudicus = "pudicus.cli:main"
|
|
21
|
+
|
|
22
|
+
[tool.semantic_release]
|
|
23
|
+
version_toml = ["pyproject.toml:project.version"]
|
|
24
|
+
branch = "main"
|
|
25
|
+
upload_to_pypi = true
|
|
26
|
+
upload_to_release = true
|
|
27
|
+
build_command = "python -m pip install build && python -m build"
|
pudicus-0.1.1/setup.cfg
ADDED